blob: 1b572bf1dd7871df9e9c7caa037d6635981da494 [file] [log] [blame]
pam@chromium.orgf46aed92012-03-08 09:18:17 +00001# Copyright (c) 2012 The Chromium Authors. All rights reserved.
dpranke@chromium.org2a009622011-03-01 02:43:31 +00002# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
dpranke@chromium.org17cc2442012-10-17 21:12:09 +00005"""A database of OWNERS files.
6
7OWNERS 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).
9Note that all changes must still be reviewed by someone familiar with the code,
10so you may need approval from both an OWNER and a reviewer in many cases.
11
12The syntax of the OWNERS file is, roughly:
13
Edward Lesmes5c62ed52018-04-19 16:47:15 -040014lines := (\s* line? \s* comment? \s* "\n")*
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000015
16line := directive
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +000017 | "per-file" \s+ glob \s* "=" \s* directive
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000018
19directive := "set noparent"
peter@chromium.org2ce13132015-04-16 16:42:08 +000020 | "file:" glob
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000021 | email_address
22 | "*"
23
24glob := [a-zA-Z0-9_-*?]+
25
26comment := "#" [^"\n"]*
27
28Email addresses must follow the foo@bar.com short form (exact syntax given
29in BASIC_EMAIL_REGEXP, below). Filename globs follow the simple unix
30shell conventions, and relative and absolute paths are not allowed (i.e.,
31globs only refer to the files in the current directory).
32
33If a user's email is one of the email_addresses in the file, the user is
34considered an "OWNER" for all files in the directory.
35
36If the "per-file" directive is used, the line only applies to files in that
37directory that match the filename glob specified.
38
39If the "set noparent" directive used, then only entries in this OWNERS file
40apply to files in this directory; if the "set noparent" directive is not
41used, then entries in OWNERS files in enclosing (upper) directories also
42apply (up until a "set noparent is encountered").
43
44If "per-file glob=set noparent" is used, then global directives are ignored
45for the glob, and only the "per-file" owners are used for files matching that
46glob.
47
peter@chromium.org2ce13132015-04-16 16:42:08 +000048If the "file:" directive is used, the referred to OWNERS file will be parsed and
49considered when determining the valid set of OWNERS. If the filename starts with
50"//" it is relative to the root of the repository, otherwise it is relative to
51the current file
52
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000053Examples for all of these combinations can be found in tests/owners_unittest.py.
54"""
dpranke@chromium.org2a009622011-03-01 02:43:31 +000055
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +000056import collections
dtu944b6052016-07-14 14:48:21 -070057import fnmatch
dpranke@chromium.orgc591a702012-12-20 20:14:58 +000058import random
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +000059import re
60
61
62# If this is present by itself on a line, this means that everyone can review.
63EVERYONE = '*'
64
65
66# Recognizes 'X@Y' email addresses. Very simplistic.
67BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
dpranke@chromium.org2a009622011-03-01 02:43:31 +000068
dpranke@chromium.org2a009622011-03-01 02:43:31 +000069
Jochen Eisinger72606f82017-04-04 10:44:18 +020070# Key for global comments per email address. Should be unlikely to be a
71# pathname.
72GLOBAL_STATUS = '*'
73
74
dpranke@chromium.org923950f2011-03-17 23:40:00 +000075def _assert_is_collection(obj):
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000076 assert not isinstance(obj, basestring)
maruel@chromium.org725f1c32011-04-01 20:24:54 +000077 # Module 'collections' has no 'Iterable' member
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -080078 # pylint: disable=no-member
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000079 if hasattr(collections, 'Iterable') and hasattr(collections, 'Sized'):
80 assert (isinstance(obj, collections.Iterable) and
81 isinstance(obj, collections.Sized))
dpranke@chromium.org923950f2011-03-17 23:40:00 +000082
83
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000084class SyntaxErrorInOwnersFile(Exception):
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000085 def __init__(self, path, lineno, msg):
86 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000087 self.path = path
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000088 self.lineno = lineno
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000089 self.msg = msg
90
91 def __str__(self):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +000092 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000093
94
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000095class Database(object):
96 """A database of OWNERS files for a repository.
97
98 This class allows you to find a suggested set of reviewers for a list
99 of changed files, and see if a list of changed files is covered by a
100 list of reviewers."""
101
Jochen Eisingereb744762017-04-05 11:00:05 +0200102 def __init__(self, root, fopen, os_path):
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000103 """Args:
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000104 root: the path to the root of the Repository
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000105 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000106 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700107 'exists', 'join', and 'relpath'
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000108 """
109 self.root = root
110 self.fopen = fopen
111 self.os_path = os_path
112
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000113 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000114 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000115
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200116 # Replacement contents for the given files. Maps the file name of an
117 # OWNERS file (relative to root) to an iterator returning the replacement
118 # file contents.
119 self.override_files = {}
120
dtu944b6052016-07-14 14:48:21 -0700121 # Mapping of owners to the paths or globs they own.
122 self._owners_to_paths = {EVERYONE: set()}
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000123
124 # Mapping of paths to authorized owners.
dtu944b6052016-07-14 14:48:21 -0700125 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000126
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000127 # Mapping reviewers to the preceding comment per file in the OWNERS files.
128 self.comments = {}
129
nick7e16cf32016-09-16 16:05:05 -0700130 # Cache of compiled regexes for _fnmatch()
131 self._fnmatch_cache = {}
132
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000133 # Set of paths that stop us from looking above them for owners.
134 # (This is implicitly true for the root directory).
dtu944b6052016-07-14 14:48:21 -0700135 self._stop_looking = set([''])
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000136
peter@chromium.org2ce13132015-04-16 16:42:08 +0000137 # Set of files which have already been read.
138 self.read_files = set()
139
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800140 # Set of files which were included from other files. Files are processed
141 # differently depending on whether they are regular owners files or
142 # being included from another file.
143 self._included_files = {}
144
Jochen Eisingereb744762017-04-05 11:00:05 +0200145 # File with global status lines for owners.
146 self._status_file = None
147
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000148 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000149 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000150
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000151 files is a sequence of paths relative to (and under) self.root.
152 If author is nonempty, we ensure it is not included in the set returned
153 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000154 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000155 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700156
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000157 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000158 if EVERYONE in suggested_owners:
159 if len(suggested_owners) > 1:
160 suggested_owners.remove(EVERYONE)
161 else:
162 suggested_owners = set(['<anyone>'])
163 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000164
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000165 def files_not_covered_by(self, files, reviewers):
166 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000167
168 Args:
169 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000170 reviewers is a sequence of strings matching self.email_regexp.
171 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000172 self._check_paths(files)
173 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000174 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000175
dtu944b6052016-07-14 14:48:21 -0700176 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000177
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000178 def _check_paths(self, files):
179 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000180 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000181 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000182 assert all(not self.os_path.isabs(f) and
183 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000184
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000185 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000186 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 15:44:50 -0400187 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000188
dtu944b6052016-07-14 14:48:21 -0700189 def _is_obj_covered_by(self, objname, reviewers):
190 reviewers = list(reviewers) + [EVERYONE]
191 while True:
192 for reviewer in reviewers:
193 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
194 if fnmatch.fnmatch(objname, owned_pattern):
195 return True
196 if self._should_stop_looking(objname):
197 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000198 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700199 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000200
Francois Dorayd42c6812017-05-30 15:10:20 -0400201 def enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000202 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000203 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700204 while not self._owners_for(dirpath):
205 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000206 break
207 dirpath = self.os_path.dirname(dirpath)
208 return dirpath
209
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000210 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200211 self._read_global_comments()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000212 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000213 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 14:48:21 -0700214 while not self._owners_for(dirpath):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000215 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700216 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000217 break
218 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000219
dtu944b6052016-07-14 14:48:21 -0700220 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 16:05:05 -0700221 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 14:48:21 -0700222 for stop_looking in self._stop_looking)
223
224 def _owners_for(self, objname):
225 obj_owners = set()
226 for owned_path, path_owners in self._paths_to_owners.iteritems():
nick7e16cf32016-09-16 16:05:05 -0700227 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 14:48:21 -0700228 obj_owners |= path_owners
229 return obj_owners
230
peter@chromium.org2ce13132015-04-16 16:42:08 +0000231 def _read_owners(self, path):
232 owners_path = self.os_path.join(self.root, path)
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800233 if not (self.os_path.exists(owners_path) or (path in self.override_files)):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000234 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000235
236 if owners_path in self.read_files:
237 return
238
239 self.read_files.add(owners_path)
240
Jochen Eisingereb744762017-04-05 11:00:05 +0200241 is_toplevel = path == 'OWNERS'
242
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000243 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000244 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000245 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200246 # We treat the beginning of the file as an blank line.
247 previous_line_was_blank = True
248 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000249 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200250
251 if path in self.override_files:
252 file_iter = self.override_files[path]
253 else:
254 file_iter = self.fopen(owners_path)
255
256 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000257 lineno += 1
258 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000259 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200260 if is_toplevel:
261 m = re.match('#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
262 if m:
263 self._status_file = m.group(1).strip()
264 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000265 if not in_comment:
266 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200267 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000268 comment.append(line[1:].strip())
269 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000270 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000271 in_comment = False
272
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200273 if line == '':
274 comment = []
275 previous_line_was_blank = True
276 continue
277
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400278 # If the line ends with a comment, strip the comment and store it for this
279 # line only.
280 line, _, line_comment = line.partition('#')
281 line = line.strip()
282 line_comment = [line_comment.strip()] if line_comment else []
283
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200284 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000285 if line == 'set noparent':
dtu944b6052016-07-14 14:48:21 -0700286 self._stop_looking.add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000287 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000288
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000289 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000290 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000291 glob_string = m.group(1).strip()
292 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000293 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000294 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000295 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000296 'per-file globs cannot span directories or use escapes: "%s"' %
297 line)
dtu944b6052016-07-14 14:48:21 -0700298 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800299 self._add_entry(relative_glob_string, directive, owners_path,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400300 lineno, '\n'.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200301 if reset_comment_after_use:
302 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000303 continue
304
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000305 if line.startswith('set '):
306 raise SyntaxErrorInOwnersFile(owners_path, lineno,
307 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000308
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800309 self._add_entry(dirpath, line, owners_path, lineno,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400310 ' '.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200311 if reset_comment_after_use:
312 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000313
Jochen Eisinger72606f82017-04-04 10:44:18 +0200314 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200315 if not self._status_file:
316 if not 'OWNERS' in self.read_files:
317 self._read_owners('OWNERS')
318 if not self._status_file:
319 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200320
Jochen Eisingereb744762017-04-05 11:00:05 +0200321 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200322 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200323 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200324 owners_status_path)
325
326 if owners_status_path in self.read_files:
327 return
328
329 self.read_files.add(owners_status_path)
330
331 lineno = 0
332 for line in self.fopen(owners_status_path):
333 lineno += 1
334 line = line.strip()
335 if line.startswith('#'):
336 continue
337 if line == '':
338 continue
339
340 m = re.match('(.+?):(.+)', line)
341 if m:
342 owner = m.group(1).strip()
343 comment = m.group(2).strip()
344 if not self.email_regexp.match(owner):
345 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
346 'invalid email address: "%s"' % owner)
347
348 self.comments.setdefault(owner, {})
349 self.comments[owner][GLOBAL_STATUS] = comment
350 continue
351
352 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
353 'cannot parse status entry: "%s"' % line.strip())
354
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800355 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000356 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800357 self._stop_looking.add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000358 elif directive.startswith('file:'):
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800359 include_file = self._resolve_include(directive[5:], owners_path)
360 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000361 raise SyntaxErrorInOwnersFile(owners_path, lineno,
362 ('%s does not refer to an existing file.' % directive[5:]))
363
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800364 included_owners = self._read_just_the_owners(include_file)
365 for owner in included_owners:
366 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
367 self._paths_to_owners.setdefault(owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000368 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200369 if comment:
370 self.comments.setdefault(directive, {})
371 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800372 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
373 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000374 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000375 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800376 ('"%s" is not a "set noparent", file include, "*", '
377 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000378
peter@chromium.org2ce13132015-04-16 16:42:08 +0000379 def _resolve_include(self, path, start):
380 if path.startswith('//'):
381 include_path = path[2:]
382 else:
383 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700384 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000385 include_path = self.os_path.join(start, path)
386
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800387 if include_path in self.override_files:
388 return include_path
389
peter@chromium.org2ce13132015-04-16 16:42:08 +0000390 owners_path = self.os_path.join(self.root, include_path)
391 if not self.os_path.exists(owners_path):
392 return None
393
394 return include_path
395
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800396 def _read_just_the_owners(self, include_file):
397 if include_file in self._included_files:
398 return self._included_files[include_file]
399
400 owners = set()
401 self._included_files[include_file] = owners
402 lineno = 0
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800403 if include_file in self.override_files:
404 file_iter = self.override_files[include_file]
405 else:
406 file_iter = self.fopen(self.os_path.join(self.root, include_file))
407 for line in file_iter:
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800408 lineno += 1
409 line = line.strip()
410 if (line.startswith('#') or line == '' or
411 line.startswith('set noparent') or
412 line.startswith('per-file')):
413 continue
414
415 if self.email_regexp.match(line) or line == EVERYONE:
416 owners.add(line)
417 continue
418 if line.startswith('file:'):
419 sub_include_file = self._resolve_include(line[5:], include_file)
420 sub_owners = self._read_just_the_owners(sub_include_file)
421 owners.update(sub_owners)
422 continue
423
424 raise SyntaxErrorInOwnersFile(include_file, lineno,
425 ('"%s" is not a "set noparent", file include, "*", '
426 'or an email address.' % (line,)))
427 return owners
428
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000429 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 15:10:20 -0400430 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000431 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000432 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700433 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000434 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
435 suggested_owners.add(owner)
436 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
437 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700438 # Now that we've used `owner` and covered all their dirs, remove them
439 # from consideration.
440 del all_possible_owners[owner]
441 for o, dirs in all_possible_owners.items():
442 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
443 if not new_dirs:
444 del all_possible_owners[o]
445 else:
446 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000447 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000448
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000449 def all_possible_owners(self, dirs, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700450 """Returns a dict of {potential owner: (dir, distance)} mappings.
451
452 A distance of 1 is the lowest/closest possible distance (which makes the
453 subsequent math easier).
454 """
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000455 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000456 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000457 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000458 distance = 1
459 while True:
dtu944b6052016-07-14 14:48:21 -0700460 for owner in self._owners_for(dirname):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000461 if author and owner == author:
462 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000463 all_possible_owners.setdefault(owner, [])
464 # If the same person is in multiple OWNERS files above a given
465 # directory, only count the closest one.
466 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
467 all_possible_owners[owner].append((current_dir, distance))
dtu944b6052016-07-14 14:48:21 -0700468 if self._should_stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000469 break
470 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000471 distance += 1
472 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000473
nick7e16cf32016-09-16 16:05:05 -0700474 def _fnmatch(self, filename, pattern):
475 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
476 matcher = self._fnmatch_cache.get(pattern)
477 if matcher is None:
478 matcher = re.compile(fnmatch.translate(pattern)).match
479 self._fnmatch_cache[pattern] = matcher
480 return matcher(filename)
481
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000482 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000483 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000484 # We want to minimize both the number of reviewers and the distance
485 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
486 # an arbitrarily-selected scaling factor that seems to work well - it
487 # will select one reviewer in the parent directory over three reviewers
488 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000489 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000490 for owner in all_possible_owners:
491 total_distance = 0
492 num_directories_owned = 0
493 for dirname, distance in all_possible_owners[owner]:
494 if dirname in dirs:
495 total_distance += distance
496 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000497 if num_directories_owned:
498 result[owner] = (total_distance /
499 pow(num_directories_owned, 1.75))
500 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000501
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000502 @staticmethod
503 def lowest_cost_owner(all_possible_owners, dirs):
504 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
505 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000506 # Return the lowest cost owner. In the case of a tie, pick one randomly.
507 lowest_cost = min(total_costs_by_owner.itervalues())
508 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000509 lambda owner: total_costs_by_owner[owner] == lowest_cost,
510 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000511 return random.Random().choice(lowest_cost_owners)