# 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. """Interactive tool for finding reviewers/owners for a change.""" import os import copy import owners as owners_module def first(iterable): for element in iterable: return element class OwnersFinder(object): COLOR_LINK = '\033[4m' COLOR_BOLD = '\033[1;32m' COLOR_GREY = '\033[0;37m' COLOR_RESET = '\033[0m' indentation = 0 def __init__(self, files, local_root, author, fopen, os_path, glob, email_postfix='@chromium.org', disable_color=False): self.email_postfix = email_postfix if os.name == 'nt' or disable_color: self.COLOR_LINK = '' self.COLOR_BOLD = '' self.COLOR_GREY = '' self.COLOR_RESET = '' self.db = owners_module.Database(local_root, fopen, os_path, glob) self.db.load_data_needed_for(files) self.os_path = os_path self.author = author filtered_files = files # Eliminate files that author himself can review. if author: if author in self.db.owned_by: for dir_name in self.db.owned_by[author]: filtered_files = [ file_name for file_name in filtered_files if not file_name.startswith(dir_name)] filtered_files = list(filtered_files) # Eliminate files that everyone can review. if owners_module.EVERYONE in self.db.owned_by: for dir_name in self.db.owned_by[owners_module.EVERYONE]: filtered_files = filter( lambda file_name: not file_name.startswith(dir_name), filtered_files) # If some files are eliminated. if len(filtered_files) != len(files): files = filtered_files # Reload the database. self.db = owners_module.Database(local_root, fopen, os_path, glob) self.db.load_data_needed_for(files) self.all_possible_owners = self.db.all_possible_owners(files, None) self.owners_to_files = {} self._map_owners_to_files(files) self.files_to_owners = {} self._map_files_to_owners() self.owners_score = self.db.total_costs_by_owner( self.all_possible_owners, files) self.original_files_to_owners = copy.deepcopy(self.files_to_owners) self.comments = self.db.comments # This is the queue that will be shown in the interactive questions. # It is initially sorted by the score in descending order. In the # interactive questions a user can choose to "defer" its decision, then the # owner will be put to the end of the queue and shown later. self.owners_queue = [] self.unreviewed_files = set() self.reviewed_by = {} self.selected_owners = set() self.deselected_owners = set() self.reset() def run(self): self.reset() while self.owners_queue and self.unreviewed_files: owner = self.owners_queue[0] if (owner in self.selected_owners) or (owner in self.deselected_owners): continue if not any((file_name in self.unreviewed_files) for file_name in self.owners_to_files[owner]): self.deselect_owner(owner) continue self.print_info(owner) while True: inp = self.input_command(owner) if inp == 'y' or inp == 'yes': self.select_owner(owner) break elif inp == 'n' or inp == 'no': self.deselect_owner(owner) break elif inp == '' or inp == 'd' or inp == 'defer': self.owners_queue.append(self.owners_queue.pop(0)) break elif inp == 'f' or inp == 'files': self.list_files() break elif inp == 'o' or inp == 'owners': self.list_owners(self.owners_queue) break elif inp == 'p' or inp == 'pick': self.pick_owner(raw_input('Pick an owner: ')) break elif inp.startswith('p ') or inp.startswith('pick '): self.pick_owner(inp.split(' ', 2)[1].strip()) break elif inp == 'r' or inp == 'restart': self.reset() break elif inp == 'q' or inp == 'quit': # Exit with error return 1 self.print_result() return 0 def _map_owners_to_files(self, files): for owner in self.all_possible_owners: for dir_name, _ in self.all_possible_owners[owner]: for file_name in files: if file_name.startswith(dir_name): self.owners_to_files.setdefault(owner, set()) self.owners_to_files[owner].add(file_name) def _map_files_to_owners(self): for owner in self.owners_to_files: for file_name in self.owners_to_files[owner]: self.files_to_owners.setdefault(file_name, set()) self.files_to_owners[file_name].add(owner) def reset(self): self.files_to_owners = copy.deepcopy(self.original_files_to_owners) self.unreviewed_files = set(self.files_to_owners.keys()) self.reviewed_by = {} self.selected_owners = set() self.deselected_owners = set() # Initialize owners queue, sort it by the score self.owners_queue = list(sorted(self.owners_to_files.keys(), key=lambda owner: self.owners_score[owner])) self.find_mandatory_owners() def select_owner(self, owner, findMandatoryOwners=True): if owner in self.selected_owners or owner in self.deselected_owners\ or not (owner in self.owners_queue): return self.writeln('Selected: ' + owner) self.owners_queue.remove(owner) self.selected_owners.add(owner) for file_name in filter( lambda file_name: file_name in self.unreviewed_files, self.owners_to_files[owner]): self.unreviewed_files.remove(file_name) self.reviewed_by[file_name] = owner if findMandatoryOwners: self.find_mandatory_owners() def deselect_owner(self, owner, findMandatoryOwners=True): if owner in self.selected_owners or owner in self.deselected_owners\ or not (owner in self.owners_queue): return self.writeln('Deselected: ' + owner) self.owners_queue.remove(owner) self.deselected_owners.add(owner) for file_name in self.owners_to_files[owner] & self.unreviewed_files: self.files_to_owners[file_name].remove(owner) if findMandatoryOwners: self.find_mandatory_owners() def find_mandatory_owners(self): continues = True for owner in self.owners_queue: if owner in self.selected_owners: continue if owner in self.deselected_owners: continue if len(self.owners_to_files[owner] & self.unreviewed_files) == 0: self.deselect_owner(owner, False) while continues: continues = False for file_name in filter( lambda file_name: len(self.files_to_owners[file_name]) == 1, self.unreviewed_files): owner = first(self.files_to_owners[file_name]) self.select_owner(owner, False) continues = True break def print_comments(self, owner): if owner not in self.comments: self.writeln(self.bold_name(owner)) else: self.writeln(self.bold_name(owner) + ' is commented as:') self.indent() for path in self.comments[owner]: if len(self.comments[owner][path]) > 0: self.writeln(self.greyed(self.comments[owner][path]) + ' (at ' + self.bold(path or '') + ')') else: self.writeln(self.greyed('[No comment] ') + ' (at ' + self.bold(path or '') + ')') self.unindent() def print_file_info(self, file_name, except_owner=''): if file_name not in self.unreviewed_files: self.writeln(self.greyed(file_name + ' (by ' + self.bold_name(self.reviewed_by[file_name]) + ')')) else: if len(self.files_to_owners[file_name]) <= 3: other_owners = [] for ow in self.files_to_owners[file_name]: if ow != except_owner: other_owners.append(self.bold_name(ow)) self.writeln(file_name + ' [' + (', '.join(other_owners)) + ']') else: self.writeln(file_name + ' [' + self.bold(str(len(self.files_to_owners[file_name]))) + ']') def print_file_info_detailed(self, file_name): self.writeln(file_name) self.indent() for ow in sorted(self.files_to_owners[file_name]): if ow in self.deselected_owners: self.writeln(self.bold_name(self.greyed(ow))) elif ow in self.selected_owners: self.writeln(self.bold_name(self.greyed(ow))) else: self.writeln(self.bold_name(ow)) self.unindent() def print_owned_files_for(self, owner): # Print owned files self.print_comments(owner) self.writeln(self.bold_name(owner) + ' owns ' + str(len(self.owners_to_files[owner])) + ' file(s):') self.indent() for file_name in sorted(self.owners_to_files[owner]): self.print_file_info(file_name, owner) self.unindent() self.writeln() def list_owners(self, owners_queue): if (len(self.owners_to_files) - len(self.deselected_owners) - len(self.selected_owners)) > 3: for ow in owners_queue: if ow not in self.deselected_owners and ow not in self.selected_owners: self.print_comments(ow) else: for ow in owners_queue: if ow not in self.deselected_owners and ow not in self.selected_owners: self.writeln() self.print_owned_files_for(ow) def list_files(self): self.indent() if len(self.unreviewed_files) > 5: for file_name in sorted(self.unreviewed_files): self.print_file_info(file_name) else: for file_name in self.unreviewed_files: self.print_file_info_detailed(file_name) self.unindent() def pick_owner(self, ow): # Allowing to omit domain suffixes if ow not in self.owners_to_files: if ow + self.email_postfix in self.owners_to_files: ow += self.email_postfix if ow not in self.owners_to_files: self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually. ' + 'It\'s an invalid name or not related to the change list.') return False elif ow in self.selected_owners: self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually. ' + 'It\'s already selected.') return False elif ow in self.deselected_owners: self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually.' + 'It\'s already unselected.') return False self.select_owner(ow) return True def print_result(self): # Print results self.writeln() self.writeln() self.writeln('** You selected these owners **') self.writeln() for owner in self.selected_owners: self.writeln(self.bold_name(owner) + ':') self.indent() for file_name in sorted(self.owners_to_files[owner]): self.writeln(file_name) self.unindent() def bold(self, text): return self.COLOR_BOLD + text + self.COLOR_RESET def bold_name(self, name): return (self.COLOR_BOLD + name.replace(self.email_postfix, '') + self.COLOR_RESET) def greyed(self, text): return self.COLOR_GREY + text + self.COLOR_RESET def indent(self): self.indentation += 1 def unindent(self): self.indentation -= 1 def print_indent(self): return ' ' * self.indentation def writeln(self, text=''): print self.print_indent() + text def hr(self): self.writeln('=====================') def print_info(self, owner): self.hr() self.writeln( self.bold(str(len(self.unreviewed_files))) + ' file(s) left.') self.print_owned_files_for(owner) def input_command(self, owner): self.writeln('Add ' + self.bold_name(owner) + ' as your reviewer? ') return raw_input( '[yes/no/Defer/pick/files/owners/quit/restart]: ').lower()