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