|
1 #!/usr/bin/env python |
|
2 # Copyright (c) 2009, 2010 Google Inc. All rights reserved. |
|
3 # Copyright (c) 2009 Apple Inc. All rights reserved. |
|
4 # |
|
5 # Redistribution and use in source and binary forms, with or without |
|
6 # modification, are permitted provided that the following conditions are |
|
7 # met: |
|
8 # |
|
9 # * Redistributions of source code must retain the above copyright |
|
10 # notice, this list of conditions and the following disclaimer. |
|
11 # * Redistributions in binary form must reproduce the above |
|
12 # copyright notice, this list of conditions and the following disclaimer |
|
13 # in the documentation and/or other materials provided with the |
|
14 # distribution. |
|
15 # * Neither the name of Google Inc. nor the names of its |
|
16 # contributors may be used to endorse or promote products derived from |
|
17 # this software without specific prior written permission. |
|
18 # |
|
19 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
|
20 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
|
21 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
|
22 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
|
23 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
24 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
|
25 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
|
26 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
|
27 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|
28 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
|
29 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
30 |
|
31 import os |
|
32 import re |
|
33 import sys |
|
34 |
|
35 from optparse import make_option |
|
36 |
|
37 import webkitpy.tool.steps as steps |
|
38 |
|
39 from webkitpy.common.config.committers import CommitterList |
|
40 from webkitpy.common.net.bugzilla import parse_bug_id |
|
41 from webkitpy.common.system.user import User |
|
42 from webkitpy.thirdparty.mock import Mock |
|
43 from webkitpy.tool.commands.abstractsequencedcommand import AbstractSequencedCommand |
|
44 from webkitpy.tool.grammar import pluralize, join_with_separators |
|
45 from webkitpy.tool.comments import bug_comment_from_svn_revision |
|
46 from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand |
|
47 from webkitpy.common.system.deprecated_logging import error, log |
|
48 |
|
49 |
|
50 class CommitMessageForCurrentDiff(AbstractDeclarativeCommand): |
|
51 name = "commit-message" |
|
52 help_text = "Print a commit message suitable for the uncommitted changes" |
|
53 |
|
54 def __init__(self): |
|
55 options = [ |
|
56 steps.Options.git_commit, |
|
57 ] |
|
58 AbstractDeclarativeCommand.__init__(self, options=options) |
|
59 |
|
60 def execute(self, options, args, tool): |
|
61 # This command is a useful test to make sure commit_message_for_this_commit |
|
62 # always returns the right value regardless of the current working directory. |
|
63 print "%s" % tool.checkout().commit_message_for_this_commit(options.git_commit).message() |
|
64 |
|
65 |
|
66 class CleanPendingCommit(AbstractDeclarativeCommand): |
|
67 name = "clean-pending-commit" |
|
68 help_text = "Clear r+ on obsolete patches so they do not appear in the pending-commit list." |
|
69 |
|
70 # NOTE: This was designed to be generic, but right now we're only processing patches from the pending-commit list, so only r+ matters. |
|
71 def _flags_to_clear_on_patch(self, patch): |
|
72 if not patch.is_obsolete(): |
|
73 return None |
|
74 what_was_cleared = [] |
|
75 if patch.review() == "+": |
|
76 if patch.reviewer(): |
|
77 what_was_cleared.append("%s's review+" % patch.reviewer().full_name) |
|
78 else: |
|
79 what_was_cleared.append("review+") |
|
80 return join_with_separators(what_was_cleared) |
|
81 |
|
82 def execute(self, options, args, tool): |
|
83 committers = CommitterList() |
|
84 for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list(): |
|
85 bug = self.tool.bugs.fetch_bug(bug_id) |
|
86 patches = bug.patches(include_obsolete=True) |
|
87 for patch in patches: |
|
88 flags_to_clear = self._flags_to_clear_on_patch(patch) |
|
89 if not flags_to_clear: |
|
90 continue |
|
91 message = "Cleared %s from obsolete attachment %s so that this bug does not appear in http://webkit.org/pending-commit." % (flags_to_clear, patch.id()) |
|
92 self.tool.bugs.obsolete_attachment(patch.id(), message) |
|
93 |
|
94 |
|
95 class AssignToCommitter(AbstractDeclarativeCommand): |
|
96 name = "assign-to-committer" |
|
97 help_text = "Assign bug to whoever attached the most recent r+'d patch" |
|
98 |
|
99 def _patches_have_commiters(self, reviewed_patches): |
|
100 for patch in reviewed_patches: |
|
101 if not patch.committer(): |
|
102 return False |
|
103 return True |
|
104 |
|
105 def _assign_bug_to_last_patch_attacher(self, bug_id): |
|
106 committers = CommitterList() |
|
107 bug = self.tool.bugs.fetch_bug(bug_id) |
|
108 if not bug.is_unassigned(): |
|
109 assigned_to_email = bug.assigned_to_email() |
|
110 log("Bug %s is already assigned to %s (%s)." % (bug_id, assigned_to_email, committers.committer_by_email(assigned_to_email))) |
|
111 return |
|
112 |
|
113 reviewed_patches = bug.reviewed_patches() |
|
114 if not reviewed_patches: |
|
115 log("Bug %s has no non-obsolete patches, ignoring." % bug_id) |
|
116 return |
|
117 |
|
118 # We only need to do anything with this bug if one of the r+'d patches does not have a valid committer (cq+ set). |
|
119 if self._patches_have_commiters(reviewed_patches): |
|
120 log("All reviewed patches on bug %s already have commit-queue+, ignoring." % bug_id) |
|
121 return |
|
122 |
|
123 latest_patch = reviewed_patches[-1] |
|
124 attacher_email = latest_patch.attacher_email() |
|
125 committer = committers.committer_by_email(attacher_email) |
|
126 if not committer: |
|
127 log("Attacher %s is not a committer. Bug %s likely needs commit-queue+." % (attacher_email, bug_id)) |
|
128 return |
|
129 |
|
130 reassign_message = "Attachment %s was posted by a committer and has review+, assigning to %s for commit." % (latest_patch.id(), committer.full_name) |
|
131 self.tool.bugs.reassign_bug(bug_id, committer.bugzilla_email(), reassign_message) |
|
132 |
|
133 def execute(self, options, args, tool): |
|
134 for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list(): |
|
135 self._assign_bug_to_last_patch_attacher(bug_id) |
|
136 |
|
137 |
|
138 class ObsoleteAttachments(AbstractSequencedCommand): |
|
139 name = "obsolete-attachments" |
|
140 help_text = "Mark all attachments on a bug as obsolete" |
|
141 argument_names = "BUGID" |
|
142 steps = [ |
|
143 steps.ObsoletePatches, |
|
144 ] |
|
145 |
|
146 def _prepare_state(self, options, args, tool): |
|
147 return { "bug_id" : args[0] } |
|
148 |
|
149 |
|
150 class AbstractPatchUploadingCommand(AbstractSequencedCommand): |
|
151 def _bug_id(self, options, args, tool, state): |
|
152 # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs). |
|
153 bug_id = args and args[0] |
|
154 if not bug_id: |
|
155 bug_id = tool.checkout().bug_id_for_this_commit(options.git_commit) |
|
156 return bug_id |
|
157 |
|
158 def _prepare_state(self, options, args, tool): |
|
159 state = {} |
|
160 state["bug_id"] = self._bug_id(options, args, tool, state) |
|
161 if not state["bug_id"]: |
|
162 error("No bug id passed and no bug url found in ChangeLogs.") |
|
163 return state |
|
164 |
|
165 |
|
166 class Post(AbstractPatchUploadingCommand): |
|
167 name = "post" |
|
168 help_text = "Attach the current working directory diff to a bug as a patch file" |
|
169 argument_names = "[BUGID]" |
|
170 steps = [ |
|
171 steps.CheckStyle, |
|
172 steps.ConfirmDiff, |
|
173 steps.ObsoletePatches, |
|
174 steps.PostDiff, |
|
175 ] |
|
176 |
|
177 |
|
178 class LandSafely(AbstractPatchUploadingCommand): |
|
179 name = "land-safely" |
|
180 help_text = "Land the current diff via the commit-queue" |
|
181 argument_names = "[BUGID]" |
|
182 long_help = """land-safely updates the ChangeLog with the reviewer listed |
|
183 in bugs.webkit.org for BUGID (or the bug ID detected from the ChangeLog). |
|
184 The command then uploads the current diff to the bug and marks it for |
|
185 commit by the commit-queue.""" |
|
186 show_in_main_help = True |
|
187 steps = [ |
|
188 steps.UpdateChangeLogsWithReviewer, |
|
189 steps.ObsoletePatches, |
|
190 steps.PostDiffForCommit, |
|
191 ] |
|
192 |
|
193 |
|
194 class Prepare(AbstractSequencedCommand): |
|
195 name = "prepare" |
|
196 help_text = "Creates a bug (or prompts for an existing bug) and prepares the ChangeLogs" |
|
197 argument_names = "[BUGID]" |
|
198 steps = [ |
|
199 steps.PromptForBugOrTitle, |
|
200 steps.CreateBug, |
|
201 steps.PrepareChangeLog, |
|
202 ] |
|
203 |
|
204 def _prepare_state(self, options, args, tool): |
|
205 bug_id = args and args[0] |
|
206 return { "bug_id" : bug_id } |
|
207 |
|
208 |
|
209 class Upload(AbstractPatchUploadingCommand): |
|
210 name = "upload" |
|
211 help_text = "Automates the process of uploading a patch for review" |
|
212 argument_names = "[BUGID]" |
|
213 show_in_main_help = True |
|
214 steps = [ |
|
215 steps.CheckStyle, |
|
216 steps.PromptForBugOrTitle, |
|
217 steps.CreateBug, |
|
218 steps.PrepareChangeLog, |
|
219 steps.EditChangeLog, |
|
220 steps.ConfirmDiff, |
|
221 steps.ObsoletePatches, |
|
222 steps.PostDiff, |
|
223 ] |
|
224 long_help = """upload uploads the current diff to bugs.webkit.org. |
|
225 If no bug id is provided, upload will create a bug. |
|
226 If the current diff does not have a ChangeLog, upload |
|
227 will prepare a ChangeLog. Once a patch is read, upload |
|
228 will open the ChangeLogs for editing using the command in the |
|
229 EDITOR environment variable and will display the diff using the |
|
230 command in the PAGER environment variable.""" |
|
231 |
|
232 def _prepare_state(self, options, args, tool): |
|
233 state = {} |
|
234 state["bug_id"] = self._bug_id(options, args, tool, state) |
|
235 return state |
|
236 |
|
237 |
|
238 class EditChangeLogs(AbstractSequencedCommand): |
|
239 name = "edit-changelogs" |
|
240 help_text = "Opens modified ChangeLogs in $EDITOR" |
|
241 show_in_main_help = True |
|
242 steps = [ |
|
243 steps.EditChangeLog, |
|
244 ] |
|
245 |
|
246 |
|
247 class PostCommits(AbstractDeclarativeCommand): |
|
248 name = "post-commits" |
|
249 help_text = "Attach a range of local commits to bugs as patch files" |
|
250 argument_names = "COMMITISH" |
|
251 |
|
252 def __init__(self): |
|
253 options = [ |
|
254 make_option("-b", "--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."), |
|
255 make_option("--add-log-as-comment", action="store_true", dest="add_log_as_comment", default=False, help="Add commit log message as a comment when uploading the patch."), |
|
256 make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"), |
|
257 steps.Options.obsolete_patches, |
|
258 steps.Options.review, |
|
259 steps.Options.request_commit, |
|
260 ] |
|
261 AbstractDeclarativeCommand.__init__(self, options=options, requires_local_commits=True) |
|
262 |
|
263 def _comment_text_for_commit(self, options, commit_message, tool, commit_id): |
|
264 comment_text = None |
|
265 if (options.add_log_as_comment): |
|
266 comment_text = commit_message.body(lstrip=True) |
|
267 comment_text += "---\n" |
|
268 comment_text += tool.scm().files_changed_summary_for_commit(commit_id) |
|
269 return comment_text |
|
270 |
|
271 def execute(self, options, args, tool): |
|
272 commit_ids = tool.scm().commit_ids_from_commitish_arguments(args) |
|
273 if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is. |
|
274 error("webkit-patch does not support attaching %s at once. Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids)))) |
|
275 |
|
276 have_obsoleted_patches = set() |
|
277 for commit_id in commit_ids: |
|
278 commit_message = tool.scm().commit_message_for_local_commit(commit_id) |
|
279 |
|
280 # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs). |
|
281 bug_id = options.bug_id or parse_bug_id(commit_message.message()) or parse_bug_id(tool.scm().create_patch(git_commit=commit_id)) |
|
282 if not bug_id: |
|
283 log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id) |
|
284 continue |
|
285 |
|
286 if options.obsolete_patches and bug_id not in have_obsoleted_patches: |
|
287 state = { "bug_id": bug_id } |
|
288 steps.ObsoletePatches(tool, options).run(state) |
|
289 have_obsoleted_patches.add(bug_id) |
|
290 |
|
291 diff = tool.scm().create_patch(git_commit=commit_id) |
|
292 description = options.description or commit_message.description(lstrip=True, strip_url=True) |
|
293 comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id) |
|
294 tool.bugs.add_patch_to_bug(bug_id, diff, description, comment_text, mark_for_review=options.review, mark_for_commit_queue=options.request_commit) |
|
295 |
|
296 |
|
297 # FIXME: This command needs to be brought into the modern age with steps and CommitInfo. |
|
298 class MarkBugFixed(AbstractDeclarativeCommand): |
|
299 name = "mark-bug-fixed" |
|
300 help_text = "Mark the specified bug as fixed" |
|
301 argument_names = "[SVN_REVISION]" |
|
302 def __init__(self): |
|
303 options = [ |
|
304 make_option("--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."), |
|
305 make_option("--comment", action="store", type="string", dest="comment", help="Text to include in bug comment."), |
|
306 make_option("--open", action="store_true", default=False, dest="open_bug", help="Open bug in default web browser (Mac only)."), |
|
307 make_option("--update-only", action="store_true", default=False, dest="update_only", help="Add comment to the bug, but do not close it."), |
|
308 ] |
|
309 AbstractDeclarativeCommand.__init__(self, options=options) |
|
310 |
|
311 # FIXME: We should be using checkout().changelog_entries_for_revision(...) instead here. |
|
312 def _fetch_commit_log(self, tool, svn_revision): |
|
313 if not svn_revision: |
|
314 return tool.scm().last_svn_commit_log() |
|
315 return tool.scm().svn_commit_log(svn_revision) |
|
316 |
|
317 def _determine_bug_id_and_svn_revision(self, tool, bug_id, svn_revision): |
|
318 commit_log = self._fetch_commit_log(tool, svn_revision) |
|
319 |
|
320 if not bug_id: |
|
321 bug_id = parse_bug_id(commit_log) |
|
322 |
|
323 if not svn_revision: |
|
324 match = re.search("^r(?P<svn_revision>\d+) \|", commit_log, re.MULTILINE) |
|
325 if match: |
|
326 svn_revision = match.group('svn_revision') |
|
327 |
|
328 if not bug_id or not svn_revision: |
|
329 not_found = [] |
|
330 if not bug_id: |
|
331 not_found.append("bug id") |
|
332 if not svn_revision: |
|
333 not_found.append("svn revision") |
|
334 error("Could not find %s on command-line or in %s." |
|
335 % (" or ".join(not_found), "r%s" % svn_revision if svn_revision else "last commit")) |
|
336 |
|
337 return (bug_id, svn_revision) |
|
338 |
|
339 def execute(self, options, args, tool): |
|
340 bug_id = options.bug_id |
|
341 |
|
342 svn_revision = args and args[0] |
|
343 if svn_revision: |
|
344 if re.match("^r[0-9]+$", svn_revision, re.IGNORECASE): |
|
345 svn_revision = svn_revision[1:] |
|
346 if not re.match("^[0-9]+$", svn_revision): |
|
347 error("Invalid svn revision: '%s'" % svn_revision) |
|
348 |
|
349 needs_prompt = False |
|
350 if not bug_id or not svn_revision: |
|
351 needs_prompt = True |
|
352 (bug_id, svn_revision) = self._determine_bug_id_and_svn_revision(tool, bug_id, svn_revision) |
|
353 |
|
354 log("Bug: <%s> %s" % (tool.bugs.bug_url_for_bug_id(bug_id), tool.bugs.fetch_bug_dictionary(bug_id)["title"])) |
|
355 log("Revision: %s" % svn_revision) |
|
356 |
|
357 if options.open_bug: |
|
358 tool.user.open_url(tool.bugs.bug_url_for_bug_id(bug_id)) |
|
359 |
|
360 if needs_prompt: |
|
361 if not tool.user.confirm("Is this correct?"): |
|
362 exit(1) |
|
363 |
|
364 bug_comment = bug_comment_from_svn_revision(svn_revision) |
|
365 if options.comment: |
|
366 bug_comment = "%s\n\n%s" % (options.comment, bug_comment) |
|
367 |
|
368 if options.update_only: |
|
369 log("Adding comment to Bug %s." % bug_id) |
|
370 tool.bugs.post_comment_to_bug(bug_id, bug_comment) |
|
371 else: |
|
372 log("Adding comment to Bug %s and marking as Resolved/Fixed." % bug_id) |
|
373 tool.bugs.close_bug_as_fixed(bug_id, bug_comment) |
|
374 |
|
375 |
|
376 # FIXME: Requires unit test. Blocking issue: too complex for now. |
|
377 class CreateBug(AbstractDeclarativeCommand): |
|
378 name = "create-bug" |
|
379 help_text = "Create a bug from local changes or local commits" |
|
380 argument_names = "[COMMITISH]" |
|
381 |
|
382 def __init__(self): |
|
383 options = [ |
|
384 steps.Options.cc, |
|
385 steps.Options.component, |
|
386 make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."), |
|
387 make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), |
|
388 make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."), |
|
389 ] |
|
390 AbstractDeclarativeCommand.__init__(self, options=options) |
|
391 |
|
392 def create_bug_from_commit(self, options, args, tool): |
|
393 commit_ids = tool.scm().commit_ids_from_commitish_arguments(args) |
|
394 if len(commit_ids) > 3: |
|
395 error("Are you sure you want to create one bug with %s patches?" % len(commit_ids)) |
|
396 |
|
397 commit_id = commit_ids[0] |
|
398 |
|
399 bug_title = "" |
|
400 comment_text = "" |
|
401 if options.prompt: |
|
402 (bug_title, comment_text) = self.prompt_for_bug_title_and_comment() |
|
403 else: |
|
404 commit_message = tool.scm().commit_message_for_local_commit(commit_id) |
|
405 bug_title = commit_message.description(lstrip=True, strip_url=True) |
|
406 comment_text = commit_message.body(lstrip=True) |
|
407 comment_text += "---\n" |
|
408 comment_text += tool.scm().files_changed_summary_for_commit(commit_id) |
|
409 |
|
410 diff = tool.scm().create_patch(git_commit=commit_id) |
|
411 bug_id = tool.bugs.create_bug(bug_title, comment_text, options.component, diff, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit) |
|
412 |
|
413 if bug_id and len(commit_ids) > 1: |
|
414 options.bug_id = bug_id |
|
415 options.obsolete_patches = False |
|
416 # FIXME: We should pass through --no-comment switch as well. |
|
417 PostCommits.execute(self, options, commit_ids[1:], tool) |
|
418 |
|
419 def create_bug_from_patch(self, options, args, tool): |
|
420 bug_title = "" |
|
421 comment_text = "" |
|
422 if options.prompt: |
|
423 (bug_title, comment_text) = self.prompt_for_bug_title_and_comment() |
|
424 else: |
|
425 commit_message = tool.checkout().commit_message_for_this_commit(options.git_commit) |
|
426 bug_title = commit_message.description(lstrip=True, strip_url=True) |
|
427 comment_text = commit_message.body(lstrip=True) |
|
428 |
|
429 diff = tool.scm().create_patch(options.git_commit) |
|
430 bug_id = tool.bugs.create_bug(bug_title, comment_text, options.component, diff, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit) |
|
431 |
|
432 def prompt_for_bug_title_and_comment(self): |
|
433 bug_title = User.prompt("Bug title: ") |
|
434 print "Bug comment (hit ^D on blank line to end):" |
|
435 lines = sys.stdin.readlines() |
|
436 try: |
|
437 sys.stdin.seek(0, os.SEEK_END) |
|
438 except IOError: |
|
439 # Cygwin raises an Illegal Seek (errno 29) exception when the above |
|
440 # seek() call is made. Ignoring it seems to cause no harm. |
|
441 # FIXME: Figure out a way to get avoid the exception in the first |
|
442 # place. |
|
443 pass |
|
444 comment_text = "".join(lines) |
|
445 return (bug_title, comment_text) |
|
446 |
|
447 def execute(self, options, args, tool): |
|
448 if len(args): |
|
449 if (not tool.scm().supports_local_commits()): |
|
450 error("Extra arguments not supported; patch is taken from working directory.") |
|
451 self.create_bug_from_commit(options, args, tool) |
|
452 else: |
|
453 self.create_bug_from_patch(options, args, tool) |