An interactive tool to help find owners covering current change list.


BUG=77248

Review URL: https://chromiumcodereview.appspot.com/12712002

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@224264 0039d316-1c4b-4281-b951-d872f2087c98
parent 8b3cad72
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"""A git-command for integrating reviews on Rietveld.""" """A git-command for integrating reviews on Rietveld."""
from distutils.version import LooseVersion from distutils.version import LooseVersion
import glob
import json import json
import logging import logging
import optparse import optparse
...@@ -38,6 +39,7 @@ import scm ...@@ -38,6 +39,7 @@ import scm
import subcommand import subcommand
import subprocess2 import subprocess2
import watchlists import watchlists
import owners_finder
__version__ = '1.0' __version__ = '1.0'
...@@ -2126,6 +2128,35 @@ def CMDset_close(parser, args): ...@@ -2126,6 +2128,35 @@ def CMDset_close(parser, args):
return 0 return 0
def CMDowners(parser, args):
"""interactively find the owners for reviewing"""
parser.add_option(
'--no-color',
action='store_true',
help='Use this option to disable color output')
options, args = parser.parse_args(args)
author = RunGit(['config', 'user.email']).strip() or None
cl = Changelist()
if args:
if len(args) > 1:
parser.error('Unknown args')
base_branch = args[0]
else:
# Default to diffing against the common ancestor of the upstream branch.
base_branch = RunGit(['merge-base', cl.GetUpstreamBranch(), 'HEAD']).strip()
change = cl.GetChange(base_branch, None)
return owners_finder.OwnersFinder(
[f.LocalPath() for f in
cl.GetChange(base_branch, None).AffectedFiles()],
change.RepositoryRoot(), author,
fopen=file, os_path=os.path, glob=glob.glob,
disable_color=options.no_color).run()
def CMDformat(parser, args): def CMDformat(parser, args):
"""Runs clang-format on the diff.""" """Runs clang-format on the diff."""
CLANG_EXTS = ['.cc', '.cpp', '.h'] CLANG_EXTS = ['.cc', '.cpp', '.h']
......
...@@ -78,7 +78,7 @@ class SyntaxErrorInOwnersFile(Exception): ...@@ -78,7 +78,7 @@ class SyntaxErrorInOwnersFile(Exception):
self.msg = msg self.msg = msg
def __str__(self): def __str__(self):
return "%s:%d syntax error: %s" % (self.path, self.lineno, self.msg) return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
class Database(object): class Database(object):
...@@ -111,6 +111,9 @@ class Database(object): ...@@ -111,6 +111,9 @@ class Database(object):
# Mapping of paths to authorized owners. # Mapping of paths to authorized owners.
self.owners_for = {} self.owners_for = {}
# Mapping reviewers to the preceding comment per file in the OWNERS files.
self.comments = {}
# Set of paths that stop us from looking above them for owners. # Set of paths that stop us from looking above them for owners.
# (This is implicitly true for the root directory). # (This is implicitly true for the root directory).
self.stop_looking = set(['']) self.stop_looking = set([''])
...@@ -122,7 +125,7 @@ class Database(object): ...@@ -122,7 +125,7 @@ class Database(object):
If author is nonempty, we ensure it is not included in the set returned If author is nonempty, we ensure it is not included in the set returned
in order avoid suggesting the author as a reviewer for their own changes.""" in order avoid suggesting the author as a reviewer for their own changes."""
self._check_paths(files) self._check_paths(files)
self._load_data_needed_for(files) self.load_data_needed_for(files)
suggested_owners = self._covering_set_of_owners_for(files, author) suggested_owners = self._covering_set_of_owners_for(files, author)
if EVERYONE in suggested_owners: if EVERYONE in suggested_owners:
if len(suggested_owners) > 1: if len(suggested_owners) > 1:
...@@ -140,7 +143,7 @@ class Database(object): ...@@ -140,7 +143,7 @@ class Database(object):
""" """
self._check_paths(files) self._check_paths(files)
self._check_reviewers(reviewers) self._check_reviewers(reviewers)
self._load_data_needed_for(files) self.load_data_needed_for(files)
covered_objs = self._objs_covered_by(reviewers) covered_objs = self._objs_covered_by(reviewers)
uncovered_files = [f for f in files uncovered_files = [f for f in files
...@@ -182,7 +185,7 @@ class Database(object): ...@@ -182,7 +185,7 @@ class Database(object):
dirpath = self.os_path.dirname(dirpath) dirpath = self.os_path.dirname(dirpath)
return dirpath return dirpath
def _load_data_needed_for(self, files): def load_data_needed_for(self, files):
for f in files: for f in files:
dirpath = self.os_path.dirname(f) dirpath = self.os_path.dirname(f)
while not dirpath in self.owners_for: while not dirpath in self.owners_for:
...@@ -195,18 +198,27 @@ class Database(object): ...@@ -195,18 +198,27 @@ class Database(object):
owners_path = self.os_path.join(self.root, dirpath, 'OWNERS') owners_path = self.os_path.join(self.root, dirpath, 'OWNERS')
if not self.os_path.exists(owners_path): if not self.os_path.exists(owners_path):
return return
comment = []
in_comment = False
lineno = 0 lineno = 0
for line in self.fopen(owners_path): for line in self.fopen(owners_path):
lineno += 1 lineno += 1
line = line.strip() line = line.strip()
if line.startswith('#') or line == '': if line.startswith('#'):
if not in_comment:
comment = []
comment.append(line[1:].strip())
in_comment = True
continue continue
if line == '':
continue
in_comment = False
if line == 'set noparent': if line == 'set noparent':
self.stop_looking.add(dirpath) self.stop_looking.add(dirpath)
continue continue
m = re.match("per-file (.+)=(.+)", line) m = re.match('per-file (.+)=(.+)', line)
if m: if m:
glob_string = m.group(1).strip() glob_string = m.group(1).strip()
directive = m.group(2).strip() directive = m.group(2).strip()
...@@ -217,20 +229,24 @@ class Database(object): ...@@ -217,20 +229,24 @@ class Database(object):
line) line)
baselines = self.glob(full_glob_string) baselines = self.glob(full_glob_string)
for baseline in (self.os_path.relpath(b, self.root) for b in baselines): for baseline in (self.os_path.relpath(b, self.root) for b in baselines):
self._add_entry(baseline, directive, "per-file line", self._add_entry(baseline, directive, 'per-file line',
owners_path, lineno) owners_path, lineno, '\n'.join(comment))
continue continue
if line.startswith('set '): if line.startswith('set '):
raise SyntaxErrorInOwnersFile(owners_path, lineno, raise SyntaxErrorInOwnersFile(owners_path, lineno,
'unknown option: "%s"' % line[4:].strip()) 'unknown option: "%s"' % line[4:].strip())
self._add_entry(dirpath, line, "line", owners_path, lineno) self._add_entry(dirpath, line, 'line', owners_path, lineno,
' '.join(comment))
def _add_entry(self, path, directive, line_type, owners_path, lineno): def _add_entry(self, path, directive,
if directive == "set noparent": line_type, owners_path, lineno, comment):
if directive == 'set noparent':
self.stop_looking.add(path) self.stop_looking.add(path)
elif self.email_regexp.match(directive) or directive == EVERYONE: elif self.email_regexp.match(directive) or directive == EVERYONE:
self.comments.setdefault(directive, {})
self.comments[directive][path] = comment
self.owned_by.setdefault(directive, set()).add(path) self.owned_by.setdefault(directive, set()).add(path)
self.owners_for.setdefault(path, set()).add(directive) self.owners_for.setdefault(path, set()).add(directive)
else: else:
...@@ -240,7 +256,7 @@ class Database(object): ...@@ -240,7 +256,7 @@ class Database(object):
def _covering_set_of_owners_for(self, files, author): def _covering_set_of_owners_for(self, files, author):
dirs_remaining = set(self._enclosing_dir_with_owners(f) for f in files) dirs_remaining = set(self._enclosing_dir_with_owners(f) for f in files)
all_possible_owners = self._all_possible_owners(dirs_remaining, author) all_possible_owners = self.all_possible_owners(dirs_remaining, author)
suggested_owners = set() suggested_owners = set()
while dirs_remaining: while dirs_remaining:
owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining) owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
...@@ -249,7 +265,7 @@ class Database(object): ...@@ -249,7 +265,7 @@ class Database(object):
dirs_remaining -= dirs_to_remove dirs_remaining -= dirs_to_remove
return suggested_owners return suggested_owners
def _all_possible_owners(self, dirs, author): def all_possible_owners(self, dirs, author):
"""Returns a list of (potential owner, distance-from-dir) tuples; a """Returns a list of (potential owner, distance-from-dir) tuples; a
distance of 1 is the lowest/closest possible distance (which makes the distance of 1 is the lowest/closest possible distance (which makes the
subsequent math easier).""" subsequent math easier)."""
...@@ -273,13 +289,13 @@ class Database(object): ...@@ -273,13 +289,13 @@ class Database(object):
return all_possible_owners return all_possible_owners
@staticmethod @staticmethod
def lowest_cost_owner(all_possible_owners, dirs): def total_costs_by_owner(all_possible_owners, dirs):
# We want to minimize both the number of reviewers and the distance # We want to minimize both the number of reviewers and the distance
# from the files/dirs needing reviews. The "pow(X, 1.75)" below is # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
# an arbitrarily-selected scaling factor that seems to work well - it # an arbitrarily-selected scaling factor that seems to work well - it
# will select one reviewer in the parent directory over three reviewers # will select one reviewer in the parent directory over three reviewers
# in subdirs, but not one reviewer over just two. # in subdirs, but not one reviewer over just two.
total_costs_by_owner = {} result = {}
for owner in all_possible_owners: for owner in all_possible_owners:
total_distance = 0 total_distance = 0
num_directories_owned = 0 num_directories_owned = 0
...@@ -287,13 +303,18 @@ class Database(object): ...@@ -287,13 +303,18 @@ class Database(object):
if dirname in dirs: if dirname in dirs:
total_distance += distance total_distance += distance
num_directories_owned += 1 num_directories_owned += 1
if num_directories_owned: if num_directories_owned:
total_costs_by_owner[owner] = (total_distance / result[owner] = (total_distance /
pow(num_directories_owned, 1.75)) pow(num_directories_owned, 1.75))
return result
@staticmethod
def lowest_cost_owner(all_possible_owners, dirs):
total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
dirs)
# Return the lowest cost owner. In the case of a tie, pick one randomly. # Return the lowest cost owner. In the case of a tie, pick one randomly.
lowest_cost = min(total_costs_by_owner.itervalues()) lowest_cost = min(total_costs_by_owner.itervalues())
lowest_cost_owners = filter( lowest_cost_owners = filter(
lambda owner: total_costs_by_owner[owner] == lowest_cost, lambda owner: total_costs_by_owner[owner] == lowest_cost,
total_costs_by_owner) total_costs_by_owner)
return random.Random().choice(lowest_cost_owners) return random.Random().choice(lowest_cost_owners)
This diff is collapsed.
#!/usr/bin/env python
# Copyright 2013 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for owners_finder.py."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from testing_support import filesystem_mock
import owners_finder
import owners
ben = 'ben@example.com'
brett = 'brett@example.com'
darin = 'darin@example.com'
john = 'john@example.com'
ken = 'ken@example.com'
peter = 'peter@example.com'
tom = 'tom@example.com'
def owners_file(*email_addresses, **kwargs):
s = ''
if kwargs.get('comment'):
s += '# %s\n' % kwargs.get('comment')
if kwargs.get('noparent'):
s += 'set noparent\n'
s += '\n'.join(kwargs.get('lines', [])) + '\n'
return s + '\n'.join(email_addresses) + '\n'
def test_repo():
return filesystem_mock.MockFileSystem(files={
'/DEPS': '',
'/OWNERS': owners_file(ken, peter, tom),
'/base/vlog.h': '',
'/chrome/OWNERS': owners_file(ben, brett),
'/chrome/browser/OWNERS': owners_file(brett),
'/chrome/browser/defaults.h': '',
'/chrome/gpu/OWNERS': owners_file(ken),
'/chrome/gpu/gpu_channel.h': '',
'/chrome/renderer/OWNERS': owners_file(peter),
'/chrome/renderer/gpu/gpu_channel_host.h': '',
'/chrome/renderer/safe_browsing/scorer.h': '',
'/content/OWNERS': owners_file(john, darin, comment='foo', noparent=True),
'/content/content.gyp': '',
'/content/bar/foo.cc': '',
'/content/baz/OWNERS': owners_file(brett),
'/content/baz/froboz.h': '',
'/content/baz/ugly.cc': '',
'/content/baz/ugly.h': '',
'/content/views/OWNERS': owners_file(ben, john, owners.EVERYONE,
noparent=True),
'/content/views/pie.h': '',
})
class OutputInterceptedOwnersFinder(owners_finder.OwnersFinder):
def __init__(self, files, local_root,
fopen, os_path, glob,
disable_color=False):
super(OutputInterceptedOwnersFinder, self).__init__(
files, local_root, None,
fopen, os_path, glob, disable_color=disable_color)
self.output = []
self.indentation_stack = []
def resetText(self):
self.output = []
self.indentation_stack = []
def indent(self):
self.indentation_stack.append(self.output)
self.output = []
def unindent(self):
block = self.output
self.output = self.indentation_stack.pop()
self.output.append(block)
def writeln(self, text=''):
self.output.append(text)
class _BaseTestCase(unittest.TestCase):
default_files = [
'base/vlog.h',
'chrome/browser/defaults.h',
'chrome/gpu/gpu_channel.h',
'chrome/renderer/gpu/gpu_channel_host.h',
'chrome/renderer/safe_browsing/scorer.h',
'content/content.gyp',
'content/bar/foo.cc',
'content/baz/ugly.cc',
'content/baz/ugly.h',
'content/views/pie.h'
]
def setUp(self):
self.repo = test_repo()
self.root = '/'
self.fopen = self.repo.open_for_reading
self.glob = self.repo.glob
def ownersFinder(self, files):
finder = OutputInterceptedOwnersFinder(files, self.root,
fopen=self.fopen,
os_path=self.repo,
glob=self.glob,
disable_color=True)
return finder
def defaultFinder(self):
return self.ownersFinder(self.default_files)
class OwnersFinderTests(_BaseTestCase):
def test_constructor(self):
self.assertNotEquals(self.defaultFinder(), None)
def test_reset(self):
finder = self.defaultFinder()
i = 0
while i < 2:
i += 1
self.assertEqual(finder.owners_queue,
[brett, john, darin, peter, ken, ben, tom])
self.assertEqual(finder.unreviewed_files, {
'base/vlog.h',
'chrome/browser/defaults.h',
'chrome/gpu/gpu_channel.h',
'chrome/renderer/gpu/gpu_channel_host.h',
'chrome/renderer/safe_browsing/scorer.h',
'content/content.gyp',
'content/bar/foo.cc',
'content/baz/ugly.cc',
'content/baz/ugly.h'
})
self.assertEqual(finder.selected_owners, set())
self.assertEqual(finder.deselected_owners, set())
self.assertEqual(finder.reviewed_by, {})
self.assertEqual(finder.output, [])
finder.select_owner(john)
finder.reset()
finder.resetText()
def test_select(self):
finder = self.defaultFinder()
finder.select_owner(john)
self.assertEqual(finder.owners_queue, [brett, peter, ken, ben, tom])
self.assertEqual(finder.selected_owners, {john})
self.assertEqual(finder.deselected_owners, {darin})
self.assertEqual(finder.reviewed_by, {'content/bar/foo.cc': john,
'content/baz/ugly.cc': john,
'content/baz/ugly.h': john,
'content/content.gyp': john})
self.assertEqual(finder.output,
['Selected: ' + john, 'Deselected: ' + darin])
finder = self.defaultFinder()
finder.select_owner(darin)
self.assertEqual(finder.owners_queue, [brett, peter, ken, ben, tom])
self.assertEqual(finder.selected_owners, {darin})
self.assertEqual(finder.deselected_owners, {john})
self.assertEqual(finder.reviewed_by, {'content/bar/foo.cc': darin,
'content/baz/ugly.cc': darin,
'content/baz/ugly.h': darin,
'content/content.gyp': darin})
self.assertEqual(finder.output,
['Selected: ' + darin, 'Deselected: ' + john])
finder = self.defaultFinder()
finder.select_owner(brett)
self.assertEqual(finder.owners_queue, [john, darin, peter, ken, tom])
self.assertEqual(finder.selected_owners, {brett})
self.assertEqual(finder.deselected_owners, {ben})
self.assertEqual(finder.reviewed_by,
{'chrome/browser/defaults.h': brett,
'chrome/gpu/gpu_channel.h': brett,
'chrome/renderer/gpu/gpu_channel_host.h': brett,
'chrome/renderer/safe_browsing/scorer.h': brett,
'content/baz/ugly.cc': brett,
'content/baz/ugly.h': brett})
self.assertEqual(finder.output,
['Selected: ' + brett, 'Deselected: ' + ben])
def test_deselect(self):
finder = self.defaultFinder()
finder.deselect_owner(john)
self.assertEqual(finder.owners_queue, [brett, peter, ken, ben, tom])
self.assertEqual(finder.selected_owners, {darin})
self.assertEqual(finder.deselected_owners, {john})
self.assertEqual(finder.reviewed_by, {'content/bar/foo.cc': darin,
'content/baz/ugly.cc': darin,
'content/baz/ugly.h': darin,
'content/content.gyp': darin})
self.assertEqual(finder.output,
['Deselected: ' + john, 'Selected: ' + darin])
def test_print_file_info(self):
finder = self.defaultFinder()
finder.print_file_info('chrome/browser/defaults.h')
self.assertEqual(finder.output, ['chrome/browser/defaults.h [5]'])
finder.resetText()
finder.print_file_info('chrome/renderer/gpu/gpu_channel_host.h')
self.assertEqual(finder.output,
['chrome/renderer/gpu/gpu_channel_host.h [5]'])
def test_print_file_info_detailed(self):
finder = self.defaultFinder()
finder.print_file_info_detailed('chrome/browser/defaults.h')
self.assertEqual(finder.output,
['chrome/browser/defaults.h',
[ben, brett, ken, peter, tom]])
finder.resetText()
finder.print_file_info_detailed('chrome/renderer/gpu/gpu_channel_host.h')
self.assertEqual(finder.output,
['chrome/renderer/gpu/gpu_channel_host.h',
[ben, brett, ken, peter, tom]])
def test_print_comments(self):
finder = self.defaultFinder()
finder.print_comments(darin)
self.assertEqual(finder.output,
[darin + ' is commented as:', ['foo (at content)']])
if __name__ == '__main__':
unittest.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