1# Copyright (c) 2011 Google Inc. All rights reserved.
2# Copyright (c) 2011 Code Aurora Forum. All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8# * Redistributions of source code must retain the above copyright
9# notice, this list of conditions and the following disclaimer.
10# * Redistributions in binary form must reproduce the above
11# copyright notice, this list of conditions and the following disclaimer
12# in the documentation and/or other materials provided with the
13# distribution.
14# * Neither the name of Google Inc. nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30from optparse import make_option
31import re
32
33from webkitpy.common.checkout.changelog import ChangeLogEntry
34from webkitpy.common.config.committers import CommitterList
35from webkitpy.tool import steps
36from webkitpy.tool.grammar import join_with_separators
37from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand
38
39
40class SuggestNominations(AbstractDeclarativeCommand):
41 name = "suggest-nominations"
42 help_text = "Suggest contributors for committer/reviewer nominations"
43
44 def __init__(self):
45 options = [
46 make_option("--committer-minimum", action="store", dest="committer_minimum", type="int", default=10, help="Specify minimum patch count for Committer nominations."),
47 make_option("--reviewer-minimum", action="store", dest="reviewer_minimum", type="int", default=80, help="Specify minimum patch count for Reviewer nominations."),
48 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)."),
49 make_option("--show-commits", action="store_true", dest="show_commits", default=False, help="Show commit history with nomination suggestions."),
50 ]
51
52 AbstractDeclarativeCommand.__init__(self, options=options)
53 # FIXME: This should probably be on the tool somewhere.
54 self._committer_list = CommitterList()
55
56 _counters_by_name = {}
57 _counters_by_email = {}
58
59 def _init_options(self, options):
60 self.committer_minimum = options.committer_minimum
61 self.reviewer_minimum = options.reviewer_minimum
62 self.max_commit_age = options.max_commit_age
63 self.show_commits = options.show_commits
64 self.verbose = options.verbose
65
66 # FIXME: This should move to scm.py
67 def _recent_commit_messages(self):
68 git_log = self._tool.executive.run_command(['git', 'log', '--since="%s months ago"' % self.max_commit_age])
69 match_git_svn_id = re.compile(r"\n\n git-svn-id:.*\n", re.MULTILINE)
70 match_get_log_lines = re.compile(r"^\S.*\n", re.MULTILINE)
71 match_leading_indent = re.compile(r"^[ ]{4}", re.MULTILINE)
72
73 messages = re.split(r"commit \w{40}", git_log)[1:] # Ignore the first message which will be empty.
74 for message in messages:
75 # Remove any lines from git and unindent all the lines
76 (message, _) = match_git_svn_id.subn("", message)
77 (message, _) = match_get_log_lines.subn("", message)
78 (message, _) = match_leading_indent.subn("", message)
79 yield message.lstrip() # Remove any leading newlines from the log message.
80
81 # e.g. Patch by Eric Seidel <eric@webkit.org> on 2011-09-15
82 patch_by_regexp = r'^Patch by (?P<name>.+?)\s+<(?P<email>[^<>]+)> on (?P<date>\d{4}-\d{2}-\d{2})$'
83
84 def _count_recent_patches(self):
85 # This entire block could be written as a map/reduce over the messages.
86 for message in self._recent_commit_messages():
87 # FIXME: This should use ChangeLogEntry to do the entire parse instead
88 # of grabbing at its regexps.
89 dateline_match = re.match(ChangeLogEntry.date_line_regexp, message, re.MULTILINE)
90 if not dateline_match:
91 # Modern commit messages don't just dump the ChangeLog entry, but rather
92 # have a special Patch by line for non-committers.
93 dateline_match = re.search(self.patch_by_regexp, message, re.MULTILINE)
94 if not dateline_match:
95 continue
96
97 author_email = dateline_match.group("email")
98 if not author_email:
99 continue
100
101 # We only care about reviewed patches, so make sure it has a valid reviewer line.
102 reviewer_match = re.search(ChangeLogEntry.reviewed_by_regexp, message, re.MULTILINE)
103 # We might also want to validate the reviewer name against the committer list.
104 if not reviewer_match or not reviewer_match.group("reviewer"):
105 continue
106
107 author_name = dateline_match.group("name")
108 if not author_name:
109 continue
110
111 if re.search("([^a-zA-Z]and[^a-zA-Z])|(,)|(@)", author_name):
112 # This entry seems to have multiple reviewers, or invalid characters, so reject it.
113 continue
114
115 svn_id_match = re.search(ChangeLogEntry.svn_id_regexp, message, re.MULTILINE)
116 if svn_id_match:
117 svn_id = svn_id_match.group("svnid")
118 if not svn_id_match or not svn_id:
119 svn_id = "unknown"
120 commit_date = dateline_match.group("date")
121
122 # See if we already have a contributor with this name or email
123 counter_by_name = self._counters_by_name.get(author_name)
124 counter_by_email = self._counters_by_email.get(author_email)
125 if counter_by_name:
126 if counter_by_email:
127 if counter_by_name != counter_by_email:
128 # Merge these two counters This is for the case where we had
129 # John Smith (jsmith@gmail.com) and Jonathan Smith (jsmith@apple.com)
130 # and just found a John Smith (jsmith@apple.com). Now we know the
131 # two names are the same person
132 counter_by_name['names'] |= counter_by_email['names']
133 counter_by_name['emails'] |= counter_by_email['emails']
134 counter_by_name['count'] += counter_by_email.get('count', 0)
135 self._counters_by_email[author_email] = counter_by_name
136 else:
137 # Add email to the existing counter
138 self._counters_by_email[author_email] = counter_by_name
139 counter_by_name['emails'] |= set([author_email])
140 else:
141 if counter_by_email:
142 # Add name to the existing counter
143 self._counters_by_name[author_name] = counter_by_email
144 counter_by_email['names'] |= set([author_name])
145 else:
146 # Create new counter
147 new_counter = {'names': set([author_name]), 'emails': set([author_email]), 'latest_name': author_name, 'latest_email': author_email, 'commits': ""}
148 self._counters_by_name[author_name] = new_counter
149 self._counters_by_email[author_email] = new_counter
150
151 assert(self._counters_by_name[author_name] == self._counters_by_email[author_email])
152 counter = self._counters_by_name[author_name]
153 counter['count'] = counter.get('count', 0) + 1
154
155 if svn_id.isdigit():
156 svn_id = "http://trac.webkit.org/changeset/" + svn_id
157 counter['commits'] += " commit: %s on %s by %s (%s)\n" % (svn_id, commit_date, author_name, author_email)
158
159 return self._counters_by_email
160
161 def _collect_nominations(self, counters_by_email):
162 nominations = []
163 for author_email, counter in counters_by_email.items():
164 if author_email != counter['latest_email']:
165 continue
166 roles = []
167
168 contributor = self._committer_list.contributor_by_email(author_email)
169
170 author_name = counter['latest_name']
171 patch_count = counter['count']
172
173 if patch_count >= self.committer_minimum and (not contributor or not contributor.can_commit):
174 roles.append("committer")
175 if patch_count >= self.reviewer_minimum and (not contributor or not contributor.can_review):
176 roles.append("reviewer")
177 if roles:
178 nominations.append({
179 'roles': roles,
180 'author_name': author_name,
181 'author_email': author_email,
182 'patch_count': patch_count,
183 })
184 return nominations
185
186 def _print_nominations(self, nominations):
187 def nomination_cmp(a_nomination, b_nomination):
188 roles_result = cmp(a_nomination['roles'], b_nomination['roles'])
189 if roles_result:
190 return -roles_result
191 count_result = cmp(a_nomination['patch_count'], b_nomination['patch_count'])
192 if count_result:
193 return -count_result
194 return cmp(a_nomination['author_name'], b_nomination['author_name'])
195
196 for nomination in sorted(nominations, nomination_cmp):
197 # This is a little bit of a hack, but its convienent to just pass the nomination dictionary to the formating operator.
198 nomination['roles_string'] = join_with_separators(nomination['roles']).upper()
199 print "%(roles_string)s: %(author_name)s (%(author_email)s) has %(patch_count)s reviewed patches" % nomination
200 counter = self._counters_by_email[nomination['author_email']]
201
202 if self.show_commits:
203 print counter['commits']
204
205 def _print_counts(self, counters_by_email):
206 def counter_cmp(a_tuple, b_tuple):
207 # split the tuples
208 # the second element is the "counter" structure
209 _, a_counter = a_tuple
210 _, b_counter = b_tuple
211
212 count_result = cmp(a_counter['count'], b_counter['count'])
213 if count_result:
214 return -count_result
215 return cmp(a_counter['latest_name'].lower(), b_counter['latest_name'].lower())
216
217 for author_email, counter in sorted(counters_by_email.items(), counter_cmp):
218 if author_email != counter['latest_email']:
219 continue
220 contributor = self._committer_list.contributor_by_email(author_email)
221 author_name = counter['latest_name']
222 patch_count = counter['count']
223 counter['names'] = counter['names'] - set([author_name])
224 counter['emails'] = counter['emails'] - set([author_email])
225
226 alias_list = []
227 for alias in counter['names']:
228 alias_list.append(alias)
229 for alias in counter['emails']:
230 alias_list.append(alias)
231 if alias_list:
232 print "CONTRIBUTOR: %s (%s) has %d reviewed patches %s" % (author_name, author_email, patch_count, "(aliases: " + ", ".join(alias_list) + ")")
233 else:
234 print "CONTRIBUTOR: %s (%s) has %d reviewed patches" % (author_name, author_email, patch_count)
235 return
236
237 def execute(self, options, args, tool):
238 self._init_options(options)
239 patch_counts = self._count_recent_patches()
240 nominations = self._collect_nominations(patch_counts)
241 self._print_nominations(nominations)
242 if self.verbose:
243 self._print_counts(patch_counts)
244
245
246if __name__ == "__main__":
247 SuggestNominations()