blob: 0c5dbd6b10f186a66eec205c1677b859645fb1ab [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
128 # Mapping of paths to authorized owners.
dtu944b6052016-07-14 14:48:21 -0700129 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000130
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000131 # Mapping reviewers to the preceding comment per file in the OWNERS files.
132 self.comments = {}
133
nick7e16cf32016-09-16 16:05:05 -0700134 # Cache of compiled regexes for _fnmatch()
135 self._fnmatch_cache = {}
136
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000137 # Set of paths that stop us from looking above them for owners.
138 # (This is implicitly true for the root directory).
dtu944b6052016-07-14 14:48:21 -0700139 self._stop_looking = set([''])
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000140
peter@chromium.org2ce13132015-04-16 16:42:08 +0000141 # Set of files which have already been read.
142 self.read_files = set()
143
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800144 # Set of files which were included from other files. Files are processed
145 # differently depending on whether they are regular owners files or
146 # being included from another file.
147 self._included_files = {}
148
Jochen Eisingereb744762017-04-05 11:00:05 +0200149 # File with global status lines for owners.
150 self._status_file = None
151
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000152 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000153 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000154
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000155 files is a sequence of paths relative to (and under) self.root.
156 If author is nonempty, we ensure it is not included in the set returned
157 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000158 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000159 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700160
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000161 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000162 if EVERYONE in suggested_owners:
163 if len(suggested_owners) > 1:
164 suggested_owners.remove(EVERYONE)
165 else:
166 suggested_owners = set(['<anyone>'])
167 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000168
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000169 def files_not_covered_by(self, files, reviewers):
170 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000171
172 Args:
173 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000174 reviewers is a sequence of strings matching self.email_regexp.
175 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000176 self._check_paths(files)
177 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000178 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000179
dtu944b6052016-07-14 14:48:21 -0700180 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000181
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000182 def _check_paths(self, files):
183 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000184 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000185 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000186 assert all(not self.os_path.isabs(f) and
187 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000188
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000189 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000190 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 15:44:50 -0400191 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000192
dtu944b6052016-07-14 14:48:21 -0700193 def _is_obj_covered_by(self, objname, reviewers):
194 reviewers = list(reviewers) + [EVERYONE]
195 while True:
196 for reviewer in reviewers:
197 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
198 if fnmatch.fnmatch(objname, owned_pattern):
199 return True
200 if self._should_stop_looking(objname):
201 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000202 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700203 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000204
Francois Dorayd42c6812017-05-30 15:10:20 -0400205 def enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000206 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000207 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700208 while not self._owners_for(dirpath):
209 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000210 break
211 dirpath = self.os_path.dirname(dirpath)
212 return dirpath
213
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000214 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200215 self._read_global_comments()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000216 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000217 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 14:48:21 -0700218 while not self._owners_for(dirpath):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000219 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700220 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000221 break
222 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000223
dtu944b6052016-07-14 14:48:21 -0700224 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 16:05:05 -0700225 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 14:48:21 -0700226 for stop_looking in self._stop_looking)
227
228 def _owners_for(self, objname):
229 obj_owners = set()
230 for owned_path, path_owners in self._paths_to_owners.iteritems():
nick7e16cf32016-09-16 16:05:05 -0700231 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 14:48:21 -0700232 obj_owners |= path_owners
233 return obj_owners
234
peter@chromium.org2ce13132015-04-16 16:42:08 +0000235 def _read_owners(self, path):
236 owners_path = self.os_path.join(self.root, path)
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800237 if not (self.os_path.exists(owners_path) or (path in self.override_files)):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000238 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000239
240 if owners_path in self.read_files:
241 return
242
243 self.read_files.add(owners_path)
244
Jochen Eisingereb744762017-04-05 11:00:05 +0200245 is_toplevel = path == 'OWNERS'
246
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000247 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000248 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000249 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200250 # We treat the beginning of the file as an blank line.
251 previous_line_was_blank = True
252 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000253 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200254
255 if path in self.override_files:
256 file_iter = self.override_files[path]
257 else:
258 file_iter = self.fopen(owners_path)
259
260 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000261 lineno += 1
262 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000263 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200264 if is_toplevel:
265 m = re.match('#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
266 if m:
267 self._status_file = m.group(1).strip()
268 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000269 if not in_comment:
270 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200271 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000272 comment.append(line[1:].strip())
273 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000274 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000275 in_comment = False
276
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200277 if line == '':
278 comment = []
279 previous_line_was_blank = True
280 continue
281
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400282 # If the line ends with a comment, strip the comment and store it for this
283 # line only.
284 line, _, line_comment = line.partition('#')
285 line = line.strip()
286 line_comment = [line_comment.strip()] if line_comment else []
287
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200288 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000289 if line == 'set noparent':
dtu944b6052016-07-14 14:48:21 -0700290 self._stop_looking.add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000291 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000292
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000293 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000294 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000295 glob_string = m.group(1).strip()
296 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000297 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000298 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000299 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000300 'per-file globs cannot span directories or use escapes: "%s"' %
301 line)
dtu944b6052016-07-14 14:48:21 -0700302 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800303 self._add_entry(relative_glob_string, directive, owners_path,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400304 lineno, '\n'.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200305 if reset_comment_after_use:
306 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000307 continue
308
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000309 if line.startswith('set '):
310 raise SyntaxErrorInOwnersFile(owners_path, lineno,
311 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000312
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800313 self._add_entry(dirpath, line, owners_path, lineno,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400314 ' '.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200315 if reset_comment_after_use:
316 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000317
Jochen Eisinger72606f82017-04-04 10:44:18 +0200318 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200319 if not self._status_file:
320 if not 'OWNERS' in self.read_files:
321 self._read_owners('OWNERS')
322 if not self._status_file:
323 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200324
Jochen Eisingereb744762017-04-05 11:00:05 +0200325 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200326 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200327 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200328 owners_status_path)
329
330 if owners_status_path in self.read_files:
331 return
332
333 self.read_files.add(owners_status_path)
334
335 lineno = 0
336 for line in self.fopen(owners_status_path):
337 lineno += 1
338 line = line.strip()
339 if line.startswith('#'):
340 continue
341 if line == '':
342 continue
343
344 m = re.match('(.+?):(.+)', line)
345 if m:
346 owner = m.group(1).strip()
347 comment = m.group(2).strip()
348 if not self.email_regexp.match(owner):
349 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
350 'invalid email address: "%s"' % owner)
351
352 self.comments.setdefault(owner, {})
353 self.comments[owner][GLOBAL_STATUS] = comment
354 continue
355
356 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
357 'cannot parse status entry: "%s"' % line.strip())
358
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800359 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000360 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800361 self._stop_looking.add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000362 elif directive.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000363 include_file = self._resolve_include(directive[5:], owners_path, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800364 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000365 raise SyntaxErrorInOwnersFile(owners_path, lineno,
366 ('%s does not refer to an existing file.' % directive[5:]))
367
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800368 included_owners = self._read_just_the_owners(include_file)
369 for owner in included_owners:
370 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
371 self._paths_to_owners.setdefault(owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000372 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200373 if comment:
374 self.comments.setdefault(directive, {})
375 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800376 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
377 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000378 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000379 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800380 ('"%s" is not a "set noparent", file include, "*", '
381 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000382
Daniel Cheng74fda712018-09-05 03:56:39 +0000383 def _resolve_include(self, path, start, lineno):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000384 if path.startswith('//'):
385 include_path = path[2:]
386 else:
387 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700388 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000389 include_path = self.os_path.join(start, path)
390
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800391 if include_path in self.override_files:
392 return include_path
393
peter@chromium.org2ce13132015-04-16 16:42:08 +0000394 owners_path = self.os_path.join(self.root, include_path)
Daniel Cheng74fda712018-09-05 03:56:39 +0000395 # Paths included via "file:" must end in OWNERS or _OWNERS. Files that can
396 # affect ownership have a different set of ownership rules, so that users
397 # cannot self-approve changes adding themselves to an OWNERS file.
398 if not (owners_path.endswith('/OWNERS') or owners_path.endswith('_OWNERS')):
399 raise SyntaxErrorInOwnersFile(start, lineno, 'file: include must specify '
400 'a file named OWNERS or ending in _OWNERS')
401
peter@chromium.org2ce13132015-04-16 16:42:08 +0000402 if not self.os_path.exists(owners_path):
403 return None
404
405 return include_path
406
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800407 def _read_just_the_owners(self, include_file):
408 if include_file in self._included_files:
409 return self._included_files[include_file]
410
411 owners = set()
412 self._included_files[include_file] = owners
413 lineno = 0
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800414 if include_file in self.override_files:
415 file_iter = self.override_files[include_file]
416 else:
417 file_iter = self.fopen(self.os_path.join(self.root, include_file))
418 for line in file_iter:
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800419 lineno += 1
420 line = line.strip()
421 if (line.startswith('#') or line == '' or
422 line.startswith('set noparent') or
423 line.startswith('per-file')):
424 continue
425
426 if self.email_regexp.match(line) or line == EVERYONE:
427 owners.add(line)
428 continue
429 if line.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000430 sub_include_file = self._resolve_include(line[5:], include_file, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800431 sub_owners = self._read_just_the_owners(sub_include_file)
432 owners.update(sub_owners)
433 continue
434
435 raise SyntaxErrorInOwnersFile(include_file, lineno,
436 ('"%s" is not a "set noparent", file include, "*", '
437 'or an email address.' % (line,)))
438 return owners
439
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000440 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 15:10:20 -0400441 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000442 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000443 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700444 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000445 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
446 suggested_owners.add(owner)
447 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
448 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700449 # Now that we've used `owner` and covered all their dirs, remove them
450 # from consideration.
451 del all_possible_owners[owner]
452 for o, dirs in all_possible_owners.items():
453 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
454 if not new_dirs:
455 del all_possible_owners[o]
456 else:
457 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000458 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000459
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000460 def all_possible_owners(self, dirs, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700461 """Returns a dict of {potential owner: (dir, distance)} mappings.
462
463 A distance of 1 is the lowest/closest possible distance (which makes the
464 subsequent math easier).
465 """
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000466 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000467 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000468 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000469 distance = 1
470 while True:
dtu944b6052016-07-14 14:48:21 -0700471 for owner in self._owners_for(dirname):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000472 if author and owner == author:
473 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000474 all_possible_owners.setdefault(owner, [])
475 # If the same person is in multiple OWNERS files above a given
476 # directory, only count the closest one.
477 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
478 all_possible_owners[owner].append((current_dir, distance))
dtu944b6052016-07-14 14:48:21 -0700479 if self._should_stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000480 break
481 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000482 distance += 1
483 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000484
nick7e16cf32016-09-16 16:05:05 -0700485 def _fnmatch(self, filename, pattern):
486 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
487 matcher = self._fnmatch_cache.get(pattern)
488 if matcher is None:
489 matcher = re.compile(fnmatch.translate(pattern)).match
490 self._fnmatch_cache[pattern] = matcher
491 return matcher(filename)
492
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000493 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000494 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000495 # We want to minimize both the number of reviewers and the distance
496 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
497 # an arbitrarily-selected scaling factor that seems to work well - it
498 # will select one reviewer in the parent directory over three reviewers
499 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000500 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000501 for owner in all_possible_owners:
502 total_distance = 0
503 num_directories_owned = 0
504 for dirname, distance in all_possible_owners[owner]:
505 if dirname in dirs:
506 total_distance += distance
507 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000508 if num_directories_owned:
509 result[owner] = (total_distance /
510 pow(num_directories_owned, 1.75))
511 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000512
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000513 @staticmethod
514 def lowest_cost_owner(all_possible_owners, dirs):
515 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
516 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000517 # Return the lowest cost owner. In the case of a tie, pick one randomly.
518 lowest_cost = min(total_costs_by_owner.itervalues())
519 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000520 lambda owner: total_costs_by_owner[owner] == lowest_cost,
521 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000522 return random.Random().choice(lowest_cost_owners)