webkit-patch: Passing --no-review should submit patch to EWS by default
[WebKit.git] / Tools / Scripts / webkitpy / tool / commands / upload.py
1 # Copyright (c) 2009, 2010 Google Inc. All rights reserved.
2 # Copyright (c) 2009 Apple Inc. 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
30 import logging
31 import os
32 import re
33 import sys
34
35 from optparse import make_option
36
37 from webkitpy.tool import steps
38
39 from webkitpy.common.checkout.changelog import parse_bug_id_from_changelog
40 from webkitpy.common.config.committers import CommitterList
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.comments import bug_comment_from_svn_revision
45 from webkitpy.tool.grammar import pluralize, join_with_separators
46 from webkitpy.tool.multicommandtool import Command
47
48 _log = logging.getLogger(__name__)
49
50
51 class CommitMessageForCurrentDiff(Command):
52     name = "commit-message"
53     help_text = "Print a commit message suitable for the uncommitted changes"
54
55     def __init__(self):
56         options = [
57             steps.Options.git_commit,
58         ]
59         Command.__init__(self, options=options)
60
61     def execute(self, options, args, tool):
62         # This command is a useful test to make sure commit_message_for_this_commit
63         # always returns the right value regardless of the current working directory.
64         print "%s" % tool.checkout().commit_message_for_this_commit(options.git_commit).message()
65
66
67 class CleanPendingCommit(Command):
68     name = "clean-pending-commit"
69     help_text = "Clear r+ on obsolete patches so they do not appear in the pending-commit list."
70
71     # NOTE: This was designed to be generic, but right now we're only processing patches from the pending-commit list, so only r+ matters.
72     def _flags_to_clear_on_patch(self, patch):
73         if not patch.is_obsolete():
74             return None
75         what_was_cleared = []
76         if patch.review() == "+":
77             if patch.reviewer():
78                 what_was_cleared.append(u"%s's review+" % patch.reviewer().full_name)
79             else:
80                 what_was_cleared.append("review+")
81         return join_with_separators(what_was_cleared)
82
83     def execute(self, options, args, tool):
84         committers = CommitterList()
85         for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list():
86             bug = self._tool.bugs.fetch_bug(bug_id)
87             patches = bug.patches(include_obsolete=True)
88             for patch in patches:
89                 flags_to_clear = self._flags_to_clear_on_patch(patch)
90                 if not flags_to_clear:
91                     continue
92                 message = u"Cleared %s from obsolete attachment %s so that this bug does not appear in http://webkit.org/pending-commit." % (flags_to_clear, patch.id())
93                 self._tool.bugs.obsolete_attachment(patch.id(), message)
94
95
96 # FIXME: This should be share more logic with AssignToCommitter and CleanPendingCommit
97 class CleanReviewQueue(Command):
98     name = "clean-review-queue"
99     help_text = "Clear r? on obsolete patches so they do not appear in the pending-review list."
100
101     def execute(self, options, args, tool):
102         queue_url = "http://webkit.org/pending-review"
103         # We do this inefficient dance to be more like webkit.org/pending-review
104         # bugs.queries.fetch_bug_ids_from_review_queue() doesn't return
105         # closed bugs, but folks using /pending-review will see them. :(
106         for patch_id in tool.bugs.queries.fetch_attachment_ids_from_review_queue():
107             patch = self._tool.bugs.fetch_attachment(patch_id)
108             if not patch.review() == "?":
109                 continue
110             attachment_obsolete_modifier = ""
111             if patch.is_obsolete():
112                 attachment_obsolete_modifier = "obsolete "
113             elif patch.bug().is_closed():
114                 bug_closed_explanation = "  If you would like this patch reviewed, please attach it to a new bug (or re-open this bug before marking it for review again)."
115             else:
116                 # Neither the patch was obsolete or the bug was closed, next patch...
117                 continue
118             message = "Cleared review? from %sattachment %s so that this bug does not appear in %s.%s" % (attachment_obsolete_modifier, patch.id(), queue_url, bug_closed_explanation)
119             self._tool.bugs.obsolete_attachment(patch.id(), message)
120
121
122 class AssignToCommitter(Command):
123     name = "assign-to-committer"
124     help_text = "Assign bug to whoever attached the most recent r+'d patch"
125
126     def _patches_have_commiters(self, reviewed_patches):
127         for patch in reviewed_patches:
128             if not patch.committer():
129                 return False
130         return True
131
132     def _assign_bug_to_last_patch_attacher(self, bug_id):
133         committers = CommitterList()
134         bug = self._tool.bugs.fetch_bug(bug_id)
135         if not bug.is_unassigned():
136             assigned_to_email = bug.assigned_to_email()
137             _log.info(u"Bug %s is already assigned to %s (%s)." % (bug_id, assigned_to_email, committers.committer_by_email(assigned_to_email)))
138             return
139
140         reviewed_patches = bug.reviewed_patches()
141         if not reviewed_patches:
142             _log.info("Bug %s has no non-obsolete patches, ignoring." % bug_id)
143             return
144
145         # We only need to do anything with this bug if one of the r+'d patches does not have a valid committer (cq+ set).
146         if self._patches_have_commiters(reviewed_patches):
147             _log.info("All reviewed patches on bug %s already have commit-queue+, ignoring." % bug_id)
148             return
149
150         latest_patch = reviewed_patches[-1]
151         attacher_email = latest_patch.attacher_email()
152         committer = committers.committer_by_email(attacher_email)
153         if not committer:
154             _log.info("Attacher %s is not a committer.  Bug %s likely needs commit-queue+." % (attacher_email, bug_id))
155             return
156
157         reassign_message = u"Attachment %s was posted by a committer and has review+, assigning to %s for commit." % (latest_patch.id(), committer.full_name)
158         self._tool.bugs.reassign_bug(bug_id, committer.bugzilla_email(), reassign_message)
159
160     def execute(self, options, args, tool):
161         for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list():
162             self._assign_bug_to_last_patch_attacher(bug_id)
163
164
165 class ObsoleteAttachments(AbstractSequencedCommand):
166     name = "obsolete-attachments"
167     help_text = "Mark all attachments on a bug as obsolete"
168     argument_names = "BUGID"
169     steps = [
170         steps.ObsoletePatches,
171     ]
172
173     def _prepare_state(self, options, args, tool):
174         return { "bug_id" : args[0] }
175
176
177 class AttachToBug(AbstractSequencedCommand):
178     name = "attach-to-bug"
179     help_text = "Attach the file to the bug"
180     argument_names = "BUGID FILEPATH"
181     steps = [
182         steps.AttachToBug,
183     ]
184
185     def _prepare_state(self, options, args, tool):
186         state = {}
187         state["bug_id"] = args[0]
188         state["filepath"] = args[1]
189         return state
190
191
192 class AbstractPatchUploadingCommand(AbstractSequencedCommand):
193     def _bug_id(self, options, args, tool, state):
194         # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
195         bug_id = args and args[0]
196         if not bug_id:
197             changed_files = self._tool.scm().changed_files(options.git_commit)
198             state["changed_files"] = changed_files
199             bug_id = tool.checkout().bug_id_for_this_commit(options.git_commit, changed_files)
200         return bug_id
201
202     def _prepare_state(self, options, args, tool):
203         state = {}
204         state["bug_id"] = self._bug_id(options, args, tool, state)
205         if not state["bug_id"]:
206             _log.error("No bug id passed and no bug url found in ChangeLogs.")
207             sys.exit(1)
208         return state
209
210
211 class Post(AbstractPatchUploadingCommand):
212     name = "post"
213     help_text = "Attach the current working directory diff to a bug as a patch file"
214     argument_names = "[BUGID]"
215     steps = [
216         steps.ValidateChangeLogs,
217         steps.CheckStyle,
218         steps.ConfirmDiff,
219         steps.ObsoletePatches,
220         steps.SuggestReviewers,
221         steps.EnsureBugIsOpenAndAssigned,
222         steps.PostDiff,
223     ]
224
225
226 class LandSafely(AbstractPatchUploadingCommand):
227     name = "land-safely"
228     help_text = "Land the current diff via the commit-queue"
229     argument_names = "[BUGID]"
230     long_help = """land-safely updates the ChangeLog with the reviewer listed
231     in bugs.webkit.org for BUGID (or the bug ID detected from the ChangeLog).
232     The command then uploads the current diff to the bug and marks it for
233     commit by the commit-queue."""
234     show_in_main_help = True
235     steps = [
236         steps.UpdateChangeLogsWithReviewer,
237         steps.ValidateChangeLogs,
238         steps.ObsoletePatches,
239         steps.EnsureBugIsOpenAndAssigned,
240         steps.PostDiffForCommit,
241     ]
242
243
244 class HasLanded(AbstractPatchUploadingCommand):
245     name = "has-landed"
246     help_text = "Check that the current code was successfully landed and no changes remain."
247     argument_names = "[BUGID]"
248     steps = [
249         steps.HasLanded,
250     ]
251
252
253 class Prepare(AbstractSequencedCommand):
254     name = "prepare"
255     help_text = "Creates a bug (or prompts for an existing bug) and prepares the ChangeLogs"
256     argument_names = "[BUGID]"
257     steps = [
258         steps.PromptForBugOrTitle,
259         steps.CreateBug,
260         steps.SortXcodeProjectFiles,
261         steps.PrepareChangeLog,
262     ]
263
264     def _prepare_state(self, options, args, tool):
265         bug_id = args and args[0]
266         return { "bug_id" : bug_id }
267
268
269 class Upload(AbstractPatchUploadingCommand):
270     name = "upload"
271     help_text = "Automates the process of uploading a patch for review"
272     argument_names = "[BUGID]"
273     show_in_main_help = True
274     steps = [
275         steps.ValidateChangeLogs,
276         steps.CheckStyle,
277         steps.PromptForBugOrTitle,
278         steps.CreateBug,
279         steps.SortXcodeProjectFiles,
280         steps.PrepareChangeLog,
281         steps.EditChangeLog,
282         steps.ConfirmDiff,
283         steps.ObsoletePatches,
284         steps.SuggestReviewers,
285         steps.EnsureBugIsOpenAndAssigned,
286         steps.PostDiff,
287         steps.SubmitToEWS,
288     ]
289     long_help = """upload uploads the current diff to bugs.webkit.org.
290     If no bug id is provided, upload will create a bug.
291     If the current diff does not have a ChangeLog, upload
292     will prepare a ChangeLog.  Once a patch is read, upload
293     will open the ChangeLogs for editing using the command in the
294     EDITOR environment variable and will display the diff using the
295     command in the PAGER environment variable."""
296
297     def _prepare_state(self, options, args, tool):
298         state = {}
299         state["bug_id"] = self._bug_id(options, args, tool, state)
300         return state
301
302
303 class EditChangeLogs(AbstractSequencedCommand):
304     name = "edit-changelogs"
305     help_text = "Opens modified ChangeLogs in $EDITOR"
306     show_in_main_help = True
307     steps = [
308         steps.EditChangeLog,
309     ]
310
311
312 class PostCommits(Command):
313     name = "post-commits"
314     help_text = "Attach a range of local commits to bugs as patch files"
315     argument_names = "COMMITISH"
316
317     def __init__(self):
318         options = [
319             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."),
320             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."),
321             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
322             steps.Options.obsolete_patches,
323             steps.Options.review,
324             steps.Options.request_commit,
325             steps.Options.ews,
326         ]
327         Command.__init__(self, options=options, requires_local_commits=True)
328
329     def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
330         comment_text = None
331         if (options.add_log_as_comment):
332             comment_text = commit_message.body(lstrip=True)
333             comment_text += "---\n"
334             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
335         return comment_text
336
337     def execute(self, options, args, tool):
338         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
339         if len(commit_ids) > 10:  # We could lower this limit, 10 is too many for one bug as-is.
340             _log.error("webkit-patch does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize(len(commit_ids), "patch")))
341             sys.exit(1)
342
343         have_obsoleted_patches = set()
344         for commit_id in commit_ids:
345             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
346
347             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
348             bug_id = options.bug_id or parse_bug_id_from_changelog(commit_message.message()) or parse_bug_id_from_changelog(tool.scm().create_patch(git_commit=commit_id))
349             if not bug_id:
350                 _log.info("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
351                 continue
352
353             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
354                 state = { "bug_id": bug_id }
355                 steps.ObsoletePatches(tool, options).run(state)
356                 have_obsoleted_patches.add(bug_id)
357
358             diff = tool.scm().create_patch(git_commit=commit_id)
359             description = options.description or commit_message.description(lstrip=True, strip_url=True)
360             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
361             attachment_id = tool.bugs.add_patch_to_bug(bug_id, diff, description, comment_text, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
362
363             # We only need to submit --no-review patches to EWS as patches posted for review are
364             # automatically submitted to EWS by EWSFeeder.
365             if not options.review and options.ews:
366                 state = {'attachment_ids': [attachment_id]}
367                 steps.SubmitToEWS(tool, options).run(state)
368
369
370 # FIXME: This command needs to be brought into the modern age with steps and CommitInfo.
371 class MarkBugFixed(Command):
372     name = "mark-bug-fixed"
373     help_text = "Mark the specified bug as fixed"
374     argument_names = "[SVN_REVISION]"
375
376     def __init__(self):
377         options = [
378             make_option("--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."),
379             make_option("--comment", action="store", type="string", dest="comment", help="Text to include in bug comment."),
380             make_option("--open", action="store_true", default=False, dest="open_bug", help="Open bug in default web browser (Mac only)."),
381             make_option("--update-only", action="store_true", default=False, dest="update_only", help="Add comment to the bug, but do not close it."),
382         ]
383         Command.__init__(self, options=options)
384
385     # FIXME: We should be using checkout().changelog_entries_for_revision(...) instead here.
386     def _fetch_commit_log(self, tool, svn_revision):
387         if not svn_revision:
388             return tool.scm().last_svn_commit_log()
389         return tool.scm().svn_commit_log(svn_revision)
390
391     def _determine_bug_id_and_svn_revision(self, tool, bug_id, svn_revision):
392         commit_log = self._fetch_commit_log(tool, svn_revision)
393
394         if not bug_id:
395             bug_id = parse_bug_id_from_changelog(commit_log)
396
397         if not svn_revision:
398             match = re.search("^r(?P<svn_revision>\d+) \|", commit_log, re.MULTILINE)
399             if match:
400                 svn_revision = match.group('svn_revision')
401
402         if not bug_id or not svn_revision:
403             not_found = []
404             if not bug_id:
405                 not_found.append("bug id")
406             if not svn_revision:
407                 not_found.append("svn revision")
408             _log.error("Could not find %s on command-line or in %s."
409                   % (" or ".join(not_found), "r%s" % svn_revision if svn_revision else "last commit"))
410             sys.exit(1)
411
412         return (bug_id, svn_revision)
413
414     def execute(self, options, args, tool):
415         bug_id = options.bug_id
416
417         svn_revision = args and args[0]
418         if svn_revision:
419             if re.match("^r[0-9]+$", svn_revision, re.IGNORECASE):
420                 svn_revision = svn_revision[1:]
421             if not re.match("^[0-9]+$", svn_revision):
422                 _log.error("Invalid svn revision: '%s'" % svn_revision)
423                 sys.exit(1)
424
425         needs_prompt = False
426         if not bug_id or not svn_revision:
427             needs_prompt = True
428             (bug_id, svn_revision) = self._determine_bug_id_and_svn_revision(tool, bug_id, svn_revision)
429
430         _log.info("Bug: <%s> %s" % (tool.bugs.bug_url_for_bug_id(bug_id), tool.bugs.fetch_bug_dictionary(bug_id)["title"]))
431         _log.info("Revision: %s" % svn_revision)
432
433         if options.open_bug:
434             tool.user.open_url(tool.bugs.bug_url_for_bug_id(bug_id))
435
436         if needs_prompt:
437             if not tool.user.confirm("Is this correct?"):
438                 self._exit(1)
439
440         bug_comment = bug_comment_from_svn_revision(svn_revision)
441         if options.comment:
442             bug_comment = "%s\n\n%s" % (options.comment, bug_comment)
443
444         if options.update_only:
445             _log.info("Adding comment to Bug %s." % bug_id)
446             tool.bugs.post_comment_to_bug(bug_id, bug_comment)
447         else:
448             _log.info("Adding comment to Bug %s and marking as Resolved/Fixed." % bug_id)
449             tool.bugs.close_bug_as_fixed(bug_id, bug_comment)
450
451
452 # FIXME: Requires unit test.  Blocking issue: too complex for now.
453 class CreateBug(Command):
454     name = "create-bug"
455     help_text = "Create a bug from local changes or local commits"
456     argument_names = "[COMMITISH]"
457
458     def __init__(self):
459         options = [
460             steps.Options.cc,
461             steps.Options.component,
462             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
463             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
464             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
465         ]
466         Command.__init__(self, options=options)
467
468     def create_bug_from_commit(self, options, args, tool):
469         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
470         if len(commit_ids) > 3:
471             _log.error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
472             sys.exit(1)
473
474         commit_id = commit_ids[0]
475
476         bug_title = ""
477         comment_text = ""
478         if options.prompt:
479             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
480         else:
481             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
482             bug_title = commit_message.description(lstrip=True, strip_url=True)
483             comment_text = commit_message.body(lstrip=True)
484             comment_text += "---\n"
485             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
486
487         diff = tool.scm().create_patch(git_commit=commit_id)
488         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)
489
490         if bug_id and len(commit_ids) > 1:
491             options.bug_id = bug_id
492             options.obsolete_patches = False
493             # FIXME: We should pass through --no-comment switch as well.
494             PostCommits.execute(self, options, commit_ids[1:], tool)
495
496     def create_bug_from_patch(self, options, args, tool):
497         bug_title = ""
498         comment_text = ""
499         if options.prompt:
500             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
501         else:
502             commit_message = tool.checkout().commit_message_for_this_commit(options.git_commit)
503             bug_title = commit_message.description(lstrip=True, strip_url=True)
504             comment_text = commit_message.body(lstrip=True)
505
506         diff = tool.scm().create_patch(options.git_commit)
507         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)
508
509     def prompt_for_bug_title_and_comment(self):
510         bug_title = User.prompt("Bug title: ")
511         # FIXME: User should provide a function for doing this multi-line prompt.
512         print "Bug comment (hit ^D on blank line to end):"
513         lines = sys.stdin.readlines()
514         try:
515             sys.stdin.seek(0, os.SEEK_END)
516         except IOError:
517             # Cygwin raises an Illegal Seek (errno 29) exception when the above
518             # seek() call is made. Ignoring it seems to cause no harm.
519             # FIXME: Figure out a way to get avoid the exception in the first
520             # place.
521             pass
522         comment_text = "".join(lines)
523         return (bug_title, comment_text)
524
525     def execute(self, options, args, tool):
526         if len(args):
527             if (not tool.scm().supports_local_commits()):
528                 _log.error("Extra arguments not supported; patch is taken from working directory.")
529                 sys.exit(1)
530             self.create_bug_from_commit(options, args, tool)
531         else:
532             self.create_bug_from_patch(options, args, tool)