blob: 5d6e3937b28eafe9cda47965adf00a750d4cf47e [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
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000152 def _file_affects_ownership(self, path):
153 """Returns true if the path refers to a file that could affect ownership."""
154 filename = self.os_path.split(path)[-1]
155 return filename == 'OWNERS' or filename.endswith('_OWNERS')
156
157
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000158 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000159 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000160
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000161 files is a sequence of paths relative to (and under) self.root.
162 If author is nonempty, we ensure it is not included in the set returned
163 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000164 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000165 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700166
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000167 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000168 if EVERYONE in suggested_owners:
169 if len(suggested_owners) > 1:
170 suggested_owners.remove(EVERYONE)
171 else:
172 suggested_owners = set(['<anyone>'])
173 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000174
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000175 def files_not_covered_by(self, files, reviewers):
176 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000177
178 Args:
179 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000180 reviewers is a sequence of strings matching self.email_regexp.
181 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000182 self._check_paths(files)
183 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000184 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000185
dtu944b6052016-07-14 14:48:21 -0700186 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000187
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000188 def _check_paths(self, files):
189 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000190 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000191 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000192 assert all(not self.os_path.isabs(f) and
193 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000194
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000195 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000196 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 15:44:50 -0400197 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000198
dtu944b6052016-07-14 14:48:21 -0700199 def _is_obj_covered_by(self, objname, reviewers):
200 reviewers = list(reviewers) + [EVERYONE]
201 while True:
202 for reviewer in reviewers:
203 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
204 if fnmatch.fnmatch(objname, owned_pattern):
205 return True
206 if self._should_stop_looking(objname):
207 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000208 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700209 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000210
Francois Dorayd42c6812017-05-30 15:10:20 -0400211 def enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000212 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000213 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700214 while not self._owners_for(dirpath):
215 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000216 break
217 dirpath = self.os_path.dirname(dirpath)
218 return dirpath
219
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000220 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200221 self._read_global_comments()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000222 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000223 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 14:48:21 -0700224 while not self._owners_for(dirpath):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000225 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700226 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000227 break
228 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000229
dtu944b6052016-07-14 14:48:21 -0700230 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 16:05:05 -0700231 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 14:48:21 -0700232 for stop_looking in self._stop_looking)
233
234 def _owners_for(self, objname):
235 obj_owners = set()
236 for owned_path, path_owners in self._paths_to_owners.iteritems():
nick7e16cf32016-09-16 16:05:05 -0700237 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 14:48:21 -0700238 obj_owners |= path_owners
239 return obj_owners
240
peter@chromium.org2ce13132015-04-16 16:42:08 +0000241 def _read_owners(self, path):
242 owners_path = self.os_path.join(self.root, path)
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800243 if not (self.os_path.exists(owners_path) or (path in self.override_files)):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000244 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000245
246 if owners_path in self.read_files:
247 return
248
249 self.read_files.add(owners_path)
250
Jochen Eisingereb744762017-04-05 11:00:05 +0200251 is_toplevel = path == 'OWNERS'
252
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000253 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000254 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000255 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200256 # We treat the beginning of the file as an blank line.
257 previous_line_was_blank = True
258 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000259 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200260
261 if path in self.override_files:
262 file_iter = self.override_files[path]
263 else:
264 file_iter = self.fopen(owners_path)
265
266 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000267 lineno += 1
268 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000269 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200270 if is_toplevel:
271 m = re.match('#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
272 if m:
273 self._status_file = m.group(1).strip()
274 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000275 if not in_comment:
276 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200277 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000278 comment.append(line[1:].strip())
279 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000280 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000281 in_comment = False
282
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200283 if line == '':
284 comment = []
285 previous_line_was_blank = True
286 continue
287
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400288 # If the line ends with a comment, strip the comment and store it for this
289 # line only.
290 line, _, line_comment = line.partition('#')
291 line = line.strip()
292 line_comment = [line_comment.strip()] if line_comment else []
293
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200294 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000295 if line == 'set noparent':
dtu944b6052016-07-14 14:48:21 -0700296 self._stop_looking.add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000297 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000298
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000299 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000300 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000301 glob_string = m.group(1).strip()
302 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000303 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000304 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000305 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000306 'per-file globs cannot span directories or use escapes: "%s"' %
307 line)
dtu944b6052016-07-14 14:48:21 -0700308 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800309 self._add_entry(relative_glob_string, directive, owners_path,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400310 lineno, '\n'.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200311 if reset_comment_after_use:
312 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000313 continue
314
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000315 if line.startswith('set '):
316 raise SyntaxErrorInOwnersFile(owners_path, lineno,
317 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000318
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800319 self._add_entry(dirpath, line, owners_path, lineno,
Edward Lesmes5c62ed52018-04-19 16:47:15 -0400320 ' '.join(comment + line_comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200321 if reset_comment_after_use:
322 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000323
Jochen Eisinger72606f82017-04-04 10:44:18 +0200324 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200325 if not self._status_file:
326 if not 'OWNERS' in self.read_files:
327 self._read_owners('OWNERS')
328 if not self._status_file:
329 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200330
Jochen Eisingereb744762017-04-05 11:00:05 +0200331 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200332 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200333 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200334 owners_status_path)
335
336 if owners_status_path in self.read_files:
337 return
338
339 self.read_files.add(owners_status_path)
340
341 lineno = 0
342 for line in self.fopen(owners_status_path):
343 lineno += 1
344 line = line.strip()
345 if line.startswith('#'):
346 continue
347 if line == '':
348 continue
349
350 m = re.match('(.+?):(.+)', line)
351 if m:
352 owner = m.group(1).strip()
353 comment = m.group(2).strip()
354 if not self.email_regexp.match(owner):
355 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
356 'invalid email address: "%s"' % owner)
357
358 self.comments.setdefault(owner, {})
359 self.comments[owner][GLOBAL_STATUS] = comment
360 continue
361
362 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
363 'cannot parse status entry: "%s"' % line.strip())
364
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800365 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000366 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800367 self._stop_looking.add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000368 elif directive.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000369 include_file = self._resolve_include(directive[5:], owners_path, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800370 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000371 raise SyntaxErrorInOwnersFile(owners_path, lineno,
372 ('%s does not refer to an existing file.' % directive[5:]))
373
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800374 included_owners = self._read_just_the_owners(include_file)
375 for owner in included_owners:
376 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
377 self._paths_to_owners.setdefault(owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000378 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200379 if comment:
380 self.comments.setdefault(directive, {})
381 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800382 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
383 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000384 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000385 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800386 ('"%s" is not a "set noparent", file include, "*", '
387 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000388
Daniel Cheng74fda712018-09-05 03:56:39 +0000389 def _resolve_include(self, path, start, lineno):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000390 if path.startswith('//'):
391 include_path = path[2:]
392 else:
393 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700394 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000395 include_path = self.os_path.join(start, path)
396
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800397 if include_path in self.override_files:
398 return include_path
399
peter@chromium.org2ce13132015-04-16 16:42:08 +0000400 owners_path = self.os_path.join(self.root, include_path)
Daniel Cheng74fda712018-09-05 03:56:39 +0000401 # Paths included via "file:" must end in OWNERS or _OWNERS. Files that can
402 # affect ownership have a different set of ownership rules, so that users
403 # cannot self-approve changes adding themselves to an OWNERS file.
Daniel Cheng24bca4e2018-11-01 04:11:41 +0000404 if not self._file_affects_ownership(owners_path):
Daniel Cheng74fda712018-09-05 03:56:39 +0000405 raise SyntaxErrorInOwnersFile(start, lineno, 'file: include must specify '
406 'a file named OWNERS or ending in _OWNERS')
407
peter@chromium.org2ce13132015-04-16 16:42:08 +0000408 if not self.os_path.exists(owners_path):
409 return None
410
411 return include_path
412
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800413 def _read_just_the_owners(self, include_file):
414 if include_file in self._included_files:
415 return self._included_files[include_file]
416
417 owners = set()
418 self._included_files[include_file] = owners
419 lineno = 0
Jochen Eisingere3991bc2017-11-05 13:18:58 -0800420 if include_file in self.override_files:
421 file_iter = self.override_files[include_file]
422 else:
423 file_iter = self.fopen(self.os_path.join(self.root, include_file))
424 for line in file_iter:
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800425 lineno += 1
426 line = line.strip()
427 if (line.startswith('#') or line == '' or
428 line.startswith('set noparent') or
429 line.startswith('per-file')):
430 continue
431
432 if self.email_regexp.match(line) or line == EVERYONE:
433 owners.add(line)
434 continue
435 if line.startswith('file:'):
Daniel Cheng74fda712018-09-05 03:56:39 +0000436 sub_include_file = self._resolve_include(line[5:], include_file, lineno)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800437 sub_owners = self._read_just_the_owners(sub_include_file)
438 owners.update(sub_owners)
439 continue
440
441 raise SyntaxErrorInOwnersFile(include_file, lineno,
442 ('"%s" is not a "set noparent", file include, "*", '
443 'or an email address.' % (line,)))
444 return owners
445
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000446 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 15:10:20 -0400447 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000448 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000449 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700450 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000451 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
452 suggested_owners.add(owner)
453 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
454 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700455 # Now that we've used `owner` and covered all their dirs, remove them
456 # from consideration.
457 del all_possible_owners[owner]
458 for o, dirs in all_possible_owners.items():
459 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
460 if not new_dirs:
461 del all_possible_owners[o]
462 else:
463 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000464 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000465
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000466 def all_possible_owners(self, dirs, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700467 """Returns a dict of {potential owner: (dir, distance)} mappings.
468
469 A distance of 1 is the lowest/closest possible distance (which makes the
470 subsequent math easier).
471 """
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000472 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000473 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000474 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000475 distance = 1
476 while True:
dtu944b6052016-07-14 14:48:21 -0700477 for owner in self._owners_for(dirname):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000478 if author and owner == author:
479 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000480 all_possible_owners.setdefault(owner, [])
481 # If the same person is in multiple OWNERS files above a given
482 # directory, only count the closest one.
483 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
484 all_possible_owners[owner].append((current_dir, distance))
dtu944b6052016-07-14 14:48:21 -0700485 if self._should_stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000486 break
487 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000488 distance += 1
489 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000490
nick7e16cf32016-09-16 16:05:05 -0700491 def _fnmatch(self, filename, pattern):
492 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
493 matcher = self._fnmatch_cache.get(pattern)
494 if matcher is None:
495 matcher = re.compile(fnmatch.translate(pattern)).match
496 self._fnmatch_cache[pattern] = matcher
497 return matcher(filename)
498
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000499 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000500 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000501 # We want to minimize both the number of reviewers and the distance
502 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
503 # an arbitrarily-selected scaling factor that seems to work well - it
504 # will select one reviewer in the parent directory over three reviewers
505 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000506 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000507 for owner in all_possible_owners:
508 total_distance = 0
509 num_directories_owned = 0
510 for dirname, distance in all_possible_owners[owner]:
511 if dirname in dirs:
512 total_distance += distance
513 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000514 if num_directories_owned:
515 result[owner] = (total_distance /
516 pow(num_directories_owned, 1.75))
517 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000518
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000519 @staticmethod
520 def lowest_cost_owner(all_possible_owners, dirs):
521 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
522 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000523 # Return the lowest cost owner. In the case of a tie, pick one randomly.
524 lowest_cost = min(total_costs_by_owner.itervalues())
525 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000526 lambda owner: total_costs_by_owner[owner] == lowest_cost,
527 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000528 return random.Random().choice(lowest_cost_owners)