| # 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 grammar |
| from webkitpy.tool.multicommandtool import Command |
| |
| |
| class CommitLogError(Exception): |
| def __init__(self): |
| Exception.__init__(self) |
| |
| |
| class CommitLogMissingReviewer(CommitLogError): |
| def __init__(self): |
| CommitLogError.__init__(self) |
| |
| |
| class AbstractCommitLogCommand(Command): |
| _leading_indent_regexp = re.compile(r"^[ ]{4}", re.MULTILINE) |
| _reviewed_by_regexp = re.compile(ChangeLogEntry.reviewed_by_regexp, re.MULTILINE) |
| _patch_by_regexp = re.compile(r'^Patch by (?P<name>.+?)\s+<(?P<email>[^<>]+)> on (?P<date>\d{4}-\d{2}-\d{2})$', re.MULTILINE) |
| _committer_regexp = re.compile(r'^Author: (?P<email>\S+)\s+<[^>]+>$', re.MULTILINE) |
| _date_regexp = re.compile(r'^Date: (?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) [\+\-]\d{4}$', re.MULTILINE) |
| _revision_regexp = re.compile(r'^git-svn-id: http://svn.webkit.org/repository/webkit/trunk@(?P<svnid>\d+) (?P<gitid>[0-9a-f\-]{36})$', re.MULTILINE) |
| |
| def __init__(self, options=None): |
| options = options or [] |
| options += [ |
| make_option("--max-commit-age", action="store", dest="max_commit_age", type="int", default=9, help="Specify maximum commit age to consider (in months)."), |
| ] |
| options = sorted(options, key=lambda option: option.dest) |
| super(AbstractCommitLogCommand, self).__init__(options=options) |
| # FIXME: This should probably be on the tool somewhere. |
| self._committer_list = CommitterList() |
| |
| def _init_options(self, options): |
| self.verbose = options.verbose |
| self.max_commit_age = options.max_commit_age |
| |
| # FIXME: This should move to scm.py |
| def _recent_commit_messages(self): |
| git_log = self._tool.executive.run_command(['git', 'log', '--date=iso', '--since="%s months ago"' % self.max_commit_age]) |
| messages = re.compile(r"^commit \w{40}$", re.MULTILINE).split(git_log)[1:] # Ignore the first message which will be empty. |
| for message in messages: |
| # Unindent all the lines |
| (message, _) = self._leading_indent_regexp.subn("", message) |
| yield message.lstrip() # Remove any leading newlines from the log message. |
| |
| def _author_name_from_email(self, email): |
| contributor = self._committer_list.contributor_by_email(email) |
| return contributor.full_name if contributor else None |
| |
| def _contributor_from_email(self, email): |
| contributor = self._committer_list.contributor_by_email(email) |
| return contributor if contributor else None |
| |
| def _parse_commit_message(self, commit_message): |
| committer_match = self._committer_regexp.search(commit_message) |
| if not committer_match: |
| raise CommitLogError |
| |
| committer_email = committer_match.group('email') |
| if not committer_email: |
| raise CommitLogError |
| |
| committer = self._contributor_from_email(committer_email) |
| if not committer: |
| raise CommitLogError |
| |
| commit_date_match = self._date_regexp.search(commit_message) |
| if not commit_date_match: |
| raise CommitLogError |
| commit_date = commit_date_match.group('date') |
| |
| revision_match = self._revision_regexp.search(commit_message) |
| if not revision_match: |
| raise CommitLogError |
| revision = revision_match.group('svnid') |
| |
| # Look for "Patch by" line first, which is used for non-committer contributors; |
| # otherwise, use committer info determined above. |
| author_match = self._patch_by_regexp.search(commit_message) |
| if not author_match: |
| author_match = committer_match |
| |
| author_email = author_match.group('email') |
| if not author_email: |
| author_email = committer_email |
| |
| author_name = author_match.group('name') if 'name' in author_match.groupdict() else None |
| if not author_name: |
| author_name = self._author_name_from_email(author_email) |
| if not author_name: |
| raise CommitLogError |
| |
| contributor = self._contributor_from_email(author_email) |
| if contributor and author_name != contributor.full_name and contributor.full_name: |
| author_name = contributor.full_name |
| |
| reviewer_match = self._reviewed_by_regexp.search(commit_message) |
| if not reviewer_match: |
| raise CommitLogMissingReviewer |
| reviewers = reviewer_match.group('reviewer') |
| |
| return { |
| 'committer': committer, |
| 'commit_date': commit_date, |
| 'revision': revision, |
| 'author_email': author_email, |
| 'author_name': author_name, |
| 'contributor': contributor, |
| 'reviewers': reviewers, |
| } |
| |
| |
| class SuggestNominations(AbstractCommitLogCommand): |
| 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("--show-commits", action="store_true", dest="show_commits", default=False, help="Show commit history with nomination suggestions."), |
| ] |
| super(SuggestNominations, self).__init__(options=options) |
| |
| def _init_options(self, options): |
| super(SuggestNominations, self)._init_options(options) |
| self.committer_minimum = options.committer_minimum |
| self.reviewer_minimum = options.reviewer_minimum |
| self.show_commits = options.show_commits |
| |
| def _count_commit(self, commit, analysis): |
| author_name = commit['author_name'] |
| author_email = commit['author_email'] |
| revision = commit['revision'] |
| commit_date = commit['commit_date'] |
| |
| # See if we already have a contributor with this author_name or email |
| counter_by_name = analysis['counters_by_name'].get(author_name) |
| counter_by_email = analysis['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) |
| analysis['counters_by_email'][author_email] = counter_by_name |
| else: |
| # Add email to the existing counter |
| analysis['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 |
| analysis['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': ""} |
| analysis['counters_by_name'][author_name] = new_counter |
| analysis['counters_by_email'][author_email] = new_counter |
| |
| assert(analysis['counters_by_name'][author_name] == analysis['counters_by_email'][author_email]) |
| counter = analysis['counters_by_name'][author_name] |
| counter['count'] = counter.get('count', 0) + 1 |
| |
| if revision.isdigit(): |
| revision = "https://trac.webkit.org/changeset/" + revision |
| counter['commits'] += " commit: %s on %s by %s (%s)\n" % (revision, commit_date, author_name, author_email) |
| |
| def _count_recent_patches(self): |
| analysis = { |
| 'counters_by_name': {}, |
| 'counters_by_email': {}, |
| } |
| for commit_message in self._recent_commit_messages(): |
| try: |
| self._count_commit(self._parse_commit_message(commit_message), analysis) |
| except CommitLogError as exception: |
| continue |
| return analysis['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 contributor and contributor.can_commit and 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, counters_by_email): |
| nominations = sorted(nominations, key=lambda a: a['roles']) |
| nominations = sorted(nominations, key=lambda a: a['patch_count']) |
| nominations = sorted(nominations, key=lambda a: a['author_name']) |
| |
| for nomination in nominations: |
| # This is a little bit of a hack, but its convienent to just pass the nomination dictionary to the formating operator. |
| nomination['roles_string'] = grammar.join_with_separators(nomination['roles']).upper() |
| print("%(roles_string)s: %(author_name)s (%(author_email)s) has %(patch_count)s reviewed patches" % nomination) |
| counter = counters_by_email[nomination['author_email']] |
| |
| if self.show_commits: |
| print(counter['commits']) |
| |
| def _print_counts(self, counters_by_email): |
| counters = sorted(counters_by_email.items(), key=lambda counter: counter[1]['count']) |
| counters = sorted(counters, key=lambda counter: counter[1]['latest_name']) |
| |
| for author_email, counter in counters: |
| 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 %s %s" % (author_name, author_email, grammar.pluralize(patch_count, "reviewed patch"), "(aliases: " + ", ".join(alias_list) + ")")) |
| else: |
| print("CONTRIBUTOR: %s (%s) has %s" % (author_name, author_email, grammar.pluralize(patch_count, "reviewed patch"))) |
| 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, patch_counts) |
| if self.verbose: |
| self._print_counts(patch_counts) |
| |
| if __name__ == "__main__": |
| SuggestNominations() |