| OLD | NEW |
| 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 Loading... |
| 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 Loading... |
| 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 Loading... |
| 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 Loading... |
| 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) |
| OLD | NEW |