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