# Copyright (c) 2011 Google Inc. All rights reserved. # Copyright (c) 2011 Code Aurora Forum. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from optparse import make_option import re from webkitpy.common.checkout.changelog import ChangeLogEntry from webkitpy.common.config.committers import CommitterList from webkitpy.tool import steps from webkitpy.tool.grammar import join_with_separators from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand class SuggestNominations(AbstractDeclarativeCommand): name = "suggest-nominations" help_text = "Suggest contributors for committer/reviewer nominations" def __init__(self): options = [ make_option("--committer-minimum", action="store", dest="committer_minimum", type="int", default=10, help="Specify minimum patch count for Committer nominations."), make_option("--reviewer-minimum", action="store", dest="reviewer_minimum", type="int", default=80, help="Specify minimum patch count for Reviewer nominations."), make_option("--max-commit-age", action="store", dest="max_commit_age", type="int", default=9, help="Specify max commit age to consider for nominations (in months)."), make_option("--show-commits", action="store_true", dest="show_commits", default=False, help="Show commit history with nomination suggestions."), ] AbstractDeclarativeCommand.__init__(self, options=options) # FIXME: This should probably be on the tool somewhere. self._committer_list = CommitterList() _counters_by_name = {} _counters_by_email = {} def _init_options(self, options): self.committer_minimum = options.committer_minimum self.reviewer_minimum = options.reviewer_minimum self.max_commit_age = options.max_commit_age self.show_commits = options.show_commits self.verbose = options.verbose # FIXME: This should move to scm.py def _recent_commit_messages(self): git_log = self._tool.executive.run_command(['git', 'log', '--since="%s months ago"' % self.max_commit_age]) match_git_svn_id = re.compile(r"\n\n git-svn-id:.*\n", re.MULTILINE) match_get_log_lines = re.compile(r"^\S.*\n", re.MULTILINE) match_leading_indent = re.compile(r"^[ ]{4}", re.MULTILINE) messages = re.split(r"commit \w{40}", git_log)[1:] # Ignore the first message which will be empty. for message in messages: # Remove any lines from git and unindent all the lines (message, _) = match_git_svn_id.subn("", message) (message, _) = match_get_log_lines.subn("", message) (message, _) = match_leading_indent.subn("", message) yield message.lstrip() # Remove any leading newlines from the log message. # e.g. Patch by Eric Seidel on 2011-09-15 patch_by_regexp = r'^Patch by (?P.+?)\s+<(?P[^<>]+)> on (?P\d{4}-\d{2}-\d{2})$' def _count_recent_patches(self): # This entire block could be written as a map/reduce over the messages. for message in self._recent_commit_messages(): # FIXME: This should use ChangeLogEntry to do the entire parse instead # of grabbing at its regexps. dateline_match = re.match(ChangeLogEntry.date_line_regexp, message, re.MULTILINE) if not dateline_match: # Modern commit messages don't just dump the ChangeLog entry, but rather # have a special Patch by line for non-committers. dateline_match = re.search(self.patch_by_regexp, message, re.MULTILINE) if not dateline_match: continue author_email = dateline_match.group("email") if not author_email: continue # We only care about reviewed patches, so make sure it has a valid reviewer line. reviewer_match = re.search(ChangeLogEntry.reviewed_by_regexp, message, re.MULTILINE) # We might also want to validate the reviewer name against the committer list. if not reviewer_match or not reviewer_match.group("reviewer"): continue author_name = dateline_match.group("name") if not author_name: continue if re.search("([^a-zA-Z]and[^a-zA-Z])|(,)|(@)", author_name): # This entry seems to have multiple reviewers, or invalid characters, so reject it. continue svn_id_match = re.search(ChangeLogEntry.svn_id_regexp, message, re.MULTILINE) if svn_id_match: svn_id = svn_id_match.group("svnid") if not svn_id_match or not svn_id: svn_id = "unknown" commit_date = dateline_match.group("date") # See if we already have a contributor with this name or email counter_by_name = self._counters_by_name.get(author_name) counter_by_email = self._counters_by_email.get(author_email) if counter_by_name: if counter_by_email: if counter_by_name != counter_by_email: # Merge these two counters This is for the case where we had # John Smith (jsmith@gmail.com) and Jonathan Smith (jsmith@apple.com) # and just found a John Smith (jsmith@apple.com). Now we know the # two names are the same person counter_by_name['names'] |= counter_by_email['names'] counter_by_name['emails'] |= counter_by_email['emails'] counter_by_name['count'] += counter_by_email.get('count', 0) self._counters_by_email[author_email] = counter_by_name else: # Add email to the existing counter self._counters_by_email[author_email] = counter_by_name counter_by_name['emails'] |= set([author_email]) else: if counter_by_email: # Add name to the existing counter self._counters_by_name[author_name] = counter_by_email counter_by_email['names'] |= set([author_name]) else: # Create new counter new_counter = {'names': set([author_name]), 'emails': set([author_email]), 'latest_name': author_name, 'latest_email': author_email, 'commits': ""} self._counters_by_name[author_name] = new_counter self._counters_by_email[author_email] = new_counter assert(self._counters_by_name[author_name] == self._counters_by_email[author_email]) counter = self._counters_by_name[author_name] counter['count'] = counter.get('count', 0) + 1 if svn_id.isdigit(): svn_id = "http://trac.webkit.org/changeset/" + svn_id counter['commits'] += " commit: %s on %s by %s (%s)\n" % (svn_id, commit_date, author_name, author_email) return self._counters_by_email def _collect_nominations(self, counters_by_email): nominations = [] for author_email, counter in counters_by_email.items(): if author_email != counter['latest_email']: continue roles = [] contributor = self._committer_list.contributor_by_email(author_email) author_name = counter['latest_name'] patch_count = counter['count'] if patch_count >= self.committer_minimum and (not contributor or not contributor.can_commit): roles.append("committer") if patch_count >= self.reviewer_minimum and (not contributor or not contributor.can_review): roles.append("reviewer") if roles: nominations.append({ 'roles': roles, 'author_name': author_name, 'author_email': author_email, 'patch_count': patch_count, }) return nominations def _print_nominations(self, nominations): def nomination_cmp(a_nomination, b_nomination): roles_result = cmp(a_nomination['roles'], b_nomination['roles']) if roles_result: return -roles_result count_result = cmp(a_nomination['patch_count'], b_nomination['patch_count']) if count_result: return -count_result return cmp(a_nomination['author_name'], b_nomination['author_name']) for nomination in sorted(nominations, nomination_cmp): # This is a little bit of a hack, but its convienent to just pass the nomination dictionary to the formating operator. nomination['roles_string'] = join_with_separators(nomination['roles']).upper() print "%(roles_string)s: %(author_name)s (%(author_email)s) has %(patch_count)s reviewed patches" % nomination counter = self._counters_by_email[nomination['author_email']] if self.show_commits: print counter['commits'] def _print_counts(self, counters_by_email): def counter_cmp(a_tuple, b_tuple): # split the tuples # the second element is the "counter" structure _, a_counter = a_tuple _, b_counter = b_tuple count_result = cmp(a_counter['count'], b_counter['count']) if count_result: return -count_result return cmp(a_counter['latest_name'].lower(), b_counter['latest_name'].lower()) for author_email, counter in sorted(counters_by_email.items(), counter_cmp): if author_email != counter['latest_email']: continue contributor = self._committer_list.contributor_by_email(author_email) author_name = counter['latest_name'] patch_count = counter['count'] counter['names'] = counter['names'] - set([author_name]) counter['emails'] = counter['emails'] - set([author_email]) alias_list = [] for alias in counter['names']: alias_list.append(alias) for alias in counter['emails']: alias_list.append(alias) if alias_list: print "CONTRIBUTOR: %s (%s) has %d reviewed patches %s" % (author_name, author_email, patch_count, "(aliases: " + ", ".join(alias_list) + ")") else: print "CONTRIBUTOR: %s (%s) has %d reviewed patches" % (author_name, author_email, patch_count) return def execute(self, options, args, tool): self._init_options(options) patch_counts = self._count_recent_patches() nominations = self._collect_nominations(patch_counts) self._print_nominations(nominations) if self.verbose: self._print_counts(patch_counts) if __name__ == "__main__": SuggestNominations()