dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 1 | # Copyright (c) 2010 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
| 4 | |
| 5 | """A database of OWNERS files.""" |
| 6 | |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 7 | import re |
| 8 | |
| 9 | |
| 10 | # If this is present by itself on a line, this means that everyone can review. |
| 11 | EVERYONE = '*' |
| 12 | |
| 13 | |
| 14 | # Recognizes 'X@Y' email addresses. Very simplistic. |
| 15 | BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$' |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 16 | |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 17 | |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 18 | class SyntaxErrorInOwnersFile(Exception): |
dpranke@chromium.org | 86bbf19 | 2011-03-09 21:37:06 +0000 | [diff] [blame] | 19 | def __init__(self, path, lineno, msg): |
| 20 | super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg)) |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 21 | self.path = path |
dpranke@chromium.org | 86bbf19 | 2011-03-09 21:37:06 +0000 | [diff] [blame] | 22 | self.lineno = lineno |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 23 | self.msg = msg |
| 24 | |
| 25 | def __str__(self): |
dpranke@chromium.org | 86bbf19 | 2011-03-09 21:37:06 +0000 | [diff] [blame] | 26 | return "%s:%d syntax error: %s" % (self.path, self.lineno, self.msg) |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 27 | |
| 28 | |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 29 | class Database(object): |
| 30 | """A database of OWNERS files for a repository. |
| 31 | |
| 32 | This class allows you to find a suggested set of reviewers for a list |
| 33 | of changed files, and see if a list of changed files is covered by a |
| 34 | list of reviewers.""" |
| 35 | |
| 36 | def __init__(self, root, fopen, os_path): |
| 37 | """Args: |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 38 | root: the path to the root of the Repository |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 39 | open: function callback to open a text file for reading |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 40 | os_path: module/object callback with fields for 'abspath', 'dirname', |
| 41 | 'exists', and 'join' |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 42 | """ |
| 43 | self.root = root |
| 44 | self.fopen = fopen |
| 45 | self.os_path = os_path |
| 46 | |
dpranke@chromium.org | 627ea67 | 2011-03-11 23:29:03 +0000 | [diff] [blame^] | 47 | # Pick a default email regexp to use; callers can override as desired. |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 48 | self.email_regexp = re.compile(BASIC_EMAIL_REGEXP) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 49 | |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 50 | # Mapping of owners to the paths they own. |
| 51 | self.owned_by = {EVERYONE: set()} |
| 52 | |
| 53 | # Mapping of paths to authorized owners. |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 54 | self.owners_for = {} |
| 55 | |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 56 | # Set of paths that stop us from looking above them for owners. |
| 57 | # (This is implicitly true for the root directory). |
| 58 | self.stop_looking = set(['']) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 59 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 60 | def reviewers_for(self, files): |
dpranke@chromium.org | 898a10e | 2011-03-04 21:54:43 +0000 | [diff] [blame] | 61 | """Returns a suggested set of reviewers that will cover the set of files. |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 62 | |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 63 | files is a set of paths relative to (and under) self.root.""" |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 64 | self._check_paths(files) |
| 65 | self._load_data_needed_for(files) |
| 66 | return self._covering_set_of_owners_for(files) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 67 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 68 | def files_are_covered_by(self, files, reviewers): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 69 | """Returns whether every file is owned by at least one reviewer.""" |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 70 | return not self.files_not_covered_by(files, reviewers) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 71 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 72 | def files_not_covered_by(self, files, reviewers): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 73 | """Returns the set of files that are not owned by at least one reviewer.""" |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 74 | self._check_paths(files) |
| 75 | self._check_reviewers(reviewers) |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 76 | if not reviewers: |
| 77 | return files |
| 78 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 79 | self._load_data_needed_for(files) |
| 80 | files_by_dir = self._files_by_dir(files) |
| 81 | covered_dirs = self._dirs_covered_by(reviewers) |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 82 | uncovered_files = [] |
| 83 | for d, files_in_d in files_by_dir.iteritems(): |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 84 | if not self._is_dir_covered_by(d, covered_dirs): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 85 | uncovered_files.extend(files_in_d) |
| 86 | return set(uncovered_files) |
| 87 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 88 | def _check_paths(self, files): |
| 89 | def _is_under(f, pfx): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 90 | return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx) |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 91 | assert all(_is_under(f, self.os_path.abspath(self.root)) for f in files) |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 92 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 93 | def _check_reviewers(self, reviewers): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 94 | """Verifies each reviewer is a valid email address.""" |
| 95 | assert all(self.email_regexp.match(r) for r in reviewers) |
| 96 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 97 | def _files_by_dir(self, files): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 98 | dirs = {} |
| 99 | for f in files: |
| 100 | dirs.setdefault(self.os_path.dirname(f), []).append(f) |
| 101 | return dirs |
| 102 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 103 | def _dirs_covered_by(self, reviewers): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 104 | dirs = self.owned_by[EVERYONE] |
| 105 | for r in reviewers: |
| 106 | dirs = dirs | self.owned_by.get(r, set()) |
| 107 | return dirs |
| 108 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 109 | def _stop_looking(self, dirname): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 110 | return dirname in self.stop_looking |
| 111 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 112 | def _is_dir_covered_by(self, dirname, covered_dirs): |
| 113 | while not dirname in covered_dirs and not self._stop_looking(dirname): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 114 | dirname = self.os_path.dirname(dirname) |
| 115 | return dirname in covered_dirs |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 116 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 117 | def _load_data_needed_for(self, files): |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 118 | for f in files: |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 119 | dirpath = self.os_path.dirname(f) |
| 120 | while not dirpath in self.owners_for: |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 121 | self._read_owners_in_dir(dirpath) |
| 122 | if self._stop_looking(dirpath): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 123 | break |
| 124 | dirpath = self.os_path.dirname(dirpath) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 125 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 126 | def _read_owners_in_dir(self, dirpath): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 127 | owners_path = self.os_path.join(self.root, dirpath, 'OWNERS') |
| 128 | if not self.os_path.exists(owners_path): |
| 129 | return |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 130 | |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 131 | lineno = 0 |
| 132 | for line in self.fopen(owners_path): |
| 133 | lineno += 1 |
| 134 | line = line.strip() |
| 135 | if line.startswith('#'): |
| 136 | continue |
| 137 | if line == 'set noparent': |
| 138 | self.stop_looking.add(dirpath) |
| 139 | continue |
dpranke@chromium.org | 86bbf19 | 2011-03-09 21:37:06 +0000 | [diff] [blame] | 140 | if line.startswith('set '): |
| 141 | raise SyntaxErrorInOwnersFile(owners_path, lineno, |
| 142 | 'unknown option: "%s"' % line[4:].strip()) |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 143 | if self.email_regexp.match(line) or line == EVERYONE: |
| 144 | self.owned_by.setdefault(line, set()).add(dirpath) |
| 145 | self.owners_for.setdefault(dirpath, set()).add(line) |
| 146 | continue |
dpranke@chromium.org | 86bbf19 | 2011-03-09 21:37:06 +0000 | [diff] [blame] | 147 | raise SyntaxErrorInOwnersFile(owners_path, lineno, |
| 148 | ('line is not a comment, a "set" directive, ' |
| 149 | 'or an email address: "%s"' % line)) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 150 | |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 151 | def _covering_set_of_owners_for(self, files): |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 152 | # TODO(dpranke): implement the greedy algorithm for covering sets, and |
| 153 | # consider returning multiple options in case there are several equally |
| 154 | # short combinations of owners. |
| 155 | every_owner = set() |
| 156 | for f in files: |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 157 | dirname = self.os_path.dirname(f) |
| 158 | while dirname in self.owners_for: |
| 159 | every_owner |= self.owners_for[dirname] |
dpranke@chromium.org | 7eea259 | 2011-03-09 21:35:46 +0000 | [diff] [blame] | 160 | if self._stop_looking(dirname): |
dpranke@chromium.org | 6dada4e | 2011-03-08 22:32:40 +0000 | [diff] [blame] | 161 | break |
| 162 | dirname = self.os_path.dirname(dirname) |
dpranke@chromium.org | 2a00962 | 2011-03-01 02:43:31 +0000 | [diff] [blame] | 163 | return every_owner |