blob: 2a44a9da9f7b3cde2b2f6edee55190a6d14420ae [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 Eisinger72606f82017-04-04 10:44:18 +0200103 def __init__(self, root, status_file, 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
Jochen Eisinger72606f82017-04-04 10:44:18 +0200106 status_file: the path relative to root to global status entries or None
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000107 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000108 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700109 'exists', 'join', and 'relpath'
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000110 """
111 self.root = root
Jochen Eisinger72606f82017-04-04 10:44:18 +0200112 self.status_file = status_file
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000113 self.fopen = fopen
114 self.os_path = os_path
115
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000116 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000117 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000118
dtu944b6052016-07-14 14:48:21 -0700119 # Mapping of owners to the paths or globs they own.
120 self._owners_to_paths = {EVERYONE: set()}
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000121
122 # Mapping of paths to authorized owners.
dtu944b6052016-07-14 14:48:21 -0700123 self._paths_to_owners = {}
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000124
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000125 # Mapping reviewers to the preceding comment per file in the OWNERS files.
126 self.comments = {}
127
nick7e16cf32016-09-16 16:05:05 -0700128 # Cache of compiled regexes for _fnmatch()
129 self._fnmatch_cache = {}
130
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000131 # Set of paths that stop us from looking above them for owners.
132 # (This is implicitly true for the root directory).
dtu944b6052016-07-14 14:48:21 -0700133 self._stop_looking = set([''])
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000134
peter@chromium.org2ce13132015-04-16 16:42:08 +0000135 # Set of files which have already been read.
136 self.read_files = set()
137
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800138 # Set of files which were included from other files. Files are processed
139 # differently depending on whether they are regular owners files or
140 # being included from another file.
141 self._included_files = {}
142
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000143 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000144 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000145
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000146 files is a sequence of paths relative to (and under) self.root.
147 If author is nonempty, we ensure it is not included in the set returned
148 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000149 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000150 self.load_data_needed_for(files)
dtu944b6052016-07-14 14:48:21 -0700151
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000152 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000153 if EVERYONE in suggested_owners:
154 if len(suggested_owners) > 1:
155 suggested_owners.remove(EVERYONE)
156 else:
157 suggested_owners = set(['<anyone>'])
158 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000159
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000160 def files_not_covered_by(self, files, reviewers):
161 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000162
163 Args:
164 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000165 reviewers is a sequence of strings matching self.email_regexp.
166 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000167 self._check_paths(files)
168 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000169 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000170
dtu944b6052016-07-14 14:48:21 -0700171 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000172
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000173 def _check_paths(self, files):
174 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000175 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000176 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000177 assert all(not self.os_path.isabs(f) and
178 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000179
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000180 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000181 _assert_is_collection(reviewers)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000182 assert all(self.email_regexp.match(r) for r in reviewers)
183
dtu944b6052016-07-14 14:48:21 -0700184 def _is_obj_covered_by(self, objname, reviewers):
185 reviewers = list(reviewers) + [EVERYONE]
186 while True:
187 for reviewer in reviewers:
188 for owned_pattern in self._owners_to_paths.get(reviewer, set()):
189 if fnmatch.fnmatch(objname, owned_pattern):
190 return True
191 if self._should_stop_looking(objname):
192 break
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000193 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 14:48:21 -0700194 return False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000195
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000196 def _enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000197 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000198 dirpath = objname
dtu944b6052016-07-14 14:48:21 -0700199 while not self._owners_for(dirpath):
200 if self._should_stop_looking(dirpath):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000201 break
202 dirpath = self.os_path.dirname(dirpath)
203 return dirpath
204
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000205 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 10:44:18 +0200206 self._read_global_comments()
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000207 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000208 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 14:48:21 -0700209 while not self._owners_for(dirpath):
peter@chromium.org2ce13132015-04-16 16:42:08 +0000210 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 14:48:21 -0700211 if self._should_stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000212 break
213 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000214
dtu944b6052016-07-14 14:48:21 -0700215 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 16:05:05 -0700216 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 14:48:21 -0700217 for stop_looking in self._stop_looking)
218
219 def _owners_for(self, objname):
220 obj_owners = set()
221 for owned_path, path_owners in self._paths_to_owners.iteritems():
nick7e16cf32016-09-16 16:05:05 -0700222 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 14:48:21 -0700223 obj_owners |= path_owners
224 return obj_owners
225
peter@chromium.org2ce13132015-04-16 16:42:08 +0000226 def _read_owners(self, path):
227 owners_path = self.os_path.join(self.root, path)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000228 if not self.os_path.exists(owners_path):
229 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000230
231 if owners_path in self.read_files:
232 return
233
234 self.read_files.add(owners_path)
235
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000236 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000237 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000238 in_comment = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000239 lineno = 0
240 for line in self.fopen(owners_path):
241 lineno += 1
242 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000243 if line.startswith('#'):
244 if not in_comment:
245 comment = []
246 comment.append(line[1:].strip())
247 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000248 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000249 if line == '':
250 continue
251 in_comment = False
252
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000253 if line == 'set noparent':
dtu944b6052016-07-14 14:48:21 -0700254 self._stop_looking.add(dirpath)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000255 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000256
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000257 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000258 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000259 glob_string = m.group(1).strip()
260 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000261 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000262 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000263 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000264 'per-file globs cannot span directories or use escapes: "%s"' %
265 line)
dtu944b6052016-07-14 14:48:21 -0700266 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800267 self._add_entry(relative_glob_string, directive, owners_path,
268 lineno, '\n'.join(comment))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000269 continue
270
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000271 if line.startswith('set '):
272 raise SyntaxErrorInOwnersFile(owners_path, lineno,
273 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000274
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800275 self._add_entry(dirpath, line, owners_path, lineno,
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000276 ' '.join(comment))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000277
Jochen Eisinger72606f82017-04-04 10:44:18 +0200278 def _read_global_comments(self):
279 if not self.status_file:
280 return
281
282 owners_status_path = self.os_path.join(self.root, self.status_file)
283 if not self.os_path.exists(owners_status_path):
284 raise IOError('Could not find global status file "%s"' %
285 owners_status_path)
286
287 if owners_status_path in self.read_files:
288 return
289
290 self.read_files.add(owners_status_path)
291
292 lineno = 0
293 for line in self.fopen(owners_status_path):
294 lineno += 1
295 line = line.strip()
296 if line.startswith('#'):
297 continue
298 if line == '':
299 continue
300
301 m = re.match('(.+?):(.+)', line)
302 if m:
303 owner = m.group(1).strip()
304 comment = m.group(2).strip()
305 if not self.email_regexp.match(owner):
306 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
307 'invalid email address: "%s"' % owner)
308
309 self.comments.setdefault(owner, {})
310 self.comments[owner][GLOBAL_STATUS] = comment
311 continue
312
313 raise SyntaxErrorInOwnersFile(owners_status_path, lineno,
314 'cannot parse status entry: "%s"' % line.strip())
315
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800316 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000317 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800318 self._stop_looking.add(owned_paths)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000319 elif directive.startswith('file:'):
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800320 include_file = self._resolve_include(directive[5:], owners_path)
321 if not include_file:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000322 raise SyntaxErrorInOwnersFile(owners_path, lineno,
323 ('%s does not refer to an existing file.' % directive[5:]))
324
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800325 included_owners = self._read_just_the_owners(include_file)
326 for owner in included_owners:
327 self._owners_to_paths.setdefault(owner, set()).add(owned_paths)
328 self._paths_to_owners.setdefault(owned_paths, set()).add(owner)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000329 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 10:44:18 +0200330 if comment:
331 self.comments.setdefault(directive, {})
332 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800333 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
334 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000335 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000336 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800337 ('"%s" is not a "set noparent", file include, "*", '
338 'or an email address.' % (directive,)))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000339
peter@chromium.org2ce13132015-04-16 16:42:08 +0000340 def _resolve_include(self, path, start):
341 if path.startswith('//'):
342 include_path = path[2:]
343 else:
344 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700345 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000346 include_path = self.os_path.join(start, path)
347
348 owners_path = self.os_path.join(self.root, include_path)
349 if not self.os_path.exists(owners_path):
350 return None
351
352 return include_path
353
Dirk Pranke4dc849f2017-02-28 15:31:19 -0800354 def _read_just_the_owners(self, include_file):
355 if include_file in self._included_files:
356 return self._included_files[include_file]
357
358 owners = set()
359 self._included_files[include_file] = owners
360 lineno = 0
361 for line in self.fopen(self.os_path.join(self.root, include_file)):
362 lineno += 1
363 line = line.strip()
364 if (line.startswith('#') or line == '' or
365 line.startswith('set noparent') or
366 line.startswith('per-file')):
367 continue
368
369 if self.email_regexp.match(line) or line == EVERYONE:
370 owners.add(line)
371 continue
372 if line.startswith('file:'):
373 sub_include_file = self._resolve_include(line[5:], include_file)
374 sub_owners = self._read_just_the_owners(sub_include_file)
375 owners.update(sub_owners)
376 continue
377
378 raise SyntaxErrorInOwnersFile(include_file, lineno,
379 ('"%s" is not a "set noparent", file include, "*", '
380 'or an email address.' % (line,)))
381 return owners
382
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000383 def _covering_set_of_owners_for(self, files, author):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000384 dirs_remaining = set(self._enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000385 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000386 suggested_owners = set()
387 while dirs_remaining:
388 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
389 suggested_owners.add(owner)
390 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
391 dirs_remaining -= dirs_to_remove
392 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000393
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000394 def all_possible_owners(self, dirs, author):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000395 """Returns a list of (potential owner, distance-from-dir) tuples; a
396 distance of 1 is the lowest/closest possible distance (which makes the
397 subsequent math easier)."""
398 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000399 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000400 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000401 distance = 1
402 while True:
dtu944b6052016-07-14 14:48:21 -0700403 for owner in self._owners_for(dirname):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000404 if author and owner == author:
405 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000406 all_possible_owners.setdefault(owner, [])
407 # If the same person is in multiple OWNERS files above a given
408 # directory, only count the closest one.
409 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
410 all_possible_owners[owner].append((current_dir, distance))
dtu944b6052016-07-14 14:48:21 -0700411 if self._should_stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000412 break
413 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000414 distance += 1
415 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000416
nick7e16cf32016-09-16 16:05:05 -0700417 def _fnmatch(self, filename, pattern):
418 """Same as fnmatch.fnmatch(), but interally caches the compiled regexes."""
419 matcher = self._fnmatch_cache.get(pattern)
420 if matcher is None:
421 matcher = re.compile(fnmatch.translate(pattern)).match
422 self._fnmatch_cache[pattern] = matcher
423 return matcher(filename)
424
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000425 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000426 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000427 # We want to minimize both the number of reviewers and the distance
428 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
429 # an arbitrarily-selected scaling factor that seems to work well - it
430 # will select one reviewer in the parent directory over three reviewers
431 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000432 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000433 for owner in all_possible_owners:
434 total_distance = 0
435 num_directories_owned = 0
436 for dirname, distance in all_possible_owners[owner]:
437 if dirname in dirs:
438 total_distance += distance
439 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000440 if num_directories_owned:
441 result[owner] = (total_distance /
442 pow(num_directories_owned, 1.75))
443 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000444
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000445 @staticmethod
446 def lowest_cost_owner(all_possible_owners, dirs):
447 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
448 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000449 # Return the lowest cost owner. In the case of a tie, pick one randomly.
450 lowest_cost = min(total_costs_by_owner.itervalues())
451 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000452 lambda owner: total_costs_by_owner[owner] == lowest_cost,
453 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000454 return random.Random().choice(lowest_cost_owners)