Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(363)

Side by Side Diff: owners.py

Issue 2148153002: Fix per-file owners check for deleted files. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Rebase! Created 4 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « git_cl.py ('k') | owners_finder.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 # Copyright (c) 2012 The Chromium Authors. All rights reserved. 1 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be 2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file. 3 # found in the LICENSE file.
4 4
5 """A database of OWNERS files. 5 """A database of OWNERS files.
6 6
7 OWNERS files indicate who is allowed to approve changes in a specific directory 7 OWNERS files indicate who is allowed to approve changes in a specific directory
8 (or who is allowed to make changes without needing approval of another OWNER). 8 (or who is allowed to make changes without needing approval of another OWNER).
9 Note that all changes must still be reviewed by someone familiar with the code, 9 Note that all changes must still be reviewed by someone familiar with the code,
10 so you may need approval from both an OWNER and a reviewer in many cases. 10 so you may need approval from both an OWNER and a reviewer in many cases.
(...skipping 37 matching lines...) Expand 10 before | Expand all | Expand 10 after
48 48
49 If the "file:" directive is used, the referred to OWNERS file will be parsed and 49 If the "file:" directive is used, the referred to OWNERS file will be parsed and
50 considered when determining the valid set of OWNERS. If the filename starts with 50 considered when determining the valid set of OWNERS. If the filename starts with
51 "//" it is relative to the root of the repository, otherwise it is relative to 51 "//" it is relative to the root of the repository, otherwise it is relative to
52 the current file 52 the current file
53 53
54 Examples for all of these combinations can be found in tests/owners_unittest.py. 54 Examples for all of these combinations can be found in tests/owners_unittest.py.
55 """ 55 """
56 56
57 import collections 57 import collections
58 import fnmatch
58 import random 59 import random
59 import re 60 import re
60 61
61 62
62 # If this is present by itself on a line, this means that everyone can review. 63 # If this is present by itself on a line, this means that everyone can review.
63 EVERYONE = '*' 64 EVERYONE = '*'
64 65
65 66
66 # Recognizes 'X@Y' email addresses. Very simplistic. 67 # Recognizes 'X@Y' email addresses. Very simplistic.
67 BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$' 68 BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
(...skipping 19 matching lines...) Expand all
87 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg) 88 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
88 89
89 90
90 class Database(object): 91 class Database(object):
91 """A database of OWNERS files for a repository. 92 """A database of OWNERS files for a repository.
92 93
93 This class allows you to find a suggested set of reviewers for a list 94 This class allows you to find a suggested set of reviewers for a list
94 of changed files, and see if a list of changed files is covered by a 95 of changed files, and see if a list of changed files is covered by a
95 list of reviewers.""" 96 list of reviewers."""
96 97
97 def __init__(self, root, fopen, os_path, glob): 98 def __init__(self, root, fopen, os_path):
98 """Args: 99 """Args:
99 root: the path to the root of the Repository 100 root: the path to the root of the Repository
100 open: function callback to open a text file for reading 101 open: function callback to open a text file for reading
101 os_path: module/object callback with fields for 'abspath', 'dirname', 102 os_path: module/object callback with fields for 'abspath', 'dirname',
102 'exists', 'join', and 'relpath' 103 'exists', 'join', and 'relpath'
103 glob: function callback to list entries in a directory match a glob
104 (i.e., glob.glob)
105 """ 104 """
106 self.root = root 105 self.root = root
107 self.fopen = fopen 106 self.fopen = fopen
108 self.os_path = os_path 107 self.os_path = os_path
109 self.glob = glob
110 108
111 # Pick a default email regexp to use; callers can override as desired. 109 # Pick a default email regexp to use; callers can override as desired.
112 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP) 110 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
113 111
114 # Mapping of owners to the paths they own. 112 # Mapping of owners to the paths or globs they own.
115 self.owned_by = {EVERYONE: set()} 113 self._owners_to_paths = {EVERYONE: set()}
116 114
117 # Mapping of paths to authorized owners. 115 # Mapping of paths to authorized owners.
118 self.owners_for = {} 116 self._paths_to_owners = {}
119 117
120 # Mapping reviewers to the preceding comment per file in the OWNERS files. 118 # Mapping reviewers to the preceding comment per file in the OWNERS files.
121 self.comments = {} 119 self.comments = {}
122 120
123 # Set of paths that stop us from looking above them for owners. 121 # Set of paths that stop us from looking above them for owners.
124 # (This is implicitly true for the root directory). 122 # (This is implicitly true for the root directory).
125 self.stop_looking = set(['']) 123 self._stop_looking = set([''])
126 124
127 # Set of files which have already been read. 125 # Set of files which have already been read.
128 self.read_files = set() 126 self.read_files = set()
129 127
130 def reviewers_for(self, files, author): 128 def reviewers_for(self, files, author):
131 """Returns a suggested set of reviewers that will cover the files. 129 """Returns a suggested set of reviewers that will cover the files.
132 130
133 files is a sequence of paths relative to (and under) self.root. 131 files is a sequence of paths relative to (and under) self.root.
134 If author is nonempty, we ensure it is not included in the set returned 132 If author is nonempty, we ensure it is not included in the set returned
135 in order avoid suggesting the author as a reviewer for their own changes.""" 133 in order avoid suggesting the author as a reviewer for their own changes."""
136 self._check_paths(files) 134 self._check_paths(files)
137 self.load_data_needed_for(files) 135 self.load_data_needed_for(files)
136
138 suggested_owners = self._covering_set_of_owners_for(files, author) 137 suggested_owners = self._covering_set_of_owners_for(files, author)
139 if EVERYONE in suggested_owners: 138 if EVERYONE in suggested_owners:
140 if len(suggested_owners) > 1: 139 if len(suggested_owners) > 1:
141 suggested_owners.remove(EVERYONE) 140 suggested_owners.remove(EVERYONE)
142 else: 141 else:
143 suggested_owners = set(['<anyone>']) 142 suggested_owners = set(['<anyone>'])
144 return suggested_owners 143 return suggested_owners
145 144
146 def files_not_covered_by(self, files, reviewers): 145 def files_not_covered_by(self, files, reviewers):
147 """Returns the files not owned by one of the reviewers. 146 """Returns the files not owned by one of the reviewers.
148 147
149 Args: 148 Args:
150 files is a sequence of paths relative to (and under) self.root. 149 files is a sequence of paths relative to (and under) self.root.
151 reviewers is a sequence of strings matching self.email_regexp. 150 reviewers is a sequence of strings matching self.email_regexp.
152 """ 151 """
153 self._check_paths(files) 152 self._check_paths(files)
154 self._check_reviewers(reviewers) 153 self._check_reviewers(reviewers)
155 self.load_data_needed_for(files) 154 self.load_data_needed_for(files)
156 155
157 covered_objs = self._objs_covered_by(reviewers) 156 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
158 uncovered_files = [f for f in files
159 if not self._is_obj_covered_by(f, covered_objs)]
160
161 return set(uncovered_files)
162 157
163 def _check_paths(self, files): 158 def _check_paths(self, files):
164 def _is_under(f, pfx): 159 def _is_under(f, pfx):
165 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx) 160 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
166 _assert_is_collection(files) 161 _assert_is_collection(files)
167 assert all(not self.os_path.isabs(f) and 162 assert all(not self.os_path.isabs(f) and
168 _is_under(f, self.os_path.abspath(self.root)) for f in files) 163 _is_under(f, self.os_path.abspath(self.root)) for f in files)
169 164
170 def _check_reviewers(self, reviewers): 165 def _check_reviewers(self, reviewers):
171 _assert_is_collection(reviewers) 166 _assert_is_collection(reviewers)
172 assert all(self.email_regexp.match(r) for r in reviewers) 167 assert all(self.email_regexp.match(r) for r in reviewers)
173 168
174 def _objs_covered_by(self, reviewers): 169 def _is_obj_covered_by(self, objname, reviewers):
175 objs = self.owned_by[EVERYONE] 170 reviewers = list(reviewers) + [EVERYONE]
176 for r in reviewers: 171 while True:
177 objs = objs | self.owned_by.get(r, set()) 172 for reviewer in reviewers:
178 return objs 173 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
179 174 if fnmatch.fnmatch(objname, owned_pattern):
180 def _stop_looking(self, objname): 175 return True
181 return objname in self.stop_looking 176 if self._should_stop_looking(objname):
182 177 break
183 def _is_obj_covered_by(self, objname, covered_objs):
184 while not objname in covered_objs and not self._stop_looking(objname):
185 objname = self.os_path.dirname(objname) 178 objname = self.os_path.dirname(objname)
186 return objname in covered_objs 179 return False
187 180
188 def _enclosing_dir_with_owners(self, objname): 181 def _enclosing_dir_with_owners(self, objname):
189 """Returns the innermost enclosing directory that has an OWNERS file.""" 182 """Returns the innermost enclosing directory that has an OWNERS file."""
190 dirpath = objname 183 dirpath = objname
191 while not dirpath in self.owners_for: 184 while not self._owners_for(dirpath):
192 if self._stop_looking(dirpath): 185 if self._should_stop_looking(dirpath):
193 break 186 break
194 dirpath = self.os_path.dirname(dirpath) 187 dirpath = self.os_path.dirname(dirpath)
195 return dirpath 188 return dirpath
196 189
197 def load_data_needed_for(self, files): 190 def load_data_needed_for(self, files):
198 for f in files: 191 for f in files:
199 dirpath = self.os_path.dirname(f) 192 dirpath = self.os_path.dirname(f)
200 while not dirpath in self.owners_for: 193 while not self._owners_for(dirpath):
201 self._read_owners(self.os_path.join(dirpath, 'OWNERS')) 194 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
202 if self._stop_looking(dirpath): 195 if self._should_stop_looking(dirpath):
203 break 196 break
204 dirpath = self.os_path.dirname(dirpath) 197 dirpath = self.os_path.dirname(dirpath)
205 198
199 def _should_stop_looking(self, objname):
200 return any(fnmatch.fnmatch(objname, stop_looking)
201 for stop_looking in self._stop_looking)
202
203 def _owners_for(self, objname):
204 obj_owners = set()
205 for owned_path, path_owners in self._paths_to_owners.iteritems():
206 if fnmatch.fnmatch(objname, owned_path):
207 obj_owners |= path_owners
208 return obj_owners
209
206 def _read_owners(self, path): 210 def _read_owners(self, path):
207 owners_path = self.os_path.join(self.root, path) 211 owners_path = self.os_path.join(self.root, path)
208 if not self.os_path.exists(owners_path): 212 if not self.os_path.exists(owners_path):
209 return 213 return
210 214
211 if owners_path in self.read_files: 215 if owners_path in self.read_files:
212 return 216 return
213 217
214 self.read_files.add(owners_path) 218 self.read_files.add(owners_path)
215 219
216 comment = [] 220 comment = []
217 dirpath = self.os_path.dirname(path) 221 dirpath = self.os_path.dirname(path)
218 in_comment = False 222 in_comment = False
219 lineno = 0 223 lineno = 0
220 for line in self.fopen(owners_path): 224 for line in self.fopen(owners_path):
221 lineno += 1 225 lineno += 1
222 line = line.strip() 226 line = line.strip()
223 if line.startswith('#'): 227 if line.startswith('#'):
224 if not in_comment: 228 if not in_comment:
225 comment = [] 229 comment = []
226 comment.append(line[1:].strip()) 230 comment.append(line[1:].strip())
227 in_comment = True 231 in_comment = True
228 continue 232 continue
229 if line == '': 233 if line == '':
230 continue 234 continue
231 in_comment = False 235 in_comment = False
232 236
233 if line == 'set noparent': 237 if line == 'set noparent':
234 self.stop_looking.add(dirpath) 238 self._stop_looking.add(dirpath)
235 continue 239 continue
236 240
237 m = re.match('per-file (.+)=(.+)', line) 241 m = re.match('per-file (.+)=(.+)', line)
238 if m: 242 if m:
239 glob_string = m.group(1).strip() 243 glob_string = m.group(1).strip()
240 directive = m.group(2).strip() 244 directive = m.group(2).strip()
241 full_glob_string = self.os_path.join(self.root, dirpath, glob_string) 245 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
242 if '/' in glob_string or '\\' in glob_string: 246 if '/' in glob_string or '\\' in glob_string:
243 raise SyntaxErrorInOwnersFile(owners_path, lineno, 247 raise SyntaxErrorInOwnersFile(owners_path, lineno,
244 'per-file globs cannot span directories or use escapes: "%s"' % 248 'per-file globs cannot span directories or use escapes: "%s"' %
245 line) 249 line)
246 baselines = self.glob(full_glob_string) 250 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
247 for baseline in (self.os_path.relpath(b, self.root) for b in baselines): 251 self._add_entry(relative_glob_string, directive, 'per-file line',
248 self._add_entry(baseline, directive, 'per-file line', 252 owners_path, lineno, '\n'.join(comment))
249 owners_path, lineno, '\n'.join(comment))
250 continue 253 continue
251 254
252 if line.startswith('set '): 255 if line.startswith('set '):
253 raise SyntaxErrorInOwnersFile(owners_path, lineno, 256 raise SyntaxErrorInOwnersFile(owners_path, lineno,
254 'unknown option: "%s"' % line[4:].strip()) 257 'unknown option: "%s"' % line[4:].strip())
255 258
256 self._add_entry(dirpath, line, 'line', owners_path, lineno, 259 self._add_entry(dirpath, line, 'line', owners_path, lineno,
257 ' '.join(comment)) 260 ' '.join(comment))
258 261
259 def _add_entry(self, path, directive, 262 def _add_entry(self, path, directive,
260 line_type, owners_path, lineno, comment): 263 line_type, owners_path, lineno, comment):
261 if directive == 'set noparent': 264 if directive == 'set noparent':
262 self.stop_looking.add(path) 265 self._stop_looking.add(path)
263 elif directive.startswith('file:'): 266 elif directive.startswith('file:'):
264 owners_file = self._resolve_include(directive[5:], owners_path) 267 owners_file = self._resolve_include(directive[5:], owners_path)
265 if not owners_file: 268 if not owners_file:
266 raise SyntaxErrorInOwnersFile(owners_path, lineno, 269 raise SyntaxErrorInOwnersFile(owners_path, lineno,
267 ('%s does not refer to an existing file.' % directive[5:])) 270 ('%s does not refer to an existing file.' % directive[5:]))
268 271
269 self._read_owners(owners_file) 272 self._read_owners(owners_file)
270 273
271 dirpath = self.os_path.dirname(owners_file) 274 dirpath = self.os_path.dirname(owners_file)
272 for key in self.owned_by: 275 for key in self._owners_to_paths:
273 if not dirpath in self.owned_by[key]: 276 if not dirpath in self._owners_to_paths[key]:
274 continue 277 continue
275 self.owned_by[key].add(path) 278 self._owners_to_paths[key].add(path)
276 279
277 if dirpath in self.owners_for: 280 if dirpath in self._paths_to_owners:
278 self.owners_for.setdefault(path, set()).update(self.owners_for[dirpath]) 281 self._paths_to_owners.setdefault(path, set()).update(
282 self._paths_to_owners[dirpath])
279 283
280 elif self.email_regexp.match(directive) or directive == EVERYONE: 284 elif self.email_regexp.match(directive) or directive == EVERYONE:
281 self.comments.setdefault(directive, {}) 285 self.comments.setdefault(directive, {})
282 self.comments[directive][path] = comment 286 self.comments[directive][path] = comment
283 self.owned_by.setdefault(directive, set()).add(path) 287 self._owners_to_paths.setdefault(directive, set()).add(path)
284 self.owners_for.setdefault(path, set()).add(directive) 288 self._paths_to_owners.setdefault(path, set()).add(directive)
285 else: 289 else:
286 raise SyntaxErrorInOwnersFile(owners_path, lineno, 290 raise SyntaxErrorInOwnersFile(owners_path, lineno,
287 ('%s is not a "set" directive, file include, "*", ' 291 ('%s is not a "set" directive, file include, "*", '
288 'or an email address: "%s"' % (line_type, directive))) 292 'or an email address: "%s"' % (line_type, directive)))
289 293
290 def _resolve_include(self, path, start): 294 def _resolve_include(self, path, start):
291 if path.startswith('//'): 295 if path.startswith('//'):
292 include_path = path[2:] 296 include_path = path[2:]
293 else: 297 else:
294 assert start.startswith(self.root) 298 assert start.startswith(self.root)
(...skipping 19 matching lines...) Expand all
314 318
315 def all_possible_owners(self, dirs, author): 319 def all_possible_owners(self, dirs, author):
316 """Returns a list of (potential owner, distance-from-dir) tuples; a 320 """Returns a list of (potential owner, distance-from-dir) tuples; a
317 distance of 1 is the lowest/closest possible distance (which makes the 321 distance of 1 is the lowest/closest possible distance (which makes the
318 subsequent math easier).""" 322 subsequent math easier)."""
319 all_possible_owners = {} 323 all_possible_owners = {}
320 for current_dir in dirs: 324 for current_dir in dirs:
321 dirname = current_dir 325 dirname = current_dir
322 distance = 1 326 distance = 1
323 while True: 327 while True:
324 for owner in self.owners_for.get(dirname, []): 328 for owner in self._owners_for(dirname):
325 if author and owner == author: 329 if author and owner == author:
326 continue 330 continue
327 all_possible_owners.setdefault(owner, []) 331 all_possible_owners.setdefault(owner, [])
328 # If the same person is in multiple OWNERS files above a given 332 # If the same person is in multiple OWNERS files above a given
329 # directory, only count the closest one. 333 # directory, only count the closest one.
330 if not any(current_dir == el[0] for el in all_possible_owners[owner]): 334 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
331 all_possible_owners[owner].append((current_dir, distance)) 335 all_possible_owners[owner].append((current_dir, distance))
332 if self._stop_looking(dirname): 336 if self._should_stop_looking(dirname):
333 break 337 break
334 dirname = self.os_path.dirname(dirname) 338 dirname = self.os_path.dirname(dirname)
335 distance += 1 339 distance += 1
336 return all_possible_owners 340 return all_possible_owners
337 341
338 @staticmethod 342 @staticmethod
339 def total_costs_by_owner(all_possible_owners, dirs): 343 def total_costs_by_owner(all_possible_owners, dirs):
340 # We want to minimize both the number of reviewers and the distance 344 # We want to minimize both the number of reviewers and the distance
341 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is 345 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
342 # an arbitrarily-selected scaling factor that seems to work well - it 346 # an arbitrarily-selected scaling factor that seems to work well - it
(...skipping 15 matching lines...) Expand all
358 @staticmethod 362 @staticmethod
359 def lowest_cost_owner(all_possible_owners, dirs): 363 def lowest_cost_owner(all_possible_owners, dirs):
360 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners, 364 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
361 dirs) 365 dirs)
362 # Return the lowest cost owner. In the case of a tie, pick one randomly. 366 # Return the lowest cost owner. In the case of a tie, pick one randomly.
363 lowest_cost = min(total_costs_by_owner.itervalues()) 367 lowest_cost = min(total_costs_by_owner.itervalues())
364 lowest_cost_owners = filter( 368 lowest_cost_owners = filter(
365 lambda owner: total_costs_by_owner[owner] == lowest_cost, 369 lambda owner: total_costs_by_owner[owner] == lowest_cost,
366 total_costs_by_owner) 370 total_costs_by_owner)
367 return random.Random().choice(lowest_cost_owners) 371 return random.Random().choice(lowest_cost_owners)
OLDNEW
« no previous file with comments | « git_cl.py ('k') | owners_finder.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698