blob: 801812710af7c572c398ac45e613a5928b363beb [file] [log] [blame]
[email protected]f46aed92012-03-08 09:18:171# Copyright (c) 2012 The Chromium Authors. All rights reserved.
[email protected]2a009622011-03-01 02:43:312# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
[email protected]17cc2442012-10-17 21:12:095"""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
[email protected]d16e48b2012-12-03 21:53:4917 | "per-file" \s+ glob \s* "=" \s* directive
[email protected]17cc2442012-10-17 21:12:0918 | comment
19
20directive := "set noparent"
[email protected]2ce13132015-04-16 16:42:0821 | "file:" glob
[email protected]17cc2442012-10-17 21:12:0922 | email_address
23 | "*"
24
25glob := [a-zA-Z0-9_-*?]+
26
27comment := "#" [^"\n"]*
28
29Email addresses must follow the [email protected] 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
[email protected]2ce13132015-04-16 16:42:0849If 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
[email protected]17cc2442012-10-17 21:12:0954Examples for all of these combinations can be found in tests/owners_unittest.py.
55"""
[email protected]2a009622011-03-01 02:43:3156
[email protected]fdecfb72011-03-16 23:27:2357import collections
dtu944b6052016-07-14 21:48:2158import fnmatch
[email protected]c591a702012-12-20 20:14:5859import random
[email protected]6dada4e2011-03-08 22:32:4060import 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\-\+\%\.]+$'
[email protected]2a009622011-03-01 02:43:3169
[email protected]2a009622011-03-01 02:43:3170
Jochen Eisinger72606f82017-04-04 08:44:1871# Key for global comments per email address. Should be unlikely to be a
72# pathname.
73GLOBAL_STATUS = '*'
74
75
[email protected]923950f2011-03-17 23:40:0076def _assert_is_collection(obj):
[email protected]e6a4ab32011-03-31 01:23:0877 assert not isinstance(obj, basestring)
[email protected]725f1c32011-04-01 20:24:5478 # Module 'collections' has no 'Iterable' member
Quinten Yearsleyb2cc4a92016-12-15 21:53:2679 # pylint: disable=no-member
[email protected]e6a4ab32011-03-31 01:23:0880 if hasattr(collections, 'Iterable') and hasattr(collections, 'Sized'):
81 assert (isinstance(obj, collections.Iterable) and
82 isinstance(obj, collections.Sized))
[email protected]923950f2011-03-17 23:40:0083
84
[email protected]898a10e2011-03-04 21:54:4385class SyntaxErrorInOwnersFile(Exception):
[email protected]86bbf192011-03-09 21:37:0686 def __init__(self, path, lineno, msg):
87 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
[email protected]898a10e2011-03-04 21:54:4388 self.path = path
[email protected]86bbf192011-03-09 21:37:0689 self.lineno = lineno
[email protected]898a10e2011-03-04 21:54:4390 self.msg = msg
91
92 def __str__(self):
[email protected]faf3fdf2013-09-20 02:11:4893 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
[email protected]898a10e2011-03-04 21:54:4394
95
[email protected]898a10e2011-03-04 21:54:4396class 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 09:00:05103 def __init__(self, root, fopen, os_path):
[email protected]898a10e2011-03-04 21:54:43104 """Args:
[email protected]2a009622011-03-01 02:43:31105 root: the path to the root of the Repository
[email protected]2a009622011-03-01 02:43:31106 open: function callback to open a text file for reading
[email protected]6dada4e2011-03-08 22:32:40107 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 20:28:59108 'exists', 'join', and 'relpath'
[email protected]2a009622011-03-01 02:43:31109 """
110 self.root = root
111 self.fopen = fopen
112 self.os_path = os_path
113
[email protected]627ea672011-03-11 23:29:03114 # Pick a default email regexp to use; callers can override as desired.
[email protected]6dada4e2011-03-08 22:32:40115 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
[email protected]2a009622011-03-01 02:43:31116
Jochen Eisingerd0573ec2017-04-13 08:55:06117 # 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 21:48:21122 # Mapping of owners to the paths or globs they own.
123 self._owners_to_paths = {EVERYONE: set()}
[email protected]6dada4e2011-03-08 22:32:40124
125 # Mapping of paths to authorized owners.
dtu944b6052016-07-14 21:48:21126 self._paths_to_owners = {}
[email protected]2a009622011-03-01 02:43:31127
[email protected]faf3fdf2013-09-20 02:11:48128 # Mapping reviewers to the preceding comment per file in the OWNERS files.
129 self.comments = {}
130
nick7e16cf32016-09-16 23:05:05131 # Cache of compiled regexes for _fnmatch()
132 self._fnmatch_cache = {}
133
[email protected]6dada4e2011-03-08 22:32:40134 # Set of paths that stop us from looking above them for owners.
135 # (This is implicitly true for the root directory).
dtu944b6052016-07-14 21:48:21136 self._stop_looking = set([''])
[email protected]2a009622011-03-01 02:43:31137
[email protected]2ce13132015-04-16 16:42:08138 # Set of files which have already been read.
139 self.read_files = set()
140
Dirk Pranke4dc849f2017-02-28 23:31:19141 # 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 09:00:05146 # File with global status lines for owners.
147 self._status_file = None
148
[email protected]dbf8b4e2013-02-28 19:24:16149 def reviewers_for(self, files, author):
[email protected]fdecfb72011-03-16 23:27:23150 """Returns a suggested set of reviewers that will cover the files.
[email protected]2a009622011-03-01 02:43:31151
[email protected]dbf8b4e2013-02-28 19:24:16152 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."""
[email protected]7eea2592011-03-09 21:35:46155 self._check_paths(files)
[email protected]faf3fdf2013-09-20 02:11:48156 self.load_data_needed_for(files)
dtu944b6052016-07-14 21:48:21157
[email protected]dbf8b4e2013-02-28 19:24:16158 suggested_owners = self._covering_set_of_owners_for(files, author)
[email protected]9d66f482013-01-18 02:57:11159 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
[email protected]2a009622011-03-01 02:43:31165
[email protected]6b1e3ee2013-02-23 00:06:38166 def files_not_covered_by(self, files, reviewers):
167 """Returns the files not owned by one of the reviewers.
[email protected]fdecfb72011-03-16 23:27:23168
169 Args:
170 files is a sequence of paths relative to (and under) self.root.
[email protected]f46aed92012-03-08 09:18:17171 reviewers is a sequence of strings matching self.email_regexp.
172 """
[email protected]7eea2592011-03-09 21:35:46173 self._check_paths(files)
174 self._check_reviewers(reviewers)
[email protected]faf3fdf2013-09-20 02:11:48175 self.load_data_needed_for(files)
[email protected]f46aed92012-03-08 09:18:17176
dtu944b6052016-07-14 21:48:21177 return set(f for f in files if not self._is_obj_covered_by(f, reviewers))
[email protected]6dada4e2011-03-08 22:32:40178
[email protected]7eea2592011-03-09 21:35:46179 def _check_paths(self, files):
180 def _is_under(f, pfx):
[email protected]6dada4e2011-03-08 22:32:40181 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
[email protected]923950f2011-03-17 23:40:00182 _assert_is_collection(files)
[email protected]b54a78e2012-12-13 23:37:23183 assert all(not self.os_path.isabs(f) and
184 _is_under(f, self.os_path.abspath(self.root)) for f in files)
[email protected]6dada4e2011-03-08 22:32:40185
[email protected]7eea2592011-03-09 21:35:46186 def _check_reviewers(self, reviewers):
[email protected]923950f2011-03-17 23:40:00187 _assert_is_collection(reviewers)
Gabriel Charette9df9e9f2017-06-14 19:44:50188 assert all(self.email_regexp.match(r) for r in reviewers), reviewers
[email protected]6dada4e2011-03-08 22:32:40189
dtu944b6052016-07-14 21:48:21190 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
[email protected]6b1e3ee2013-02-23 00:06:38199 objname = self.os_path.dirname(objname)
dtu944b6052016-07-14 21:48:21200 return False
[email protected]6dada4e2011-03-08 22:32:40201
Francois Dorayd42c6812017-05-30 19:10:20202 def enclosing_dir_with_owners(self, objname):
[email protected]f46aed92012-03-08 09:18:17203 """Returns the innermost enclosing directory that has an OWNERS file."""
[email protected]6b1e3ee2013-02-23 00:06:38204 dirpath = objname
dtu944b6052016-07-14 21:48:21205 while not self._owners_for(dirpath):
206 if self._should_stop_looking(dirpath):
[email protected]f46aed92012-03-08 09:18:17207 break
208 dirpath = self.os_path.dirname(dirpath)
209 return dirpath
210
[email protected]faf3fdf2013-09-20 02:11:48211 def load_data_needed_for(self, files):
Jochen Eisinger72606f82017-04-04 08:44:18212 self._read_global_comments()
[email protected]2a009622011-03-01 02:43:31213 for f in files:
[email protected]6dada4e2011-03-08 22:32:40214 dirpath = self.os_path.dirname(f)
dtu944b6052016-07-14 21:48:21215 while not self._owners_for(dirpath):
[email protected]2ce13132015-04-16 16:42:08216 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dtu944b6052016-07-14 21:48:21217 if self._should_stop_looking(dirpath):
[email protected]6dada4e2011-03-08 22:32:40218 break
219 dirpath = self.os_path.dirname(dirpath)
[email protected]2a009622011-03-01 02:43:31220
dtu944b6052016-07-14 21:48:21221 def _should_stop_looking(self, objname):
nick7e16cf32016-09-16 23:05:05222 return any(self._fnmatch(objname, stop_looking)
dtu944b6052016-07-14 21:48:21223 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 23:05:05228 if self._fnmatch(objname, owned_path):
dtu944b6052016-07-14 21:48:21229 obj_owners |= path_owners
230 return obj_owners
231
[email protected]2ce13132015-04-16 16:42:08232 def _read_owners(self, path):
233 owners_path = self.os_path.join(self.root, path)
[email protected]6dada4e2011-03-08 22:32:40234 if not self.os_path.exists(owners_path):
235 return
[email protected]2ce13132015-04-16 16:42:08236
237 if owners_path in self.read_files:
238 return
239
240 self.read_files.add(owners_path)
241
Jochen Eisingereb744762017-04-05 09:00:05242 is_toplevel = path == 'OWNERS'
243
[email protected]faf3fdf2013-09-20 02:11:48244 comment = []
[email protected]2ce13132015-04-16 16:42:08245 dirpath = self.os_path.dirname(path)
[email protected]faf3fdf2013-09-20 02:11:48246 in_comment = False
Jochen Eisingerb624bfe2017-04-19 12:55:34247 # We treat the beginning of the file as an blank line.
248 previous_line_was_blank = True
249 reset_comment_after_use = False
[email protected]6dada4e2011-03-08 22:32:40250 lineno = 0
Jochen Eisingerd0573ec2017-04-13 08:55:06251
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:
[email protected]6dada4e2011-03-08 22:32:40258 lineno += 1
259 line = line.strip()
[email protected]faf3fdf2013-09-20 02:11:48260 if line.startswith('#'):
Jochen Eisingereb744762017-04-05 09:00:05261 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
[email protected]faf3fdf2013-09-20 02:11:48266 if not in_comment:
267 comment = []
Jochen Eisingerb624bfe2017-04-19 12:55:34268 reset_comment_after_use = not previous_line_was_blank
[email protected]faf3fdf2013-09-20 02:11:48269 comment.append(line[1:].strip())
270 in_comment = True
[email protected]6dada4e2011-03-08 22:32:40271 continue
[email protected]faf3fdf2013-09-20 02:11:48272 in_comment = False
273
Jochen Eisingerb624bfe2017-04-19 12:55:34274 if line == '':
275 comment = []
276 previous_line_was_blank = True
277 continue
278
279 previous_line_was_blank = False
[email protected]6dada4e2011-03-08 22:32:40280 if line == 'set noparent':
dtu944b6052016-07-14 21:48:21281 self._stop_looking.add(dirpath)
[email protected]6dada4e2011-03-08 22:32:40282 continue
[email protected]17cc2442012-10-17 21:12:09283
[email protected]faf3fdf2013-09-20 02:11:48284 m = re.match('per-file (.+)=(.+)', line)
[email protected]17cc2442012-10-17 21:12:09285 if m:
[email protected]d16e48b2012-12-03 21:53:49286 glob_string = m.group(1).strip()
287 directive = m.group(2).strip()
[email protected]17cc2442012-10-17 21:12:09288 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
[email protected]9e227d52012-10-20 23:47:42289 if '/' in glob_string or '\\' in glob_string:
[email protected]e3b1c3d2012-10-20 22:28:14290 raise SyntaxErrorInOwnersFile(owners_path, lineno,
[email protected]9e227d52012-10-20 23:47:42291 'per-file globs cannot span directories or use escapes: "%s"' %
292 line)
dtu944b6052016-07-14 21:48:21293 relative_glob_string = self.os_path.relpath(full_glob_string, self.root)
Dirk Pranke4dc849f2017-02-28 23:31:19294 self._add_entry(relative_glob_string, directive, owners_path,
295 lineno, '\n'.join(comment))
Jochen Eisingerb624bfe2017-04-19 12:55:34296 if reset_comment_after_use:
297 comment = []
[email protected]17cc2442012-10-17 21:12:09298 continue
299
[email protected]86bbf192011-03-09 21:37:06300 if line.startswith('set '):
301 raise SyntaxErrorInOwnersFile(owners_path, lineno,
302 'unknown option: "%s"' % line[4:].strip())
[email protected]17cc2442012-10-17 21:12:09303
Dirk Pranke4dc849f2017-02-28 23:31:19304 self._add_entry(dirpath, line, owners_path, lineno,
[email protected]faf3fdf2013-09-20 02:11:48305 ' '.join(comment))
Jochen Eisingerb624bfe2017-04-19 12:55:34306 if reset_comment_after_use:
307 comment = []
[email protected]17cc2442012-10-17 21:12:09308
Jochen Eisinger72606f82017-04-04 08:44:18309 def _read_global_comments(self):
Jochen Eisingereb744762017-04-05 09:00:05310 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 08:44:18315
Jochen Eisingereb744762017-04-05 09:00:05316 owners_status_path = self.os_path.join(self.root, self._status_file)
Jochen Eisinger72606f82017-04-04 08:44:18317 if not self.os_path.exists(owners_status_path):
Jochen Eisingereb744762017-04-05 09:00:05318 raise IOError('Could not find global status file "%s"' %
Jochen Eisinger72606f82017-04-04 08:44:18319 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 23:31:19350 def _add_entry(self, owned_paths, directive, owners_path, lineno, comment):
[email protected]faf3fdf2013-09-20 02:11:48351 if directive == 'set noparent':
Dirk Pranke4dc849f2017-02-28 23:31:19352 self._stop_looking.add(owned_paths)
[email protected]2ce13132015-04-16 16:42:08353 elif directive.startswith('file:'):
Dirk Pranke4dc849f2017-02-28 23:31:19354 include_file = self._resolve_include(directive[5:], owners_path)
355 if not include_file:
[email protected]2ce13132015-04-16 16:42:08356 raise SyntaxErrorInOwnersFile(owners_path, lineno,
357 ('%s does not refer to an existing file.' % directive[5:]))
358
Dirk Pranke4dc849f2017-02-28 23:31:19359 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)
[email protected]17cc2442012-10-17 21:12:09363 elif self.email_regexp.match(directive) or directive == EVERYONE:
Jochen Eisinger72606f82017-04-04 08:44:18364 if comment:
365 self.comments.setdefault(directive, {})
366 self.comments[directive][owned_paths] = comment
Dirk Pranke4dc849f2017-02-28 23:31:19367 self._owners_to_paths.setdefault(directive, set()).add(owned_paths)
368 self._paths_to_owners.setdefault(owned_paths, set()).add(directive)
[email protected]17cc2442012-10-17 21:12:09369 else:
[email protected]86bbf192011-03-09 21:37:06370 raise SyntaxErrorInOwnersFile(owners_path, lineno,
Dirk Pranke4dc849f2017-02-28 23:31:19371 ('"%s" is not a "set noparent", file include, "*", '
372 'or an email address.' % (directive,)))
[email protected]17cc2442012-10-17 21:12:09373
[email protected]2ce13132015-04-16 16:42:08374 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 20:28:59379 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
[email protected]2ce13132015-04-16 16:42:08380 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 23:31:19388 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
[email protected]dbf8b4e2013-02-28 19:24:16417 def _covering_set_of_owners_for(self, files, author):
Francois Dorayd42c6812017-05-30 19:10:20418 dirs_remaining = set(self.enclosing_dir_with_owners(f) for f in files)
[email protected]faf3fdf2013-09-20 02:11:48419 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
[email protected]c591a702012-12-20 20:14:58420 suggested_owners = set()
Aaron Gable93248c52017-05-15 18:23:02421 while dirs_remaining and all_possible_owners:
[email protected]c591a702012-12-20 20:14:58422 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 18:23:02426 # 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
[email protected]c591a702012-12-20 20:14:58435 return suggested_owners
[email protected]5e5d37b2012-12-19 21:04:58436
[email protected]faf3fdf2013-09-20 02:11:48437 def all_possible_owners(self, dirs, author):
Aaron Gable93248c52017-05-15 18:23:02438 """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 """
[email protected]c591a702012-12-20 20:14:58443 all_possible_owners = {}
[email protected]046e1752012-05-07 05:56:12444 for current_dir in dirs:
[email protected]046e1752012-05-07 05:56:12445 dirname = current_dir
[email protected]c591a702012-12-20 20:14:58446 distance = 1
447 while True:
dtu944b6052016-07-14 21:48:21448 for owner in self._owners_for(dirname):
[email protected]dbf8b4e2013-02-28 19:24:16449 if author and owner == author:
450 continue
[email protected]c591a702012-12-20 20:14:58451 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 21:48:21456 if self._should_stop_looking(dirname):
[email protected]6dada4e2011-03-08 22:32:40457 break
458 dirname = self.os_path.dirname(dirname)
[email protected]c591a702012-12-20 20:14:58459 distance += 1
460 return all_possible_owners
[email protected]046e1752012-05-07 05:56:12461
nick7e16cf32016-09-16 23:05:05462 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
[email protected]c591a702012-12-20 20:14:58470 @staticmethod
[email protected]faf3fdf2013-09-20 02:11:48471 def total_costs_by_owner(all_possible_owners, dirs):
[email protected]c591a702012-12-20 20:14:58472 # 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.
[email protected]faf3fdf2013-09-20 02:11:48477 result = {}
[email protected]c591a702012-12-20 20:14:58478 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
[email protected]faf3fdf2013-09-20 02:11:48485 if num_directories_owned:
486 result[owner] = (total_distance /
487 pow(num_directories_owned, 1.75))
488 return result
[email protected]046e1752012-05-07 05:56:12489
[email protected]faf3fdf2013-09-20 02:11:48490 @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)
[email protected]c591a702012-12-20 20:14:58494 # 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(
[email protected]faf3fdf2013-09-20 02:11:48497 lambda owner: total_costs_by_owner[owner] == lowest_cost,
498 total_costs_by_owner)
[email protected]c591a702012-12-20 20:14:58499 return random.Random().choice(lowest_cost_owners)