Commit 6dada4ee authored by dpranke@chromium.org's avatar dpranke@chromium.org

make tests work, implement 'set noparent', owners propagating down

Review URL: http://codereview.chromium.org/6627059

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@77351 0039d316-1c4b-4281-b951-d872f2087c98
parent 9ea49d29
...@@ -4,8 +4,15 @@ ...@@ -4,8 +4,15 @@
"""A database of OWNERS files.""" """A database of OWNERS files."""
class Assertion(AssertionError): import re
pass
# If this is present by itself on a line, this means that everyone can review.
EVERYONE = '*'
# Recognizes 'X@Y' email addresses. Very simplistic.
BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
class SyntaxErrorInOwnersFile(Exception): class SyntaxErrorInOwnersFile(Exception):
...@@ -22,10 +29,6 @@ class SyntaxErrorInOwnersFile(Exception): ...@@ -22,10 +29,6 @@ class SyntaxErrorInOwnersFile(Exception):
return "%s:%d syntax error" % (self.path, self.line) return "%s:%d syntax error" % (self.path, self.line)
# Wildcard email-address in the OWNERS file.
ANYONE = '*'
class Database(object): class Database(object):
"""A database of OWNERS files for a repository. """A database of OWNERS files for a repository.
...@@ -36,72 +39,113 @@ class Database(object): ...@@ -36,72 +39,113 @@ class Database(object):
def __init__(self, root, fopen, os_path): def __init__(self, root, fopen, os_path):
"""Args: """Args:
root: the path to the root of the Repository root: the path to the root of the Repository
all_owners: the list of every owner in the system
open: function callback to open a text file for reading open: function callback to open a text file for reading
os_path: module/object callback with fields for 'exists', os_path: module/object callback with fields for 'abspath', 'dirname',
'dirname', and 'join' 'exists', and 'join'
""" """
self.root = root self.root = root
self.fopen = fopen self.fopen = fopen
self.os_path = os_path self.os_path = os_path
# Mapping of files to authorized owners. # TODO: Figure out how to share the owners email addr format w/
self.files_owned_by = {} # tools/commit-queue/projects.py, especially for per-repo whitelists.
self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
# Mapping of owners to the files they own. # Mapping of owners to the paths they own.
self.owners_for = {} self.owned_by = {EVERYONE: set()}
# In-memory cached map of files to their OWNERS files. # Mapping of paths to authorized owners.
self.owners_file_for = {} self.owners_for = {}
# In-memory cache of OWNERS files and their contents # Set of paths that stop us from looking above them for owners.
self.owners_files = {} # (This is implicitly true for the root directory).
self.stop_looking = set([''])
def ReviewersFor(self, files): def ReviewersFor(self, files):
"""Returns a suggested set of reviewers that will cover the set of files. """Returns a suggested set of reviewers that will cover the set of files.
The set of files are paths relative to (and under) self.root.""" files is a set of paths relative to (and under) self.root."""
self._CheckPaths(files)
self._LoadDataNeededFor(files) self._LoadDataNeededFor(files)
return self._CoveringSetOfOwnersFor(files) return self._CoveringSetOfOwnersFor(files)
def FilesAreCoveredBy(self, files, reviewers): def FilesAreCoveredBy(self, files, reviewers):
"""Returns whether every file is owned by at least one reviewer."""
return not self.FilesNotCoveredBy(files, reviewers) return not self.FilesNotCoveredBy(files, reviewers)
def FilesNotCoveredBy(self, files, reviewers): def FilesNotCoveredBy(self, files, reviewers):
covered_files = set() """Returns the set of files that are not owned by at least one reviewer."""
for reviewer in reviewers: self._CheckPaths(files)
covered_files = covered_files.union(self.files_owned_by[reviewer]) self._CheckReviewers(reviewers)
return files.difference(covered_files) if not reviewers:
return files
def _LoadDataNeededFor(self, files): self._LoadDataNeededFor(files)
files_by_dir = self._FilesByDir(files)
covered_dirs = self._DirsCoveredBy(reviewers)
uncovered_files = []
for d, files_in_d in files_by_dir.iteritems():
if not self._IsDirCoveredBy(d, covered_dirs):
uncovered_files.extend(files_in_d)
return set(uncovered_files)
def _CheckPaths(self, files):
def _isunder(f, pfx):
return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
assert all(_isunder(f, self.os_path.abspath(self.root)) for f in files)
def _CheckReviewers(self, reviewers):
"""Verifies each reviewer is a valid email address."""
assert all(self.email_regexp.match(r) for r in reviewers)
def _FilesByDir(self, files):
dirs = {}
for f in files: for f in files:
self._LoadOwnersFor(f) dirs.setdefault(self.os_path.dirname(f), []).append(f)
return dirs
def _LoadOwnersFor(self, f): def _DirsCoveredBy(self, reviewers):
if f not in self.owners_for: dirs = self.owned_by[EVERYONE]
owner_file = self._FindOwnersFileFor(f) for r in reviewers:
self.owners_file_for[f] = owner_file dirs = dirs | self.owned_by.get(r, set())
self._ReadOwnersFile(owner_file, f) return dirs
def _FindOwnersFileFor(self, f): def _StopLooking(self, dirname):
# This is really a "do ... until dirname = ''" return dirname in self.stop_looking
dirname = self.os_path.dirname(f)
while dirname: def _IsDirCoveredBy(self, dirname, covered_dirs):
owner_path = self.os_path.join(dirname, 'OWNERS') while not dirname in covered_dirs and not self._StopLooking(dirname):
if self.os_path.exists(owner_path):
return owner_path
dirname = self.os_path.dirname(dirname) dirname = self.os_path.dirname(dirname)
owner_path = self.os_path.join(dirname, 'OWNERS') return dirname in covered_dirs
if self.os_path.exists(owner_path):
return owner_path def _LoadDataNeededFor(self, files):
raise Assertion('No OWNERS file found for %s' % f) for f in files:
dirpath = self.os_path.dirname(f)
def _ReadOwnersFile(self, owner_file, affected_file): while not dirpath in self.owners_for:
owners_for = self.owners_for.setdefault(affected_file, set()) self._ReadOwnersInDir(dirpath)
for owner in self.fopen(owner_file): if self._StopLooking(dirpath):
owner = owner.strip() break
self.files_owned_by.setdefault(owner, set()).add(affected_file) dirpath = self.os_path.dirname(dirpath)
owners_for.add(owner)
def _ReadOwnersInDir(self, dirpath):
owners_path = self.os_path.join(self.root, dirpath, 'OWNERS')
if not self.os_path.exists(owners_path):
return
lineno = 0
for line in self.fopen(owners_path):
lineno += 1
line = line.strip()
if line.startswith('#'):
continue
if line == 'set noparent':
self.stop_looking.add(dirpath)
continue
if self.email_regexp.match(line) or line == EVERYONE:
self.owned_by.setdefault(line, set()).add(dirpath)
self.owners_for.setdefault(dirpath, set()).add(line)
continue
raise SyntaxErrorInOwnersFile(owners_path, lineno, line)
def _CoveringSetOfOwnersFor(self, files): def _CoveringSetOfOwnersFor(self, files):
# TODO(dpranke): implement the greedy algorithm for covering sets, and # TODO(dpranke): implement the greedy algorithm for covering sets, and
...@@ -109,5 +153,10 @@ class Database(object): ...@@ -109,5 +153,10 @@ class Database(object):
# short combinations of owners. # short combinations of owners.
every_owner = set() every_owner = set()
for f in files: for f in files:
every_owner = every_owner.union(self.owners_for[f]) dirname = self.os_path.dirname(f)
while dirname in self.owners_for:
every_owner |= self.owners_for[dirname]
if self._StopLooking(dirname):
break
dirname = self.os_path.dirname(dirname)
return every_owner return every_owner
...@@ -32,10 +32,15 @@ class MockFileSystem(object): ...@@ -32,10 +32,15 @@ class MockFileSystem(object):
def _split(self, path): def _split(self, path):
return path.rsplit(self.sep, 1) return path.rsplit(self.sep, 1)
def abspath(self, path):
if path.endswith(self.sep):
return path[:-1]
return path
def dirname(self, path): def dirname(self, path):
if not self.sep in path: if self.sep not in path:
return '' return ''
return self._split(path)[0] return self._split(path)[0] or self.sep
def exists(self, path): def exists(self, path):
return self.isfile(path) or self.isdir(path) return self.isfile(path) or self.isdir(path)
...@@ -56,7 +61,7 @@ class MockFileSystem(object): ...@@ -56,7 +61,7 @@ class MockFileSystem(object):
return any(f.startswith(path) for f in files) return any(f.startswith(path) for f in files)
def join(self, *comps): def join(self, *comps):
# FIXME: might want tests for this and/or a better comment about how # TODO: Might want tests for this and/or a better comment about how
# it works. # it works.
return re.sub(re.escape(os.path.sep), self.sep, os.path.join(*comps)) return re.sub(re.escape(os.path.sep), self.sep, os.path.join(*comps))
......
...@@ -19,15 +19,17 @@ peter = 'peter@example.com' ...@@ -19,15 +19,17 @@ peter = 'peter@example.com'
def owners_file(*email_addresses, **kwargs): def owners_file(*email_addresses, **kwargs):
s = '' s = ''
if kwargs.get('comment'):
s += '# %s\n' % kwargs.get('comment')
if kwargs.get('noparent'): if kwargs.get('noparent'):
s = 'set noparent\n' s += 'set noparent\n'
return s + '\n'.join(email_addresses) + '\n' return s + '\n'.join(email_addresses) + '\n'
def test_repo(): def test_repo():
return filesystem_mock.MockFileSystem(files={ return filesystem_mock.MockFileSystem(files={
'/DEPS' : '', '/DEPS' : '',
'/OWNERS': owners_file('*'), '/OWNERS': owners_file(owners.EVERYONE),
'/base/vlog.h': '', '/base/vlog.h': '',
'/chrome/OWNERS': owners_file(ben, brett), '/chrome/OWNERS': owners_file(ben, brett),
'/chrome/gpu/OWNERS': owners_file(ken), '/chrome/gpu/OWNERS': owners_file(ken),
...@@ -35,7 +37,7 @@ def test_repo(): ...@@ -35,7 +37,7 @@ def test_repo():
'/chrome/renderer/OWNERS': owners_file(peter), '/chrome/renderer/OWNERS': owners_file(peter),
'/chrome/renderer/gpu/gpu_channel_host.h': '', '/chrome/renderer/gpu/gpu_channel_host.h': '',
'/chrome/renderer/safe_browsing/scorer.h': '', '/chrome/renderer/safe_browsing/scorer.h': '',
'/content/OWNERS': owners_file(john, darin, noparent=True), '/content/OWNERS': owners_file(john, darin, comment='foo', noparent=True),
'/content/content.gyp': '', '/content/content.gyp': '',
}) })
...@@ -53,70 +55,76 @@ class OwnersDatabaseTest(unittest.TestCase): ...@@ -53,70 +55,76 @@ class OwnersDatabaseTest(unittest.TestCase):
os_path = os_path or self.repo os_path = os_path or self.repo
return owners.Database(root, fopen, os_path) return owners.Database(root, fopen, os_path)
def assertReviewersFor(self, files, expected_reviewers): def test_Constructor(self):
db = self.db() self.assertNotEquals(self.db(), None)
self.assertEquals(db.ReviewersFor(set(files)), set(expected_reviewers))
def assertCoveredBy(self, files, reviewers): def assert_CoveredBy(self, files, reviewers):
db = self.db() db = self.db()
self.assertTrue(db.FilesAreCoveredBy(set(files), set(reviewers))) self.assertTrue(db.FilesAreCoveredBy(set(files), set(reviewers)))
def assertNotCoveredBy(self, files, reviewers, unreviewed_files): def test_CoveredBy_Everyone(self):
self.assert_CoveredBy(['DEPS'], [john])
self.assert_CoveredBy(['DEPS'], [darin])
def test_CoveredBy_Explicit(self):
self.assert_CoveredBy(['content/content.gyp'], [john])
self.assert_CoveredBy(['chrome/gpu/OWNERS'], [ken])
def test_CoveredBy_OwnersPropagatesDown(self):
self.assert_CoveredBy(['chrome/gpu/OWNERS'], [ben])
self.assert_CoveredBy(['/chrome/renderer/gpu/gpu_channel_host.h'], [peter])
def assert_NotCoveredBy(self, files, reviewers, unreviewed_files):
db = self.db() db = self.db()
self.assertEquals(db.FilesNotCoveredBy(set(files), set(reviewers)), self.assertEquals(db.FilesNotCoveredBy(set(files), set(reviewers)),
set(unreviewed_files)) set(unreviewed_files))
def test_constructor(self): def test_NotCoveredBy_NeedAtLeastOneReviewer(self):
self.assertNotEquals(self.db(), None) self.assert_NotCoveredBy(['DEPS'], [], ['DEPS'])
def test_owners_for(self): def test_NotCoveredBy_OwnersPropagatesDown(self):
self.assertReviewersFor(['DEPS'], [owners.ANYONE]) self.assert_NotCoveredBy(
self.assertReviewersFor(['content/content.gyp'], [john, darin])
self.assertReviewersFor(['chrome/gpu/gpu_channel.h'], [ken])
def test_covered_by(self):
self.assertCoveredBy(['DEPS'], [john])
self.assertCoveredBy(['DEPS'], [darin])
self.assertCoveredBy(['content/content.gyp'], [john])
self.assertCoveredBy(['chrome/gpu/OWNERS'], [ken])
self.assertCoveredBy(['chrome/gpu/OWNERS'], [ben])
def test_not_covered_by(self):
self.assertNotCoveredBy(['DEPS'], [], ['DEPS'])
self.assertNotCoveredBy(['content/content.gyp'], [ben],
['content/content.gyp'])
self.assertNotCoveredBy(
['chrome/gpu/gpu_channel.h', 'chrome/renderer/gpu/gpu_channel_host.h'],
[peter], ['chrome/gpu/gpu_channel.h'])
self.assertNotCoveredBy(
['chrome/gpu/gpu_channel.h', 'chrome/renderer/gpu/gpu_channel_host.h'], ['chrome/gpu/gpu_channel.h', 'chrome/renderer/gpu/gpu_channel_host.h'],
[ben], []) [ben], [])
def test_comments_in_owners_file(self): def test_NotCoveredBy_PartialCovering(self):
# pylint: disable=W0212 self.assert_NotCoveredBy(
['content/content.gyp', 'chrome/renderer/gpu/gpu_channel_host.h'],
[peter], ['content/content.gyp'])
def test_NotCoveredBy_SetNoParentWorks(self):
self.assert_NotCoveredBy(['content/content.gyp'], [ben],
['content/content.gyp'])
def assert_ReviewersFor(self, files, expected_reviewers):
db = self.db() db = self.db()
# Tests that this doesn't raise an error. self.assertEquals(db.ReviewersFor(set(files)), set(expected_reviewers))
db._ReadOwnersFile('OWNERS', 'DEPS')
def test_ReviewersFor_BasicFunctionality(self):
self.assert_ReviewersFor(['chrome/gpu/gpu_channel.h'],
[ken, ben, brett, owners.EVERYONE])
def test_ReviewersFor_SetNoParentWorks(self):
self.assert_ReviewersFor(['content/content.gyp'], [john, darin])
def test_syntax_error_in_owners_file(self): def test_ReviewersFor_WildcardDir(self):
# pylint: disable=W0212 self.assert_ReviewersFor(['DEPS'], [owners.EVERYONE])
def assert_SyntaxError(self, owners_file_contents):
db = self.db() db = self.db()
self.files['/foo/OWNERS'] = '{}\n' self.files['/foo/OWNERS'] = owners_file_contents
self.files['/foo/DEPS'] = '# DEPS\n' self.files['/foo/DEPS'] = ''
self.assertRaises(owners.SyntaxErrorInOwnersFile, db._ReadOwnersFile, self.assertRaises(owners.SyntaxErrorInOwnersFile, db.ReviewersFor,
'/foo/OWNERS', '/foo/DEPS') ['/foo/DEPS'])
self.files['/bar/OWNERS'] = 'set myparentislinus\n' def test_SyntaxError_UnknownToken(self):
self.files['/bar/DEPS'] = '# DEPS\n' self.assert_SyntaxError('{}\n')
self.assertRaises(owners.SyntaxErrorInOwnersFile, db._ReadOwnersFile,
'/bar/OWNERS', '/bar/DEPS') def test_SyntaxError_UnknownSet(self):
self.assert_SyntaxError('set myfatherisbillgates\n')
def test_owners_propagates_down(self):
self.assertCoveredBy(['/chrome/renderer/gpu/gpu_channel_host.h'], [peter]) def test_SyntaxError_BadEmail(self):
self.assert_SyntaxError('ben\n')
def test_set_noparent(self):
self.assertNotCoveredBy(['/content/content.gyp'], [peter],
['/content/content.gyp'])
if __name__ == '__main__': if __name__ == '__main__':
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment