Chromium Code Reviews| 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 | |
| 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). | |
| 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. | |
| 11 | |
| 12 The syntax of the OWNERS file is, roughly: | |
| 13 | |
| 14 lines := (\s* line? \s* "\n")* | |
| 15 | |
| 16 line := directive | |
| 17 | "per-file" \s+ glob "=" directive | |
| 18 | comment | |
| 19 | |
| 20 directive := "set noparent" | |
| 21 | email_address | |
| 22 | "*" | |
| 23 | |
| 24 glob := [a-zA-Z0-9_-*?]+ | |
| 25 | |
| 26 comment := "#" [^"\n"]* | |
| 27 | |
| 28 Email addresses must follow the foo@bar.com short form (exact syntax given | |
| 29 in BASIC_EMAIL_REGEXP, below). Filename globs follow the simple unix | |
| 30 shell conventions, and relative and absolute paths are not allowed (i.e., | |
| 31 globs only refer to the files in the current directory). | |
| 32 | |
| 33 If a user's email is one of the email_addresses in the file, the user is | |
| 34 considered an "OWNER" for all files in the directory. | |
| 35 | |
| 36 If the "per-file" directive is used, the line only applies to files in that | |
| 37 directory that match the filename glob specified. | |
| 38 | |
| 39 If the "set noparent" directive used, then only entries in this OWNERS file | |
| 40 apply to files in this directory; if the "set noparent" directive is not | |
| 41 used, then entries in OWNERS files in enclosing (upper) directories also | |
| 42 apply (up until a "set noparent is encountered")> | |
|
M-A Ruel
2012/10/17 20:07:07
s/>/./ ?
Dirk Pranke
2012/10/17 21:08:36
Good catch. Done.
| |
| 43 | |
| 44 If "per-file glob=set noparent" is used, then global directives are ignored | |
| 45 for the glob, and only the "per-file" owners are used for files matching that | |
| 46 glob. | |
| 47 | |
| 48 Examples for all of these combinations can be found in tests/owners_unittest.py. | |
| 49 """ | |
| 6 | 50 |
| 7 import collections | 51 import collections |
| 8 import re | 52 import re |
| 9 | 53 |
| 10 | 54 |
| 11 # If this is present by itself on a line, this means that everyone can review. | 55 # If this is present by itself on a line, this means that everyone can review. |
| 12 EVERYONE = '*' | 56 EVERYONE = '*' |
| 13 | 57 |
| 14 | 58 |
| 15 # Recognizes 'X@Y' email addresses. Very simplistic. | 59 # Recognizes 'X@Y' email addresses. Very simplistic. |
| (...skipping 20 matching lines...) Expand all Loading... | |
| 36 return "%s:%d syntax error: %s" % (self.path, self.lineno, self.msg) | 80 return "%s:%d syntax error: %s" % (self.path, self.lineno, self.msg) |
| 37 | 81 |
| 38 | 82 |
| 39 class Database(object): | 83 class Database(object): |
| 40 """A database of OWNERS files for a repository. | 84 """A database of OWNERS files for a repository. |
| 41 | 85 |
| 42 This class allows you to find a suggested set of reviewers for a list | 86 This class allows you to find a suggested set of reviewers for a list |
| 43 of changed files, and see if a list of changed files is covered by a | 87 of changed files, and see if a list of changed files is covered by a |
| 44 list of reviewers.""" | 88 list of reviewers.""" |
| 45 | 89 |
| 46 def __init__(self, root, fopen, os_path): | 90 def __init__(self, root, fopen, os_path, glob): |
| 47 """Args: | 91 """Args: |
| 48 root: the path to the root of the Repository | 92 root: the path to the root of the Repository |
| 49 open: function callback to open a text file for reading | 93 open: function callback to open a text file for reading |
| 50 os_path: module/object callback with fields for 'abspath', 'dirname', | 94 os_path: module/object callback with fields for 'abspath', 'dirname', |
| 51 'exists', and 'join' | 95 'exists', and 'join' |
| 96 glob: function callback to list entries in a directory match a glob | |
| 97 (i.e., glob.glob) | |
| 52 """ | 98 """ |
| 53 self.root = root | 99 self.root = root |
| 54 self.fopen = fopen | 100 self.fopen = fopen |
| 55 self.os_path = os_path | 101 self.os_path = os_path |
| 102 self.glob = glob | |
| 56 | 103 |
| 57 # Pick a default email regexp to use; callers can override as desired. | 104 # Pick a default email regexp to use; callers can override as desired. |
| 58 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP) | 105 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP) |
| 59 | 106 |
| 60 # Mapping of owners to the paths they own. | 107 # Mapping of owners to the paths they own. |
| 61 self.owned_by = {EVERYONE: set()} | 108 self.owned_by = {EVERYONE: set()} |
| 62 | 109 |
| 63 # Mapping of paths to authorized owners. | 110 # Mapping of paths to authorized owners. |
| 64 self.owners_for = {} | 111 self.owners_for = {} |
| 65 | 112 |
| 66 # Set of paths that stop us from looking above them for owners. | 113 # Set of paths that stop us from looking above them for owners. |
| 67 # (This is implicitly true for the root directory). | 114 # (This is implicitly true for the root directory). |
| 68 self.stop_looking = set(['']) | 115 self.stop_looking = set(['']) |
| 69 | 116 |
| 70 def reviewers_for(self, files): | 117 def reviewers_for(self, files): |
| 71 """Returns a suggested set of reviewers that will cover the files. | 118 """Returns a suggested set of reviewers that will cover the files. |
| 72 | 119 |
| 73 files is a sequence of paths relative to (and under) self.root.""" | 120 files is a sequence of paths relative to (and under) self.root.""" |
| 74 self._check_paths(files) | 121 self._check_paths(files) |
| 75 self._load_data_needed_for(files) | 122 self._load_data_needed_for(files) |
| 76 return self._covering_set_of_owners_for(files) | 123 return self._covering_set_of_owners_for(files) |
| 77 | 124 |
| 125 # TODO(dpranke): rename to objects_not_covered_by | |
| 78 def directories_not_covered_by(self, files, reviewers): | 126 def directories_not_covered_by(self, files, reviewers): |
| 79 """Returns the set of directories that are not owned by a reviewer. | 127 """Returns the set of directories that are not owned by a reviewer. |
| 80 | 128 |
| 81 Determines which of the given files are not owned by at least one of the | 129 Determines which of the given files are not owned by at least one of the |
| 82 reviewers, then returns a set containing the applicable enclosing | 130 reviewers, then returns a set containing the applicable enclosing |
| 83 directories, i.e. the ones upward from the files that have OWNERS files. | 131 directories, i.e. the ones upward from the files that have OWNERS files. |
| 84 | 132 |
| 85 Args: | 133 Args: |
| 86 files is a sequence of paths relative to (and under) self.root. | 134 files is a sequence of paths relative to (and under) self.root. |
| 87 reviewers is a sequence of strings matching self.email_regexp. | 135 reviewers is a sequence of strings matching self.email_regexp. |
| 88 """ | 136 """ |
| 89 self._check_paths(files) | 137 self._check_paths(files) |
| 90 self._check_reviewers(reviewers) | 138 self._check_reviewers(reviewers) |
| 91 self._load_data_needed_for(files) | 139 self._load_data_needed_for(files) |
| 92 | 140 |
| 93 dirs = set([self.os_path.dirname(f) for f in files]) | 141 objs = set() |
| 94 covered_dirs = self._dirs_covered_by(reviewers) | 142 for f in files: |
| 95 uncovered_dirs = [self._enclosing_dir_with_owners(d) for d in dirs | 143 if f in self.owners_for: |
| 96 if not self._is_dir_covered_by(d, covered_dirs)] | 144 objs.add(f) |
| 145 else: | |
| 146 objs.add(self.os_path.dirname(f)) | |
| 97 | 147 |
| 98 return set(uncovered_dirs) | 148 covered_objs = self._objs_covered_by(reviewers) |
| 149 uncovered_objs = [self._enclosing_obj_with_owners(o) for o in objs | |
| 150 if not self._is_obj_covered_by(o, covered_objs)] | |
| 151 | |
| 152 return set(uncovered_objs) | |
| 153 | |
| 154 objects_not_covered_by = directories_not_covered_by | |
| 99 | 155 |
| 100 def _check_paths(self, files): | 156 def _check_paths(self, files): |
| 101 def _is_under(f, pfx): | 157 def _is_under(f, pfx): |
| 102 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx) | 158 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx) |
| 103 _assert_is_collection(files) | 159 _assert_is_collection(files) |
| 104 assert all(_is_under(f, self.os_path.abspath(self.root)) for f in files) | 160 assert all(_is_under(f, self.os_path.abspath(self.root)) for f in files) |
| 105 | 161 |
| 106 def _check_reviewers(self, reviewers): | 162 def _check_reviewers(self, reviewers): |
| 107 _assert_is_collection(reviewers) | 163 _assert_is_collection(reviewers) |
| 108 assert all(self.email_regexp.match(r) for r in reviewers) | 164 assert all(self.email_regexp.match(r) for r in reviewers) |
| 109 | 165 |
| 166 # TODO(dpranke): Rename to _objs_covered_by and update_callers | |
| 110 def _dirs_covered_by(self, reviewers): | 167 def _dirs_covered_by(self, reviewers): |
| 111 dirs = self.owned_by[EVERYONE] | 168 dirs = self.owned_by[EVERYONE] |
| 112 for r in reviewers: | 169 for r in reviewers: |
| 113 dirs = dirs | self.owned_by.get(r, set()) | 170 dirs = dirs | self.owned_by.get(r, set()) |
| 114 return dirs | 171 return dirs |
| 115 | 172 |
| 173 _objs_covered_by = _dirs_covered_by | |
| 174 | |
| 116 def _stop_looking(self, dirname): | 175 def _stop_looking(self, dirname): |
| 117 return dirname in self.stop_looking | 176 return dirname in self.stop_looking |
| 118 | 177 |
| 178 # TODO(dpranke): Rename to _is_dir_covered_by and update callers. | |
| 119 def _is_dir_covered_by(self, dirname, covered_dirs): | 179 def _is_dir_covered_by(self, dirname, covered_dirs): |
| 120 while not dirname in covered_dirs and not self._stop_looking(dirname): | 180 while not dirname in covered_dirs and not self._stop_looking(dirname): |
| 121 dirname = self.os_path.dirname(dirname) | 181 dirname = self.os_path.dirname(dirname) |
| 122 return dirname in covered_dirs | 182 return dirname in covered_dirs |
| 123 | 183 |
| 184 _is_obj_covered_by = _is_dir_covered_by | |
| 185 | |
| 186 # TODO(dpranke): Rename to _enclosing_obj_with_owners and update callers. | |
| 124 def _enclosing_dir_with_owners(self, directory): | 187 def _enclosing_dir_with_owners(self, directory): |
| 125 """Returns the innermost enclosing directory that has an OWNERS file.""" | 188 """Returns the innermost enclosing directory that has an OWNERS file.""" |
| 126 dirpath = directory | 189 dirpath = directory |
| 127 while not dirpath in self.owners_for: | 190 while not dirpath in self.owners_for: |
| 128 if self._stop_looking(dirpath): | 191 if self._stop_looking(dirpath): |
| 129 break | 192 break |
| 130 dirpath = self.os_path.dirname(dirpath) | 193 dirpath = self.os_path.dirname(dirpath) |
| 131 return dirpath | 194 return dirpath |
| 132 | 195 |
| 196 _enclosing_obj_with_owners = _enclosing_dir_with_owners | |
| 197 | |
| 133 def _load_data_needed_for(self, files): | 198 def _load_data_needed_for(self, files): |
| 134 for f in files: | 199 for f in files: |
| 135 dirpath = self.os_path.dirname(f) | 200 dirpath = self.os_path.dirname(f) |
| 136 while not dirpath in self.owners_for: | 201 while not dirpath in self.owners_for: |
| 137 self._read_owners_in_dir(dirpath) | 202 self._read_owners_in_dir(dirpath) |
| 138 if self._stop_looking(dirpath): | 203 if self._stop_looking(dirpath): |
| 139 break | 204 break |
| 140 dirpath = self.os_path.dirname(dirpath) | 205 dirpath = self.os_path.dirname(dirpath) |
| 141 | 206 |
| 142 def _read_owners_in_dir(self, dirpath): | 207 def _read_owners_in_dir(self, dirpath): |
| 143 owners_path = self.os_path.join(self.root, dirpath, 'OWNERS') | 208 owners_path = self.os_path.join(self.root, dirpath, 'OWNERS') |
| 144 if not self.os_path.exists(owners_path): | 209 if not self.os_path.exists(owners_path): |
| 145 return | 210 return |
| 146 | 211 |
| 147 lineno = 0 | 212 lineno = 0 |
| 148 for line in self.fopen(owners_path): | 213 for line in self.fopen(owners_path): |
| 149 lineno += 1 | 214 lineno += 1 |
| 150 line = line.strip() | 215 line = line.strip() |
| 151 if line.startswith('#') or line == '': | 216 if line.startswith('#') or line == '': |
| 152 continue | 217 continue |
| 153 if line == 'set noparent': | 218 if line == 'set noparent': |
| 154 self.stop_looking.add(dirpath) | 219 self.stop_looking.add(dirpath) |
| 155 continue | 220 continue |
| 221 | |
| 222 m = re.match("per-file (.+)=(.+)", line) | |
| 223 if m: | |
| 224 glob_string = m.group(1) | |
| 225 directive = m.group(2) | |
| 226 full_glob_string = self.os_path.join(self.root, dirpath, glob_string) | |
| 227 baselines = self.glob(full_glob_string) | |
| 228 for baseline in (self.os_path.relpath(self.root, b) for b in baselines): | |
| 229 self._add_entry(baseline, directive, "per-file line", | |
| 230 owners_path, lineno) | |
| 231 continue | |
| 232 | |
| 156 if line.startswith('set '): | 233 if line.startswith('set '): |
| 157 raise SyntaxErrorInOwnersFile(owners_path, lineno, | 234 raise SyntaxErrorInOwnersFile(owners_path, lineno, |
| 158 'unknown option: "%s"' % line[4:].strip()) | 235 'unknown option: "%s"' % line[4:].strip()) |
| 159 if self.email_regexp.match(line) or line == EVERYONE: | 236 |
| 160 self.owned_by.setdefault(line, set()).add(dirpath) | 237 self._add_entry(dirpath, line, "line", owners_path, lineno) |
| 161 self.owners_for.setdefault(dirpath, set()).add(line) | 238 |
| 162 continue | 239 def _add_entry(self, path, directive, line_type, owners_path, lineno): |
| 240 if directive == "set noparent": | |
| 241 self.stop_looking.add(path) | |
| 242 elif self.email_regexp.match(directive) or directive == EVERYONE: | |
| 243 self.owned_by.setdefault(directive, set()).add(path) | |
| 244 self.owners_for.setdefault(path, set()).add(directive) | |
| 245 else: | |
| 163 raise SyntaxErrorInOwnersFile(owners_path, lineno, | 246 raise SyntaxErrorInOwnersFile(owners_path, lineno, |
| 164 ('line is not a comment, a "set" directive, ' | 247 ('%s is not a "set" directive, "*", ' |
| 165 'or an email address: "%s"' % line)) | 248 'or an email address: "%s"' % (line_type, directive))) |
| 249 | |
| 166 | 250 |
| 167 def _covering_set_of_owners_for(self, files): | 251 def _covering_set_of_owners_for(self, files): |
| 168 # Get the set of directories from the files. | 252 # Get the set of directories from the files. |
| 169 dirs = set() | 253 dirs = set() |
| 170 for f in files: | 254 for f in files: |
| 171 dirs.add(self.os_path.dirname(f)) | 255 dirs.add(self.os_path.dirname(f)) |
| 172 | 256 |
| 173 owned_dirs = {} | 257 owned_dirs = {} |
| 174 dir_owners = {} | 258 dir_owners = {} |
| 175 | 259 |
| (...skipping 36 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 212 break | 296 break |
| 213 | 297 |
| 214 final_owners.add(max_owner) | 298 final_owners.add(max_owner) |
| 215 | 299 |
| 216 # Remove all directories owned by the current owner from the remaining | 300 # Remove all directories owned by the current owner from the remaining |
| 217 # list. | 301 # list. |
| 218 for dirname in owned_dirs[max_owner]: | 302 for dirname in owned_dirs[max_owner]: |
| 219 dirs.discard(dirname) | 303 dirs.discard(dirname) |
| 220 | 304 |
| 221 return final_owners | 305 return final_owners |
| OLD | NEW |