blob: c117807868418f01c13737692181190fae9667fc [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
14lines := (\s* line? \s* "\n")*
15
16line := directive
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +000017 | "per-file" \s+ glob \s* "=" \s* directive
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000018 | comment
19
20directive := "set noparent"
peter@chromium.org2ce13132015-04-16 16:42:08 +000021 | "file:" glob
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000022 | email_address
23 | "*"
24
25glob := [a-zA-Z0-9_-*?]+
26
27comment := "#" [^"\n"]*
28
29Email addresses must follow the foo@bar.com short form (exact syntax given
30in BASIC_EMAIL_REGEXP, below). Filename globs follow the simple unix
31shell conventions, and relative and absolute paths are not allowed (i.e.,
32globs only refer to the files in the current directory).
33
34If a user's email is one of the email_addresses in the file, the user is
35considered an "OWNER" for all files in the directory.
36
37If the "per-file" directive is used, the line only applies to files in that
38directory that match the filename glob specified.
39
40If the "set noparent" directive used, then only entries in this OWNERS file
41apply to files in this directory; if the "set noparent" directive is not
42used, then entries in OWNERS files in enclosing (upper) directories also
43apply (up until a "set noparent is encountered").
44
45If "per-file glob=set noparent" is used, then global directives are ignored
46for the glob, and only the "per-file" owners are used for files matching that
47glob.
48
peter@chromium.org2ce13132015-04-16 16:42:08 +000049If the "file:" directive is used, the referred to OWNERS file will be parsed and
50considered when determining the valid set of OWNERS. If the filename starts with
51"//" it is relative to the root of the repository, otherwise it is relative to
52the current file
53
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000054Examples for all of these combinations can be found in tests/owners_unittest.py.
55"""
dpranke@chromium.org2a009622011-03-01 02:43:31 +000056
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +000057import collections
dtu944b6052016-07-14 14:48:21 -070058import fnmatch
dpranke@chromium.orgc591a702012-12-20 20:14:58 +000059import random
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +000060import re
61
62
63# If this is present by itself on a line, this means that everyone can review.
64EVERYONE = '*'
65
66
67# Recognizes 'X@Y' email addresses. Very simplistic.
68BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
dpranke@chromium.org2a009622011-03-01 02:43:31 +000069
dpranke@chromium.org2a009622011-03-01 02:43:31 +000070
Jochen Eisinger72606f82017-04-04 10:44:18 +020071# Key for global comments per email address. Should be unlikely to be a
72# pathname.
73GLOBAL_STATUS = '*'
74
75
dpranke@chromium.org923950f2011-03-17 23:40:00 +000076def _assert_is_collection(obj):
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000077 assert not isinstance(obj, basestring)
maruel@chromium.org725f1c32011-04-01 20:24:54 +000078 # Module 'collections' has no 'Iterable' member
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -080079 # pylint: disable=no-member
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000080 if hasattr(collections, 'Iterable') and hasattr(collections, 'Sized'):
81 assert (isinstance(obj, collections.Iterable) and
82 isinstance(obj, collections.Sized))
dpranke@chromium.org923950f2011-03-17 23:40:00 +000083
84
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000085class SyntaxErrorInOwnersFile(Exception):
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000086 def __init__(self, path, lineno, msg):
87 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000088 self.path = path
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000089 self.lineno = lineno
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000090 self.msg = msg
91
92 def __str__(self):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +000093 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000094
95
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000096class Database(object):
97 """A database of OWNERS files for a repository.
98
99 This class allows you to find a suggested set of reviewers for a list
100 of changed files, and see if a list of changed files is covered by a
101 list of reviewers."""
102
Jochen Eisingereb744762017-04-05 11:00:05 +0200103 def __init__(self, root, fopen, os_path):
dpranke@chromium.org898a10e2011-03-04 21:54:43 +0000104 """Args:
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000105 root: the path to the root of the Repository
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000106 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000107 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700108 'exists', 'join', and 'relpath'
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000109 """
110 self.root = root
111 self.fopen = fopen
112 self.os_path = os_path
113
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000114 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000115 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000116
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200117 # Replacement contents for the given files. Maps the file name of an
118 # OWNERS file (relative to root) to an iterator returning the replacement
119 # file contents.
120 self.override_files = {}
121
dtu944b6052016-07-14 14:48:21 -0700122 # Mapping of owners to the paths or globs they own.
123 self._owners_to_paths = {EVERYONE: set()}
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000124
125 # Mapping of paths to authorized owners.
dtu944b6052016-07-14 14:48:21 -0700126 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000127
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000128 # Mapping reviewers to the preceding comment per file in the OWNERS files.
129 self.comments = {}
130
nick7e16cf32016-09-16 16:05:05 -0700131 # Cache of compiled regexes for _fnmatch()
132 self._fnmatch_cache = {}
133
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000134 # Set of paths that stop us from looking above them for owners.
135 # (This is implicitly true for the root directory).
dtu944b6052016-07-14 14:48:21 -0700136 self._stop_looking = set([''])
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000137
peter@chromium.org2ce13132015-04-16 16:42:08 +0000138 # Set of files which have already been read.
139 self.read_files = set()
140
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800141 # Set of files which were included from other files. Files are processed
142 # differently depending on whether they are regular owners files or
143 # being included from another file.
144 self._included_files = {}
145
Jochen Eisingereb744762017-04-05 11:00:05 +0200146 # File with global status lines for owners.
147 self._status_file = None
148
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000149 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000150 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000151
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000152 files is a sequence of paths relative to (and under) self.root.
153 If author is nonempty, we ensure it is not included in the set returned
154 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000155 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000156 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700157
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000158 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000159 if EVERYONE in suggested_owners:
160 if len(suggested_owners) > 1:
161 suggested_owners.remove(EVERYONE)
162 else:
163 suggested_owners = set(['<anyone>'])
164 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000165
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000166 def files_not_covered_by(self, files, reviewers):
167 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000168
169 Args:
170 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000171 reviewers is a sequence of strings matching self.email_regexp.
172 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000173 self._check_paths(files)
174 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000175 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000176
dtu944b6052016-07-14 14:48:21 -0700177 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000178
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000179 def _check_paths(self, files):
180 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000181 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000182 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000183 assert all(not self.os_path.isabs(f) and
184 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000185
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000186 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000187 _assert_is_collection(reviewers)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000188 assert all(self.email_regexp.match(r) for r in reviewers)
189
dtu944b6052016-07-14 14:48:21 -0700190 def _is_obj_covered_by(self, objname, reviewers):
191 reviewers = list(reviewers) + [EVERYONE]
192 while True:
193 for reviewer in reviewers:
194 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
195 if fnmatch.fnmatch(objname, owned_pattern):
196 return True
197 if self._should_stop_looking(objname):
198 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000199 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700200 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000201
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000202 def _enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000203 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000204 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700205 while not self._owners_for(dirpath):
206 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000207 break
208 dirpath = self.os_path.dirname(dirpath)
209 return dirpath
210
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000211 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200212 self._read_global_comments()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000213 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000214 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 14:48:21 -0700215 while not self._owners_for(dirpath):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000216 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700217 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000218 break
219 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000220
dtu944b6052016-07-14 14:48:21 -0700221 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 16:05:05 -0700222 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 14:48:21 -0700223 for stop_looking in self._stop_looking)
224
225 def _owners_for(self, objname):
226 obj_owners = set()
227 for owned_path, path_owners in self._paths_to_owners.iteritems():
nick7e16cf32016-09-16 16:05:05 -0700228 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 14:48:21 -0700229 obj_owners |= path_owners
230 return obj_owners
231
peter@chromium.org2ce13132015-04-16 16:42:08 +0000232 def _read_owners(self, path):
233 owners_path = self.os_path.join(self.root, path)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000234 if not self.os_path.exists(owners_path):
235 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000236
237 if owners_path in self.read_files:
238 return
239
240 self.read_files.add(owners_path)
241
Jochen Eisingereb744762017-04-05 11:00:05 +0200242 is_toplevel = path == 'OWNERS'
243
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000244 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000245 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000246 in_comment = False
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200247 # We treat the beginning of the file as an blank line.
248 previous_line_was_blank = True
249 reset_comment_after_use = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000250 lineno = 0
Jochen Eisingerd0573ec2017-04-13 10:55:06 +0200251
252 if path in self.override_files:
253 file_iter = self.override_files[path]
254 else:
255 file_iter = self.fopen(owners_path)
256
257 for line in file_iter:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000258 lineno += 1
259 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000260 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 11:00:05 +0200261 if is_toplevel:
262 m = re.match('#\s*OWNERS_STATUS\s+=\s+(.+)$', line)
263 if m:
264 self._status_file = m.group(1).strip()
265 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000266 if not in_comment:
267 comment = []
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200268 reset_comment_after_use = not previous_line_was_blank
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000269 comment.append(line[1:].strip())
270 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000271 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000272 in_comment = False
273
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200274 if line == '':
275 comment = []
276 previous_line_was_blank = True
277 continue
278
279 previous_line_was_blank = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000280 if line == 'set noparent':
dtu944b6052016-07-14 14:48:21 -0700281 self._stop_looking.add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000282 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000283
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000284 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000285 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000286 glob_string = m.group(1).strip()
287 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000288 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000289 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000290 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000291 'per-file globs cannot span directories or use escapes: "%s"' %
292 line)
dtu944b6052016-07-14 14:48:21 -0700293 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800294 self._add_entry(relative_glob_string, directive, owners_path,
295 lineno, '\n'.join(comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200296 if reset_comment_after_use:
297 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000298 continue
299
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000300 if line.startswith('set '):
301 raise SyntaxErrorInOwnersFile(owners_path, lineno,
302 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000303
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800304 self._add_entry(dirpath, line, owners_path, lineno,
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000305 ' '.join(comment))
Jochen Eisingerb624bfe2017-04-19 14:55:34 +0200306 if reset_comment_after_use:
307 comment = []
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000308
Jochen Eisinger72606f82017-04-04 10:44:18 +0200309 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 11:00:05 +0200310 if not self._status_file:
311 if not 'OWNERS' in self.read_files:
312 self._read_owners('OWNERS')
313 if not self._status_file:
314 return
Jochen Eisinger72606f82017-04-04 10:44:18 +0200315
Jochen Eisingereb744762017-04-05 11:00:05 +0200316 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 10:44:18 +0200317 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 11:00:05 +0200318 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 10:44:18 +0200319 owners_status_path)
320
321 if owners_status_path in self.read_files:
322 return
323
324 self.read_files.add(owners_status_path)
325
326 lineno = 0
327 for line in self.fopen(owners_status_path):
328 lineno += 1
329 line = line.strip()
330 if line.startswith('#'):
331 continue
332 if line == '':
333 continue
334
335 m = re.match('(.+?):(.+)', line)
336 if m:
337 owner = m.group(1).strip()
338 comment = m.group(2).strip()
339 if not self.email_regexp.match(owner):
340 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
341 'invalid email address: "%s"' % owner)
342
343 self.comments.setdefault(owner, {})
344 self.comments[owner][GLOBAL_STATUS] = comment
345 continue
346
347 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
348 'cannot parse status entry: "%s"' % line.strip())
349
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800350 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000351 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800352 self._stop_looking.add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000353 elif directive.startswith('file:'):
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800354 include_file = self._resolve_include(directive[5:], owners_path)
355 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000356 raise SyntaxErrorInOwnersFile(owners_path, lineno,
357 ('%s does not refer to an existing file.' % directive[5:]))
358
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800359 included_owners = self._read_just_the_owners(include_file)
360 for owner in included_owners:
361 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
362 self._paths_to_owners.setdefault(owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000363 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200364 if comment:
365 self.comments.setdefault(directive, {})
366 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800367 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
368 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000369 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000370 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800371 ('"%s" is not a "set noparent", file include, "*", '
372 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000373
peter@chromium.org2ce13132015-04-16 16:42:08 +0000374 def _resolve_include(self, path, start):
375 if path.startswith('//'):
376 include_path = path[2:]
377 else:
378 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700379 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000380 include_path = self.os_path.join(start, path)
381
382 owners_path = self.os_path.join(self.root, include_path)
383 if not self.os_path.exists(owners_path):
384 return None
385
386 return include_path
387
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800388 def _read_just_the_owners(self, include_file):
389 if include_file in self._included_files:
390 return self._included_files[include_file]
391
392 owners = set()
393 self._included_files[include_file] = owners
394 lineno = 0
395 for line in self.fopen(self.os_path.join(self.root, include_file)):
396 lineno += 1
397 line = line.strip()
398 if (line.startswith('#') or line == '' or
399 line.startswith('set noparent') or
400 line.startswith('per-file')):
401 continue
402
403 if self.email_regexp.match(line) or line == EVERYONE:
404 owners.add(line)
405 continue
406 if line.startswith('file:'):
407 sub_include_file = self._resolve_include(line[5:], include_file)
408 sub_owners = self._read_just_the_owners(sub_include_file)
409 owners.update(sub_owners)
410 continue
411
412 raise SyntaxErrorInOwnersFile(include_file, lineno,
413 ('"%s" is not a "set noparent", file include, "*", '
414 'or an email address.' % (line,)))
415 return owners
416
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000417 def _covering_set_of_owners_for(self, files, author):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000418 dirs_remaining = set(self._enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000419 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000420 suggested_owners = set()
Aaron Gable93248c52017-05-15 11:23:02 -0700421 while dirs_remaining and all_possible_owners:
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000422 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
423 suggested_owners.add(owner)
424 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
425 dirs_remaining -= dirs_to_remove
Aaron Gable93248c52017-05-15 11:23:02 -0700426 # Now that we've used `owner` and covered all their dirs, remove them
427 # from consideration.
428 del all_possible_owners[owner]
429 for o, dirs in all_possible_owners.items():
430 new_dirs = [(d, dist) for (d, dist) in dirs if d not in dirs_to_remove]
431 if not new_dirs:
432 del all_possible_owners[o]
433 else:
434 all_possible_owners[o] = new_dirs
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000435 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000436
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000437 def all_possible_owners(self, dirs, author):
Aaron Gable93248c52017-05-15 11:23:02 -0700438 """Returns a dict of {potential owner: (dir, distance)} mappings.
439
440 A distance of 1 is the lowest/closest possible distance (which makes the
441 subsequent math easier).
442 """
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000443 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000444 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000445 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000446 distance = 1
447 while True:
dtu944b6052016-07-14 14:48:21 -0700448 for owner in self._owners_for(dirname):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000449 if author and owner == author:
450 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000451 all_possible_owners.setdefault(owner, [])
452 # If the same person is in multiple OWNERS files above a given
453 # directory, only count the closest one.
454 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
455 all_possible_owners[owner].append((current_dir, distance))
dtu944b6052016-07-14 14:48:21 -0700456 if self._should_stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000457 break
458 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000459 distance += 1
460 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000461
nick7e16cf32016-09-16 16:05:05 -0700462 def _fnmatch(self, filename, pattern):
463 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
464 matcher = self._fnmatch_cache.get(pattern)
465 if matcher is None:
466 matcher = re.compile(fnmatch.translate(pattern)).match
467 self._fnmatch_cache[pattern] = matcher
468 return matcher(filename)
469
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000470 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000471 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000472 # We want to minimize both the number of reviewers and the distance
473 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
474 # an arbitrarily-selected scaling factor that seems to work well - it
475 # will select one reviewer in the parent directory over three reviewers
476 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000477 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000478 for owner in all_possible_owners:
479 total_distance = 0
480 num_directories_owned = 0
481 for dirname, distance in all_possible_owners[owner]:
482 if dirname in dirs:
483 total_distance += distance
484 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000485 if num_directories_owned:
486 result[owner] = (total_distance /
487 pow(num_directories_owned, 1.75))
488 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000489
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000490 @staticmethod
491 def lowest_cost_owner(all_possible_owners, dirs):
492 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
493 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000494 # Return the lowest cost owner. In the case of a tie, pick one randomly.
495 lowest_cost = min(total_costs_by_owner.itervalues())
496 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000497 lambda owner: total_costs_by_owner[owner] == lowest_cost,
498 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000499 return random.Random().choice(lowest_cost_owners)