blob: 1a2a1ab73c9104e2d7e38629d5949a22eb1923e7 [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
Bruce Dawson37740e22019-11-14 00:27:44 +00005r"""A database of OWNERS files.
dpranke@chromium.org17cc2442012-10-17 21:12:09 +00006
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
Bruce Dawson37740e22019-11-14 00:27:44 +000065try:
66 # This fallback applies for all versions of Python before 3.3
67 import collections.abc as collections_abc
68except ImportError:
69 import collections as collections_abc
70
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +000071
72# If this is present by itself on a line, this means that everyone can review.
73EVERYONE = '*'
74
75
76# Recognizes 'X@Y' email addresses. Very simplistic.
77BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
dpranke@chromium.org2a009622011-03-01 02:43:31 +000078
dpranke@chromium.org2a009622011-03-01 02:43:31 +000079
Jochen Eisinger72606f82017-04-04 10:44:18 +020080# Key for global comments per email address. Should be unlikely to be a
81# pathname.
82GLOBAL_STATUS = '*'
83
84
dpranke@chromium.org923950f2011-03-17 23:40:00 +000085def _assert_is_collection(obj):
Edward Lemur14705d82019-10-30 22:17:10 +000086 assert not isinstance(obj, str)
maruel@chromium.org725f1c32011-04-01 20:24:54 +000087 # Module 'collections' has no 'Iterable' member
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -080088 # pylint: disable=no-member
Bruce Dawson37740e22019-11-14 00:27:44 +000089 if hasattr(collections_abc, 'Iterable') and hasattr(collections_abc, 'Sized'):
90 assert (isinstance(obj, collections_abc.Iterable) and
91 isinstance(obj, collections_abc.Sized))
dpranke@chromium.org923950f2011-03-17 23:40:00 +000092
93
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000094class SyntaxErrorInOwnersFile(Exception):
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000095 def __init__(self, path, lineno, msg):
96 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000097 self.path = path
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000098 self.lineno = lineno
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000099 self.msg = msg
100
101 def __str__(self):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000102 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000103
104
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000105class Database(object):
106 """A database of OWNERS files for a repository.
107
108 This class allows you to find a suggested set of reviewers for a list
109 of changed files, and see if a list of changed files is covered by a
110 list of reviewers."""
111
Jochen Eisingereb744762017-04-05 11:00:05 +0200112 def __init__(self, root, fopen, os_path):
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000113 """Args:
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000114 root: the path to the root of the Repository
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000115 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000116 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700117 'exists', 'join', and 'relpath'
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000118 """
119 self.root = root
120 self.fopen = fopen
121 self.os_path = os_path
122
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000123 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000124 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000125
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200126 # Replacement contents for the given files. Maps the file name of an
127 # OWNERS file (relative to root) to an iterator returning the replacement
128 # file contents.
129 self.override_files = {}
130
dtu944b6052016-07-14 14:48:21 -0700131 # Mapping of owners to the paths or globs they own.
132 self._owners_to_paths = {EVERYONE: set()}
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000133
Daniel Bratelld6bf5172019-05-21 07:20:12 +0000134 # Mappings of directories -> globs in the directory -> owners
135 # Example: "chrome/browser" -> "chrome/browser/*.h" -> ("john", "maria")
dtu944b6052016-07-14 14:48:21 -0700136 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000137
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000138 # Mapping reviewers to the preceding comment per file in the OWNERS files.
139 self.comments = {}
140
nick7e16cf32016-09-16 16:05:05 -0700141 # Cache of compiled regexes for _fnmatch()
142 self._fnmatch_cache = {}
143
Daniel Bratellb2b66992019-04-25 15:19:33 +0000144 # Sets of paths that stop us from looking above them for owners.
Daniel Bratelld6bf5172019-05-21 07:20:12 +0000145 # (This is implicitly true for the root directory).
146 #
147 # The implementation is a mapping:
148 # Directory -> globs in the directory,
149 #
150 # Example:
151 # 'ui/events/devices/mojo' -> 'ui/events/devices/mojo/*_struct_traits*.*'
Daniel Bratellb2b66992019-04-25 15:19:33 +0000152 self._stop_looking = {'': set([''])}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000153
peter@chromium.org2ce13132015-04-16 16:42:08 +0000154 # Set of files which have already been read.
155 self.read_files = set()
156
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800157 # Set of files which were included from other files. Files are processed
158 # differently depending on whether they are regular owners files or
159 # being included from another file.
160 self._included_files = {}
161
Jochen Eisingereb744762017-04-05 11:00:05 +0200162 # File with global status lines for owners.
163 self._status_file = None
164
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000165 def _file_affects_ownership(self, path):
166 """Returns true if the path refers to a file that could affect ownership."""
167 filename = self.os_path.split(path)[-1]
168 return filename == 'OWNERS' or filename.endswith('_OWNERS')
169
170
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000171 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000172 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000173
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000174 files is a sequence of paths relative to (and under) self.root.
175 If author is nonempty, we ensure it is not included in the set returned
176 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000177 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000178 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700179
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000180 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000181 if EVERYONE in suggested_owners:
182 if len(suggested_owners) > 1:
183 suggested_owners.remove(EVERYONE)
184 else:
185 suggested_owners = set(['<anyone>'])
186 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000187
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000188 def files_not_covered_by(self, files, reviewers):
189 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000190
191 Args:
192 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000193 reviewers is a sequence of strings matching self.email_regexp.
194 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000195 self._check_paths(files)
196 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000197 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000198
dtu944b6052016-07-14 14:48:21 -0700199 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000200
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000201 def _check_paths(self, files):
202 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000203 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000204 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000205 assert all(not self.os_path.isabs(f) and
206 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000207
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000208 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000209 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 15:44:50 -0400210 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000211
dtu944b6052016-07-14 14:48:21 -0700212 def _is_obj_covered_by(self, objname, reviewers):
213 reviewers = list(reviewers) + [EVERYONE]
214 while True:
215 for reviewer in reviewers:
216 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
217 if fnmatch.fnmatch(objname, owned_pattern):
218 return True
219 if self._should_stop_looking(objname):
220 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000221 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700222 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000223
Francois Dorayd42c6812017-05-30 15:10:20 -0400224 def enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000225 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000226 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700227 while not self._owners_for(dirpath):
228 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000229 break
230 dirpath = self.os_path.dirname(dirpath)
231 return dirpath
232
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000233 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200234 self._read_global_comments()
Daniel Bratellb2b66992019-04-25 15:19:33 +0000235 visited_dirs = set()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000236 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000237 dirpath = self.os_path.dirname(f)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000238 while dirpath not in visited_dirs:
239 visited_dirs.add(dirpath)
240
241 obj_owners = self._owners_for(dirpath)
242 if obj_owners:
243 break
peter@chromium.org2ce13132015-04-16 16:42:08 +0000244 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700245 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000246 break
Daniel Bratellb2b66992019-04-25 15:19:33 +0000247
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000248 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000249
dtu944b6052016-07-14 14:48:21 -0700250 def _should_stop_looking(self, objname):
Daniel Bratellb2b66992019-04-25 15:19:33 +0000251 dirname = objname
252 while True:
253 if dirname in self._stop_looking:
254 if any(self._fnmatch(objname, stop_looking)
255 for stop_looking in self._stop_looking[dirname]):
256 return True
257 up_dirname = self.os_path.dirname(dirname)
258 if up_dirname == dirname:
259 break
260 dirname = up_dirname
261 return False
262
263 def _get_root_affected_dir(self, obj_name):
264 """Returns the deepest directory/path that is affected by a file pattern
265 |obj_name|."""
266 root_affected_dir = obj_name
267 while '*' in root_affected_dir:
268 root_affected_dir = self.os_path.dirname(root_affected_dir)
269 return root_affected_dir
dtu944b6052016-07-14 14:48:21 -0700270
271 def _owners_for(self, objname):
272 obj_owners = set()
Daniel Bratellb2b66992019-04-25 15:19:33 +0000273
274 # Possibly relevant rules can be found stored at every directory
275 # level so iterate upwards, looking for them.
276 dirname = objname
277 while True:
278 dir_owner_rules = self._paths_to_owners.get(dirname)
279 if dir_owner_rules:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000280 for owned_path, path_owners in dir_owner_rules.items():
Daniel Bratellb2b66992019-04-25 15:19:33 +0000281 if self._fnmatch(objname, owned_path):
282 obj_owners |= path_owners
283 up_dirname = self.os_path.dirname(dirname)
284 if up_dirname == dirname:
285 break
286 dirname = up_dirname
287
dtu944b6052016-07-14 14:48:21 -0700288 return obj_owners
289
peter@chromium.org2ce13132015-04-16 16:42:08 +0000290 def _read_owners(self, path):
291 owners_path = self.os_path.join(self.root, path)
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800292 if not (self.os_path.exists(owners_path) or (path in self.override_files)):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000293 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000294
295 if owners_path in self.read_files:
296 return
297
298 self.read_files.add(owners_path)
299
Jochen Eisingereb744762017-04-05 11:00:05 +0200300 is_toplevel = path == 'OWNERS'
301
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000302 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000303 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000304 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200305 # We treat the beginning of the file as an blank line.
306 previous_line_was_blank = True
307 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000308 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200309
310 if path in self.override_files:
311 file_iter = self.override_files[path]
312 else:
313 file_iter = self.fopen(owners_path)
314
315 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000316 lineno += 1
317 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000318 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200319 if is_toplevel:
Bruce Dawson9c062012019-05-02 19:20:28 +0000320 m = re.match(r'#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
Jochen Eisingereb744762017-04-05 11:00:05 +0200321 if m:
322 self._status_file = m.group(1).strip()
323 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000324 if not in_comment:
325 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200326 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000327 comment.append(line[1:].strip())
328 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000329 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000330 in_comment = False
331
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200332 if line == '':
333 comment = []
334 previous_line_was_blank = True
335 continue
336
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400337 # If the line ends with a comment, strip the comment and store it for this
338 # line only.
339 line, _, line_comment = line.partition('#')
340 line = line.strip()
341 line_comment = [line_comment.strip()] if line_comment else []
342
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200343 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000344 if line == 'set noparent':
Daniel Bratellb2b66992019-04-25 15:19:33 +0000345 self._stop_looking.setdefault(
346 self._get_root_affected_dir(dirpath), set()).add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000347 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000348
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000349 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000350 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000351 glob_string = m.group(1).strip()
352 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000353 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000354 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000355 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000356 'per-file globs cannot span directories or use escapes: "%s"' %
357 line)
dtu944b6052016-07-14 14:48:21 -0700358 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800359 self._add_entry(relative_glob_string, directive, owners_path,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400360 lineno, '\n'.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200361 if reset_comment_after_use:
362 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000363 continue
364
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000365 if line.startswith('set '):
366 raise SyntaxErrorInOwnersFile(owners_path, lineno,
367 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000368
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800369 self._add_entry(dirpath, line, owners_path, lineno,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400370 ' '.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200371 if reset_comment_after_use:
372 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000373
Jochen Eisinger72606f82017-04-04 10:44:18 +0200374 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200375 if not self._status_file:
376 if not 'OWNERS' in self.read_files:
377 self._read_owners('OWNERS')
378 if not self._status_file:
379 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200380
Jochen Eisingereb744762017-04-05 11:00:05 +0200381 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200382 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200383 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200384 owners_status_path)
385
386 if owners_status_path in self.read_files:
387 return
388
389 self.read_files.add(owners_status_path)
390
391 lineno = 0
392 for line in self.fopen(owners_status_path):
393 lineno += 1
394 line = line.strip()
395 if line.startswith('#'):
396 continue
397 if line == '':
398 continue
399
400 m = re.match('(.+?):(.+)', line)
401 if m:
402 owner = m.group(1).strip()
403 comment = m.group(2).strip()
404 if not self.email_regexp.match(owner):
405 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
406 'invalid email address: "%s"' % owner)
407
408 self.comments.setdefault(owner, {})
409 self.comments[owner][GLOBAL_STATUS] = comment
410 continue
411
412 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
413 'cannot parse status entry: "%s"' % line.strip())
414
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800415 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000416 if directive == 'set noparent':
Daniel Bratellb2b66992019-04-25 15:19:33 +0000417 self._stop_looking.setdefault(
418 self._get_root_affected_dir(owned_paths), set()).add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000419 elif directive.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000420 include_file = self._resolve_include(directive[5:], owners_path, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800421 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000422 raise SyntaxErrorInOwnersFile(owners_path, lineno,
423 ('%s does not refer to an existing file.' % directive[5:]))
424
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800425 included_owners = self._read_just_the_owners(include_file)
426 for owner in included_owners:
427 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000428 self._paths_to_owners.setdefault(
429 self._get_root_affected_dir(owned_paths), {}).setdefault(
430 owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000431 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200432 if comment:
433 self.comments.setdefault(directive, {})
434 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800435 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
Daniel Bratellb2b66992019-04-25 15:19:33 +0000436 self._paths_to_owners.setdefault(
437 self._get_root_affected_dir(owned_paths), {}).setdefault(
438 owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000439 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000440 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800441 ('"%s" is not a "set noparent", file include, "*", '
442 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000443
Daniel Cheng74fda712018-09-05 03:56:39 +0000444 def _resolve_include(self, path, start, lineno):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000445 if path.startswith('//'):
446 include_path = path[2:]
447 else:
448 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700449 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
Michael Achenbachff46da82019-10-21 19:40:10 +0000450 include_path = self.os_path.normpath(self.os_path.join(start, path))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000451
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800452 if include_path in self.override_files:
453 return include_path
454
peter@chromium.org2ce13132015-04-16 16:42:08 +0000455 owners_path = self.os_path.join(self.root, include_path)
Daniel Cheng74fda712018-09-05 03:56:39 +0000456 # Paths included via "file:" must end in OWNERS or _OWNERS. Files that can
457 # affect ownership have a different set of ownership rules, so that users
458 # cannot self-approve changes adding themselves to an OWNERS file.
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000459 if not self._file_affects_ownership(owners_path):
Daniel Cheng74fda712018-09-05 03:56:39 +0000460 raise SyntaxErrorInOwnersFile(start, lineno, 'file: include must specify '
461 'a file named OWNERS or ending in _OWNERS')
462
peter@chromium.org2ce13132015-04-16 16:42:08 +0000463 if not self.os_path.exists(owners_path):
464 return None
465
466 return include_path
467
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800468 def _read_just_the_owners(self, include_file):
469 if include_file in self._included_files:
470 return self._included_files[include_file]
471
472 owners = set()
473 self._included_files[include_file] = owners
474 lineno = 0
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800475 if include_file in self.override_files:
476 file_iter = self.override_files[include_file]
477 else:
478 file_iter = self.fopen(self.os_path.join(self.root, include_file))
479 for line in file_iter:
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800480 lineno += 1
481 line = line.strip()
482 if (line.startswith('#') or line == '' or
483 line.startswith('set noparent') or
484 line.startswith('per-file')):
485 continue
486
John Budorick7f75c0e2019-08-23 22:51:00 +0000487 # If the line ends with a comment, strip the comment.
488 line, _delim, _comment = line.partition('#')
489 line = line.strip()
490
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800491 if self.email_regexp.match(line) or line == EVERYONE:
492 owners.add(line)
493 continue
494 if line.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000495 sub_include_file = self._resolve_include(line[5:], include_file, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800496 sub_owners = self._read_just_the_owners(sub_include_file)
497 owners.update(sub_owners)
498 continue
499
500 raise SyntaxErrorInOwnersFile(include_file, lineno,
501 ('"%s" is not a "set noparent", file include, "*", '
502 'or an email address.' % (line,)))
503 return owners
504
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000505 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 15:10:20 -0400506 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000507 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000508 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700509 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000510 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
511 suggested_owners.add(owner)
512 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
513 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700514 # Now that we've used `owner` and covered all their dirs, remove them
515 # from consideration.
516 del all_possible_owners[owner]
Edward Lemur14705d82019-10-30 22:17:10 +0000517 for o, dirs in list(all_possible_owners.items()):
Aaron Gable93248c52017-05-15 11:23:02 -0700518 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
519 if not new_dirs:
520 del all_possible_owners[o]
521 else:
522 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000523 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000524
Daniel Bratellb2b66992019-04-25 15:19:33 +0000525 def _all_possible_owners_for_dir_or_file(self, dir_or_file, author,
526 cache):
527 """Returns a dict of {potential owner: (dir_or_file, distance)} mappings.
528 """
529 assert not dir_or_file.startswith("/")
530 res = cache.get(dir_or_file)
531 if res is None:
532 res = {}
533 dirname = dir_or_file
534 for owner in self._owners_for(dirname):
535 if author and owner == author:
536 continue
537 res.setdefault(owner, [])
538 res[owner] = (dir_or_file, 1)
539 if not self._should_stop_looking(dirname):
540 dirname = self.os_path.dirname(dirname)
541
542 parent_res = self._all_possible_owners_for_dir_or_file(dirname,
543 author, cache)
544
545 # Merge the parent information with our information, adjusting
546 # distances as necessary, and replacing the parent directory
547 # names with our names.
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000548 for owner, par_dir_and_distances in parent_res.items():
Daniel Bratellb2b66992019-04-25 15:19:33 +0000549 if owner in res:
550 # If the same person is in multiple OWNERS files above a given
551 # directory, only count the closest one.
552 continue
553 parent_distance = par_dir_and_distances[1]
554 res[owner] = (dir_or_file, parent_distance + 1)
555
556 cache[dir_or_file] = res
557
558 return res
559
560 def all_possible_owners(self, dirs_and_files, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700561 """Returns a dict of {potential owner: (dir, distance)} mappings.
562
563 A distance of 1 is the lowest/closest possible distance (which makes the
564 subsequent math easier).
565 """
Daniel Bratellb2b66992019-04-25 15:19:33 +0000566
567 all_possible_owners_for_dir_or_file_cache = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000568 all_possible_owners = {}
Daniel Bratellb2b66992019-04-25 15:19:33 +0000569 for current_dir in dirs_and_files:
570 dir_owners = self._all_possible_owners_for_dir_or_file(
571 current_dir, author,
572 all_possible_owners_for_dir_or_file_cache)
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000573 for owner, dir_and_distance in dir_owners.items():
Daniel Bratellb2b66992019-04-25 15:19:33 +0000574 if owner in all_possible_owners:
575 all_possible_owners[owner].append(dir_and_distance)
576 else:
577 all_possible_owners[owner] = [dir_and_distance]
578
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000579 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000580
nick7e16cf32016-09-16 16:05:05 -0700581 def _fnmatch(self, filename, pattern):
582 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
583 matcher = self._fnmatch_cache.get(pattern)
584 if matcher is None:
585 matcher = re.compile(fnmatch.translate(pattern)).match
586 self._fnmatch_cache[pattern] = matcher
587 return matcher(filename)
588
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000589 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000590 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000591 # We want to minimize both the number of reviewers and the distance
592 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
593 # an arbitrarily-selected scaling factor that seems to work well - it
594 # will select one reviewer in the parent directory over three reviewers
595 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000596 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000597 for owner in all_possible_owners:
598 total_distance = 0
599 num_directories_owned = 0
600 for dirname, distance in all_possible_owners[owner]:
601 if dirname in dirs:
602 total_distance += distance
603 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000604 if num_directories_owned:
605 result[owner] = (total_distance /
606 pow(num_directories_owned, 1.75))
607 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000608
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000609 @staticmethod
610 def lowest_cost_owner(all_possible_owners, dirs):
611 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
612 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000613 # Return the lowest cost owner. In the case of a tie, pick one randomly.
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000614 lowest_cost = min(total_costs_by_owner.values())
Edward Lemur14705d82019-10-30 22:17:10 +0000615 lowest_cost_owners = [
616 owner for owner, cost in total_costs_by_owner.items()
617 if cost == lowest_cost]
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000618 return random.Random().choice(lowest_cost_owners)
Elly Fong-Jonesee8d9ce2019-08-28 20:32:21 +0000619
620 def owners_rooted_at_file(self, filename):
621 """Returns a set of all owners transitively listed in filename.
622
623 This function returns a set of all the owners either listed in filename, or
624 in a file transitively included by filename. Lines that are not plain owners
625 (i.e. per-file owners) are ignored.
626 """
627 return self._read_just_the_owners(filename)