blob: 1efd5560b6260fe02e4ae1bf747558fc1d953a9e [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
Daniel Cheng74fda712018-09-05 03:56:39 +000014lines := (\s* line? \s* comment? \s* "\n")*
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000015
Daniel Cheng74fda712018-09-05 03:56:39 +000016line := directive
17 | "per-file" \s+ glob \s* "=" \s* directive
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000018
Daniel Cheng74fda712018-09-05 03:56:39 +000019directive := "set noparent"
20 | "file:" owner_file
21 | email_address
22 | "*"
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000023
Daniel Cheng74fda712018-09-05 03:56:39 +000024glob := [a-zA-Z0-9_-*?]+
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000025
Daniel Cheng74fda712018-09-05 03:56:39 +000026comment := "#" [^"\n"]*
27
28owner_file := "OWNERS"
29 | [^"\n"]* "_OWNERS"
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000030
31Email addresses must follow the foo@bar.com short form (exact syntax given
32in BASIC_EMAIL_REGEXP, below). Filename globs follow the simple unix
33shell conventions, and relative and absolute paths are not allowed (i.e.,
34globs only refer to the files in the current directory).
35
36If a user's email is one of the email_addresses in the file, the user is
37considered an "OWNER" for all files in the directory.
38
39If the "per-file" directive is used, the line only applies to files in that
40directory that match the filename glob specified.
41
42If the "set noparent" directive used, then only entries in this OWNERS file
43apply to files in this directory; if the "set noparent" directive is not
44used, then entries in OWNERS files in enclosing (upper) directories also
45apply (up until a "set noparent is encountered").
46
47If "per-file glob=set noparent" is used, then global directives are ignored
48for the glob, and only the "per-file" owners are used for files matching that
49glob.
50
peter@chromium.org2ce13132015-04-16 16:42:08 +000051If the "file:" directive is used, the referred to OWNERS file will be parsed and
52considered when determining the valid set of OWNERS. If the filename starts with
53"//" it is relative to the root of the repository, otherwise it is relative to
Daniel Cheng74fda712018-09-05 03:56:39 +000054the current file. The referred to file *must* be named OWNERS or end in a suffix
55of _OWNERS.
peter@chromium.org2ce13132015-04-16 16:42:08 +000056
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000057Examples for all of these combinations can be found in tests/owners_unittest.py.
58"""
dpranke@chromium.org2a009622011-03-01 02:43:31 +000059
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +000060import collections
dtu944b6052016-07-14 14:48:21 -070061import fnmatch
dpranke@chromium.orgc591a702012-12-20 20:14:58 +000062import random
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +000063import re
64
65
66# If this is present by itself on a line, this means that everyone can review.
67EVERYONE = '*'
68
69
70# Recognizes 'X@Y' email addresses. Very simplistic.
71BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
dpranke@chromium.org2a009622011-03-01 02:43:31 +000072
dpranke@chromium.org2a009622011-03-01 02:43:31 +000073
Jochen Eisinger72606f82017-04-04 10:44:18 +020074# Key for global comments per email address. Should be unlikely to be a
75# pathname.
76GLOBAL_STATUS = '*'
77
78
dpranke@chromium.org923950f2011-03-17 23:40:00 +000079def _assert_is_collection(obj):
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000080 assert not isinstance(obj, basestring)
maruel@chromium.org725f1c32011-04-01 20:24:54 +000081 # Module 'collections' has no 'Iterable' member
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -080082 # pylint: disable=no-member
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000083 if hasattr(collections, 'Iterable') and hasattr(collections, 'Sized'):
84 assert (isinstance(obj, collections.Iterable) and
85 isinstance(obj, collections.Sized))
dpranke@chromium.org923950f2011-03-17 23:40:00 +000086
87
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000088class SyntaxErrorInOwnersFile(Exception):
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000089 def __init__(self, path, lineno, msg):
90 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000091 self.path = path
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000092 self.lineno = lineno
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000093 self.msg = msg
94
95 def __str__(self):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +000096 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000097
98
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000099class Database(object):
100 """A database of OWNERS files for a repository.
101
102 This class allows you to find a suggested set of reviewers for a list
103 of changed files, and see if a list of changed files is covered by a
104 list of reviewers."""
105
Jochen Eisingereb744762017-04-05 11:00:05 +0200106 def __init__(self, root, fopen, os_path):
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000107 """Args:
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000108 root: the path to the root of the Repository
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000109 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000110 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700111 'exists', 'join', and 'relpath'
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000112 """
113 self.root = root
114 self.fopen = fopen
115 self.os_path = os_path
116
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000117 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000118 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000119
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200120 # Replacement contents for the given files. Maps the file name of an
121 # OWNERS file (relative to root) to an iterator returning the replacement
122 # file contents.
123 self.override_files = {}
124
dtu944b6052016-07-14 14:48:21 -0700125 # Mapping of owners to the paths or globs they own.
126 self._owners_to_paths = {EVERYONE: set()}
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000127
Daniel Bratellb2b66992019-04-25 15:19:33 +0000128 # Mappings of paths to authorized owners, via the longest path with no
129 # glob in it.
130 # For instance "chrome/browser" -> "chrome/browser/*.h" -> ("john", "maria")
dtu944b6052016-07-14 14:48:21 -0700131 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000132
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000133 # Mapping reviewers to the preceding comment per file in the OWNERS files.
134 self.comments = {}
135
nick7e16cf32016-09-16 16:05:05 -0700136 # Cache of compiled regexes for _fnmatch()
137 self._fnmatch_cache = {}
138
Daniel Bratellb2b66992019-04-25 15:19:33 +0000139 # Sets of paths that stop us from looking above them for owners.
140 # (This is implicitly true for the root directory). They are organized
141 # by glob free path so that a 'ui/events/devices/mojo/*_struct_traits*.*'
142 # rule would be found in 'ui/events/devices/mojo'.
143 self._stop_looking = {'': set([''])}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000144
peter@chromium.org2ce13132015-04-16 16:42:08 +0000145 # Set of files which have already been read.
146 self.read_files = set()
147
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800148 # Set of files which were included from other files. Files are processed
149 # differently depending on whether they are regular owners files or
150 # being included from another file.
151 self._included_files = {}
152
Jochen Eisingereb744762017-04-05 11:00:05 +0200153 # File with global status lines for owners.
154 self._status_file = None
155
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000156 def _file_affects_ownership(self, path):
157 """Returns true if the path refers to a file that could affect ownership."""
158 filename = self.os_path.split(path)[-1]
159 return filename == 'OWNERS' or filename.endswith('_OWNERS')
160
161
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000162 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000163 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000164
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000165 files is a sequence of paths relative to (and under) self.root.
166 If author is nonempty, we ensure it is not included in the set returned
167 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000168 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000169 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700170
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000171 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000172 if EVERYONE in suggested_owners:
173 if len(suggested_owners) > 1:
174 suggested_owners.remove(EVERYONE)
175 else:
176 suggested_owners = set(['<anyone>'])
177 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000178
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000179 def files_not_covered_by(self, files, reviewers):
180 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000181
182 Args:
183 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000184 reviewers is a sequence of strings matching self.email_regexp.
185 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000186 self._check_paths(files)
187 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000188 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000189
dtu944b6052016-07-14 14:48:21 -0700190 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000191
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000192 def _check_paths(self, files):
193 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000194 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000195 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000196 assert all(not self.os_path.isabs(f) and
197 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000198
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000199 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000200 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 15:44:50 -0400201 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000202
dtu944b6052016-07-14 14:48:21 -0700203 def _is_obj_covered_by(self, objname, reviewers):
204 reviewers = list(reviewers) + [EVERYONE]
205 while True:
206 for reviewer in reviewers:
207 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
208 if fnmatch.fnmatch(objname, owned_pattern):
209 return True
210 if self._should_stop_looking(objname):
211 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000212 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700213 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000214
Francois Dorayd42c6812017-05-30 15:10:20 -0400215 def enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000216 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000217 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700218 while not self._owners_for(dirpath):
219 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000220 break
221 dirpath = self.os_path.dirname(dirpath)
222 return dirpath
223
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000224 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200225 self._read_global_comments()
Daniel Bratellb2b66992019-04-25 15:19:33 +0000226 visited_dirs = set()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000227 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000228 dirpath = self.os_path.dirname(f)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000229 while dirpath not in visited_dirs:
230 visited_dirs.add(dirpath)
231
232 obj_owners = self._owners_for(dirpath)
233 if obj_owners:
234 break
peter@chromium.org2ce13132015-04-16 16:42:08 +0000235 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700236 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000237 break
Daniel Bratellb2b66992019-04-25 15:19:33 +0000238
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000239 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000240
dtu944b6052016-07-14 14:48:21 -0700241 def _should_stop_looking(self, objname):
Daniel Bratellb2b66992019-04-25 15:19:33 +0000242 dirname = objname
243 while True:
244 if dirname in self._stop_looking:
245 if any(self._fnmatch(objname, stop_looking)
246 for stop_looking in self._stop_looking[dirname]):
247 return True
248 up_dirname = self.os_path.dirname(dirname)
249 if up_dirname == dirname:
250 break
251 dirname = up_dirname
252 return False
253
254 def _get_root_affected_dir(self, obj_name):
255 """Returns the deepest directory/path that is affected by a file pattern
256 |obj_name|."""
257 root_affected_dir = obj_name
258 while '*' in root_affected_dir:
259 root_affected_dir = self.os_path.dirname(root_affected_dir)
260 return root_affected_dir
dtu944b6052016-07-14 14:48:21 -0700261
262 def _owners_for(self, objname):
263 obj_owners = set()
Daniel Bratellb2b66992019-04-25 15:19:33 +0000264
265 # Possibly relevant rules can be found stored at every directory
266 # level so iterate upwards, looking for them.
267 dirname = objname
268 while True:
269 dir_owner_rules = self._paths_to_owners.get(dirname)
270 if dir_owner_rules:
271 for owned_path, path_owners in dir_owner_rules.iteritems():
272 if self._fnmatch(objname, owned_path):
273 obj_owners |= path_owners
274 up_dirname = self.os_path.dirname(dirname)
275 if up_dirname == dirname:
276 break
277 dirname = up_dirname
278
dtu944b6052016-07-14 14:48:21 -0700279 return obj_owners
280
peter@chromium.org2ce13132015-04-16 16:42:08 +0000281 def _read_owners(self, path):
282 owners_path = self.os_path.join(self.root, path)
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800283 if not (self.os_path.exists(owners_path) or (path in self.override_files)):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000284 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000285
286 if owners_path in self.read_files:
287 return
288
289 self.read_files.add(owners_path)
290
Jochen Eisingereb744762017-04-05 11:00:05 +0200291 is_toplevel = path == 'OWNERS'
292
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000293 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000294 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000295 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200296 # We treat the beginning of the file as an blank line.
297 previous_line_was_blank = True
298 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000299 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200300
301 if path in self.override_files:
302 file_iter = self.override_files[path]
303 else:
304 file_iter = self.fopen(owners_path)
305
306 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000307 lineno += 1
308 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000309 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200310 if is_toplevel:
311 m = re.match('#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
312 if m:
313 self._status_file = m.group(1).strip()
314 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000315 if not in_comment:
316 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200317 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000318 comment.append(line[1:].strip())
319 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000320 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000321 in_comment = False
322
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200323 if line == '':
324 comment = []
325 previous_line_was_blank = True
326 continue
327
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400328 # If the line ends with a comment, strip the comment and store it for this
329 # line only.
330 line, _, line_comment = line.partition('#')
331 line = line.strip()
332 line_comment = [line_comment.strip()] if line_comment else []
333
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200334 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000335 if line == 'set noparent':
Daniel Bratellb2b66992019-04-25 15:19:33 +0000336 self._stop_looking.setdefault(
337 self._get_root_affected_dir(dirpath), set()).add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000338 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000339
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000340 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000341 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000342 glob_string = m.group(1).strip()
343 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000344 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000345 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000346 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000347 'per-file globs cannot span directories or use escapes: "%s"' %
348 line)
dtu944b6052016-07-14 14:48:21 -0700349 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800350 self._add_entry(relative_glob_string, directive, owners_path,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400351 lineno, '\n'.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200352 if reset_comment_after_use:
353 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000354 continue
355
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000356 if line.startswith('set '):
357 raise SyntaxErrorInOwnersFile(owners_path, lineno,
358 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000359
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800360 self._add_entry(dirpath, line, owners_path, lineno,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400361 ' '.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200362 if reset_comment_after_use:
363 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000364
Jochen Eisinger72606f82017-04-04 10:44:18 +0200365 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200366 if not self._status_file:
367 if not 'OWNERS' in self.read_files:
368 self._read_owners('OWNERS')
369 if not self._status_file:
370 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200371
Jochen Eisingereb744762017-04-05 11:00:05 +0200372 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200373 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200374 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200375 owners_status_path)
376
377 if owners_status_path in self.read_files:
378 return
379
380 self.read_files.add(owners_status_path)
381
382 lineno = 0
383 for line in self.fopen(owners_status_path):
384 lineno += 1
385 line = line.strip()
386 if line.startswith('#'):
387 continue
388 if line == '':
389 continue
390
391 m = re.match('(.+?):(.+)', line)
392 if m:
393 owner = m.group(1).strip()
394 comment = m.group(2).strip()
395 if not self.email_regexp.match(owner):
396 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
397 'invalid email address: "%s"' % owner)
398
399 self.comments.setdefault(owner, {})
400 self.comments[owner][GLOBAL_STATUS] = comment
401 continue
402
403 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
404 'cannot parse status entry: "%s"' % line.strip())
405
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800406 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000407 if directive == 'set noparent':
Daniel Bratellb2b66992019-04-25 15:19:33 +0000408 self._stop_looking.setdefault(
409 self._get_root_affected_dir(owned_paths), set()).add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000410 elif directive.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000411 include_file = self._resolve_include(directive[5:], owners_path, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800412 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000413 raise SyntaxErrorInOwnersFile(owners_path, lineno,
414 ('%s does not refer to an existing file.' % directive[5:]))
415
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800416 included_owners = self._read_just_the_owners(include_file)
417 for owner in included_owners:
418 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000419 self._paths_to_owners.setdefault(
420 self._get_root_affected_dir(owned_paths), {}).setdefault(
421 owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000422 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200423 if comment:
424 self.comments.setdefault(directive, {})
425 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800426 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000427 self._paths_to_owners.setdefault(
428 self._get_root_affected_dir(owned_paths), {}).setdefault(
429 owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000430 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000431 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800432 ('"%s" is not a "set noparent", file include, "*", '
433 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000434
Daniel Cheng74fda712018-09-05 03:56:39 +0000435 def _resolve_include(self, path, start, lineno):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000436 if path.startswith('//'):
437 include_path = path[2:]
438 else:
439 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700440 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000441 include_path = self.os_path.join(start, path)
442
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800443 if include_path in self.override_files:
444 return include_path
445
peter@chromium.org2ce13132015-04-16 16:42:08 +0000446 owners_path = self.os_path.join(self.root, include_path)
Daniel Cheng74fda712018-09-05 03:56:39 +0000447 # Paths included via "file:" must end in OWNERS or _OWNERS. Files that can
448 # affect ownership have a different set of ownership rules, so that users
449 # cannot self-approve changes adding themselves to an OWNERS file.
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000450 if not self._file_affects_ownership(owners_path):
Daniel Cheng74fda712018-09-05 03:56:39 +0000451 raise SyntaxErrorInOwnersFile(start, lineno, 'file: include must specify '
452 'a file named OWNERS or ending in _OWNERS')
453
peter@chromium.org2ce13132015-04-16 16:42:08 +0000454 if not self.os_path.exists(owners_path):
455 return None
456
457 return include_path
458
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800459 def _read_just_the_owners(self, include_file):
460 if include_file in self._included_files:
461 return self._included_files[include_file]
462
463 owners = set()
464 self._included_files[include_file] = owners
465 lineno = 0
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800466 if include_file in self.override_files:
467 file_iter = self.override_files[include_file]
468 else:
469 file_iter = self.fopen(self.os_path.join(self.root, include_file))
470 for line in file_iter:
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800471 lineno += 1
472 line = line.strip()
473 if (line.startswith('#') or line == '' or
474 line.startswith('set noparent') or
475 line.startswith('per-file')):
476 continue
477
478 if self.email_regexp.match(line) or line == EVERYONE:
479 owners.add(line)
480 continue
481 if line.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000482 sub_include_file = self._resolve_include(line[5:], include_file, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800483 sub_owners = self._read_just_the_owners(sub_include_file)
484 owners.update(sub_owners)
485 continue
486
487 raise SyntaxErrorInOwnersFile(include_file, lineno,
488 ('"%s" is not a "set noparent", file include, "*", '
489 'or an email address.' % (line,)))
490 return owners
491
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000492 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 15:10:20 -0400493 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000494 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000495 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700496 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000497 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
498 suggested_owners.add(owner)
499 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
500 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700501 # Now that we've used `owner` and covered all their dirs, remove them
502 # from consideration.
503 del all_possible_owners[owner]
504 for o, dirs in all_possible_owners.items():
505 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
506 if not new_dirs:
507 del all_possible_owners[o]
508 else:
509 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000510 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000511
Daniel Bratellb2b66992019-04-25 15:19:33 +0000512 def _all_possible_owners_for_dir_or_file(self, dir_or_file, author,
513 cache):
514 """Returns a dict of {potential owner: (dir_or_file, distance)} mappings.
515 """
516 assert not dir_or_file.startswith("/")
517 res = cache.get(dir_or_file)
518 if res is None:
519 res = {}
520 dirname = dir_or_file
521 for owner in self._owners_for(dirname):
522 if author and owner == author:
523 continue
524 res.setdefault(owner, [])
525 res[owner] = (dir_or_file, 1)
526 if not self._should_stop_looking(dirname):
527 dirname = self.os_path.dirname(dirname)
528
529 parent_res = self._all_possible_owners_for_dir_or_file(dirname,
530 author, cache)
531
532 # Merge the parent information with our information, adjusting
533 # distances as necessary, and replacing the parent directory
534 # names with our names.
535 for owner, par_dir_and_distances in parent_res.iteritems():
536 if owner in res:
537 # If the same person is in multiple OWNERS files above a given
538 # directory, only count the closest one.
539 continue
540 parent_distance = par_dir_and_distances[1]
541 res[owner] = (dir_or_file, parent_distance + 1)
542
543 cache[dir_or_file] = res
544
545 return res
546
547 def all_possible_owners(self, dirs_and_files, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700548 """Returns a dict of {potential owner: (dir, distance)} mappings.
549
550 A distance of 1 is the lowest/closest possible distance (which makes the
551 subsequent math easier).
552 """
Daniel Bratellb2b66992019-04-25 15:19:33 +0000553
554 all_possible_owners_for_dir_or_file_cache = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000555 all_possible_owners = {}
Daniel Bratellb2b66992019-04-25 15:19:33 +0000556 for current_dir in dirs_and_files:
557 dir_owners = self._all_possible_owners_for_dir_or_file(
558 current_dir, author,
559 all_possible_owners_for_dir_or_file_cache)
560 for owner, dir_and_distance in dir_owners.iteritems():
561 if owner in all_possible_owners:
562 all_possible_owners[owner].append(dir_and_distance)
563 else:
564 all_possible_owners[owner] = [dir_and_distance]
565
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000566 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000567
nick7e16cf32016-09-16 16:05:05 -0700568 def _fnmatch(self, filename, pattern):
569 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
570 matcher = self._fnmatch_cache.get(pattern)
571 if matcher is None:
572 matcher = re.compile(fnmatch.translate(pattern)).match
573 self._fnmatch_cache[pattern] = matcher
574 return matcher(filename)
575
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000576 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000577 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000578 # We want to minimize both the number of reviewers and the distance
579 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
580 # an arbitrarily-selected scaling factor that seems to work well - it
581 # will select one reviewer in the parent directory over three reviewers
582 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000583 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000584 for owner in all_possible_owners:
585 total_distance = 0
586 num_directories_owned = 0
587 for dirname, distance in all_possible_owners[owner]:
588 if dirname in dirs:
589 total_distance += distance
590 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000591 if num_directories_owned:
592 result[owner] = (total_distance /
593 pow(num_directories_owned, 1.75))
594 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000595
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000596 @staticmethod
597 def lowest_cost_owner(all_possible_owners, dirs):
598 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
599 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000600 # Return the lowest cost owner. In the case of a tie, pick one randomly.
601 lowest_cost = min(total_costs_by_owner.itervalues())
602 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000603 lambda owner: total_costs_by_owner[owner] == lowest_cost,
604 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000605 return random.Random().choice(lowest_cost_owners)