2009-11-20 Adam Barth <abarth@webkit.org>
authorabarth@webkit.org <abarth@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 21 Nov 2009 00:15:23 +0000 (00:15 +0000)
committerabarth@webkit.org <abarth@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 21 Nov 2009 00:15:23 +0000 (00:15 +0000)
        Reviewed by Eric Seidel.

        Move bugzilla-tool commands into their own file
        https://bugs.webkit.org/show_bug.cgi?id=31752

        This will let us write unit tests.

        * Scripts/bugzilla-tool:
        * Scripts/modules/commands/__init__.py: Added.
        * Scripts/modules/commands/download.py: Added.
        * Scripts/modules/commands/queries.py: Added.
        * Scripts/modules/commands/queues.py: Added.
        * Scripts/modules/commands/upload.py: Added.
        * Scripts/modules/grammar.py: Added.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@51263 268f45cc-cd09-0410-ab3c-d52691b4dbfc

WebKitTools/ChangeLog
WebKitTools/Scripts/bugzilla-tool
WebKitTools/Scripts/modules/commands/__init__.py [new file with mode: 0644]
WebKitTools/Scripts/modules/commands/download.py [new file with mode: 0644]
WebKitTools/Scripts/modules/commands/queries.py [new file with mode: 0644]
WebKitTools/Scripts/modules/commands/queues.py [new file with mode: 0644]
WebKitTools/Scripts/modules/commands/upload.py [new file with mode: 0644]
WebKitTools/Scripts/modules/grammar.py [new file with mode: 0644]

index d50ed832c5d04a9a0b77b1890c8d55401831184c..271532dbf6a62b64c1cfe70b243176ec6248fa56 100644 (file)
@@ -1,3 +1,20 @@
+2009-11-20  Adam Barth  <abarth@webkit.org>
+
+        Reviewed by Eric Seidel.
+
+        Move bugzilla-tool commands into their own file
+        https://bugs.webkit.org/show_bug.cgi?id=31752
+
+        This will let us write unit tests.
+
+        * Scripts/bugzilla-tool:
+        * Scripts/modules/commands/__init__.py: Added.
+        * Scripts/modules/commands/download.py: Added.
+        * Scripts/modules/commands/queries.py: Added.
+        * Scripts/modules/commands/queues.py: Added.
+        * Scripts/modules/commands/upload.py: Added.
+        * Scripts/modules/grammar.py: Added.
+
 2009-11-20  Adam Barth  <abarth@webkit.org>
 
         Reviewed by Eric Seidel.
index d057a5ea197e5f38d795c6c773bcfc2ae43c2047..275e1f190ae0c72c14e285949e9fcb29ca76aad7 100755 (executable)
 # A tool for automating dealing with bugzilla, posting patches, committing patches, etc.
 
 import os
-import re
-import StringIO # for add_patch_to_bug file wrappers
-import subprocess
-import sys
-import time
-
-from datetime import datetime, timedelta
-from optparse import make_option
-
-# Import WebKit-specific modules.
-from modules.comments import bug_comment_from_commit_text
-from modules.bugzilla import Bugzilla, parse_bug_id
-from modules.buildbot import BuildBot
-from modules.changelogs import ChangeLog
-from modules.landingsequence import LandingSequence, ConditionalLandingSequence
-from modules.logging import error, log, tee
-from modules.multicommandtool import MultiCommandTool, Command
-from modules.patchcollection import PatchCollection
-from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
-from modules.statusbot import StatusBot
-from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
-from modules.webkitport import WebKitPort
-from modules.workqueue import WorkQueue, WorkQueueDelegate
-
-def plural(noun):
-    # This is a dumb plural() implementation which was just enough for our uses.
-    if re.search("h$", noun):
-        return noun + "es"
-    else:
-        return noun + "s"
-
-def pluralize(noun, count):
-    if count != 1:
-        noun = plural(noun)
-    return "%d %s" % (count, noun)
-
-
-class BugsToCommit(Command):
-    name = "bugs-to-commit"
-    def __init__(self):
-        Command.__init__(self, "Bugs in the commit queue")
-
-    def execute(self, options, args, tool):
-        bug_ids = tool.bugs.fetch_bug_ids_from_commit_queue()
-        for bug_id in bug_ids:
-            print "%s" % bug_id
-
-
-class PatchesToCommit(Command):
-    name = "patches-to-commit"
-    def __init__(self):
-        Command.__init__(self, "Patches in the commit queue")
-
-    def execute(self, options, args, tool):
-        patches = tool.bugs.fetch_patches_from_commit_queue()
-        log("Patches in commit queue:")
-        for patch in patches:
-            print "%s" % patch["url"]
-
-
-class ReviewedPatches(Command):
-    name = "reviewed-patches"
-    def __init__(self):
-        Command.__init__(self, "r+'d patches on a bug", "BUGID")
-
-    def execute(self, options, args, tool):
-        bug_id = args[0]
-        patches_to_land = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
-        for patch in patches_to_land:
-            print "%s" % patch["url"]
-
-
-class CheckStyle(Command):
-    name = "check-style"
-    def __init__(self):
-        options = WebKitLandingScripts.cleaning_options()
-        Command.__init__(self, "Runs check-webkit-style on the specified attachment", "ATTACHMENT_ID", options=options)
-
-    @classmethod
-    def check_style(cls, patch, options, tool):
-        tool.scm().update_webkit()
-        log("Checking style for patch %s from bug %s." % (patch["id"], patch["bug_id"]))
-        try:
-            # FIXME: check-webkit-style shouldn't really have to apply the patch to check the style.
-            tool.scm().apply_patch(patch)
-            WebKitLandingScripts.run_webkit_script("check-webkit-style")
-        except ScriptError, e:
-            log("Patch %s from bug %s failed to apply and check style." % (patch["id"], patch["bug_id"]))
-            log(e.output)
-
-        # This is safe because in order to get here the working directory had to be
-        # clean at the beginning.  Clean it out again before we exit.
-        tool.scm().ensure_clean_working_directory(force_clean=True)
-
-    def execute(self, options, args, tool):
-        attachment_id = args[0]
-        attachment = tool.bugs.fetch_attachment(attachment_id)
-
-        WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
-        self.check_style(attachment, options, tool)
-
-
-class BuildSequence(ConditionalLandingSequence):
-    def __init__(self, options, tool):
-        ConditionalLandingSequence.__init__(self, None, options, tool)
-
-    def run(self):
-        self.clean()
-        self.update()
-        self.build()
-
-
-class Build(Command):
-    name = "build"
-    def __init__(self):
-        options = WebKitLandingScripts.cleaning_options()
-        options += WebKitLandingScripts.build_options()
-        options += WebKitLandingScripts.land_options()
-        Command.__init__(self, "Updates working copy and does a build.", "", options)
-
-    def execute(self, options, args, tool):
-        sequence = BuildSequence(options, tool)
-        sequence.run_and_handle_errors()
-
-
-class ApplyAttachment(Command):
-    name = "apply-attachment"
-    def __init__(self):
-        options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
-        Command.__init__(self, "Applies an attachment to the local working directory.", "ATTACHMENT_ID", options=options)
-
-    def execute(self, options, args, tool):
-        WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
-        attachment_id = args[0]
-        attachment = tool.bugs.fetch_attachment(attachment_id)
-        WebKitApplyingScripts.apply_patches_with_options(tool.scm(), [attachment], options)
-
-
-class ApplyPatches(Command):
-    name = "apply-patches"
-    def __init__(self):
-        options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
-        Command.__init__(self, "Applies all patches on a bug to the local working directory.", "BUGID", options=options)
-
-    def execute(self, options, args, tool):
-        WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
-        bug_id = args[0]
-        patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
-        WebKitApplyingScripts.apply_patches_with_options(tool.scm(), patches, options)
-
-
-class WebKitApplyingScripts:
-    @staticmethod
-    def apply_options():
-        return [
-            make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"),
-            make_option("--local-commit", action="store_true", dest="local_commit", default=False, help="Make a local commit for each applied patch"),
-        ]
-
-    @staticmethod
-    def setup_for_patch_apply(scm, options):
-        WebKitLandingScripts.prepare_clean_working_directory(scm, options, allow_local_commits=True)
-        if options.update:
-            scm.update_webkit()
-
-    @staticmethod
-    def apply_patches_with_options(scm, patches, options):
-        if options.local_commit and not scm.supports_local_commits():
-            error("--local-commit passed, but %s does not support local commits" % scm.display_name())
-
-        for patch in patches:
-            log("Applying attachment %s from bug %s" % (patch["id"], patch["bug_id"]))
-            scm.apply_patch(patch)
-            if options.local_commit:
-                commit_message = commit_message_for_this_commit(scm)
-                scm.commit_locally_with_message(commit_message.message() or patch["name"])
-
-
-class LandDiffSequence(ConditionalLandingSequence):
-    def __init__(self, patch, options, tool):
-        ConditionalLandingSequence.__init__(self, patch, options, tool)
-
-    def run(self):
-        self.build()
-        self.test()
-        commit_log = self.commit()
-        self.close_bug(commit_log)
-
-    def close_bug(self, commit_log):
-        comment_test = bug_comment_from_commit_text(self._tool.scm(), commit_log)
-        bug_id = self._patch["bug_id"]
-        if bug_id:
-            log("Updating bug %s" % bug_id)
-            if self._options.close_bug:
-                self._tool.bugs.close_bug_as_fixed(bug_id, comment_test)
-            else:
-                # FIXME: We should a smart way to figure out if the patch is attached
-                # to the bug, and if so obsolete it.
-                self._tool.bugs.post_comment_to_bug(bug_id, comment_test)
-        else:
-            log(comment_test)
-            log("No bug id provided.")
-
-
-class LandDiff(Command):
-    name = "land-diff"
-    def __init__(self):
-        options = [
-            make_option("-r", "--reviewer", action="store", type="string", dest="reviewer", help="Update ChangeLogs to say Reviewed by REVIEWER."),
-        ]
-        options += WebKitLandingScripts.build_options()
-        options += WebKitLandingScripts.land_options()
-        Command.__init__(self, "Lands the current working directory diff and updates the bug if provided.", "[BUGID]", options=options)
-
-    def guess_reviewer_from_bug(self, bugs, bug_id):
-        patches = bugs.fetch_reviewed_patches_from_bug(bug_id)
-        if len(patches) != 1:
-            log("%s on bug %s, cannot infer reviewer." % (pluralize("reviewed patch", len(patches)), bug_id))
-            return None
-        patch = patches[0]
-        reviewer = patch["reviewer"]
-        log("Guessing \"%s\" as reviewer from attachment %s on bug %s." % (reviewer, patch["id"], bug_id))
-        return reviewer
-
-    def update_changelogs_with_reviewer(self, reviewer, bug_id, tool):
-        if not reviewer:
-            if not bug_id:
-                log("No bug id provided and --reviewer= not provided.  Not updating ChangeLogs with reviewer.")
-                return
-            reviewer = self.guess_reviewer_from_bug(tool.bugs, bug_id)
-
-        if not reviewer:
-            log("Failed to guess reviewer from bug %s and --reviewer= not provided.  Not updating ChangeLogs with reviewer." % bug_id)
-            return
-
-        for changelog_path in tool.scm().modified_changelogs():
-            ChangeLog(changelog_path).set_reviewer(reviewer)
-
-    def execute(self, options, args, tool):
-        bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
-
-        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
-
-        os.chdir(tool.scm().checkout_root)
-        self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool)
-
-        fake_patch = {
-            "id": None,
-            "bug_id": bug_id
-        }
-
-        sequence = LandDiffSequence(fake_patch, options, tool)
-        sequence.run()
-
-
-class AbstractPatchProcessingCommand(Command):
-    def __init__(self, help_text, args_description, options):
-        Command.__init__(self, help_text, args_description, options=options)
-
-    def _fetch_list_of_patches_to_process(self, options, args, tool):
-        raise NotImplementedError, "subclasses must implement"
-
-    def _prepare_to_process(self, options, args, tool):
-        raise NotImplementedError, "subclasses must implement"
-
-    @staticmethod
-    def _collect_patches_by_bug(patches):
-        bugs_to_patches = {}
-        for patch in patches:
-            bug_id = patch["bug_id"]
-            bugs_to_patches[bug_id] = bugs_to_patches.get(bug_id, []).append(patch)
-        return bugs_to_patches
-
-    def execute(self, options, args, tool):
-        if not args:
-            error("%s required" % self.argument_names)
-
-        self._prepare_to_process(options, args, tool)
-        patches = self._fetch_list_of_patches_to_process(options, args, tool)
-
-        # It's nice to print out total statistics.
-        bugs_to_patches = self._collect_patches_by_bug(patches)
-        log("Processing %s from %s." % (pluralize("patch", len(patches)), pluralize("bug", len(bugs_to_patches))))
-
-        for patch in patches:
-            self._process_patch(patch, options, args, tool)
-
-
-class BuildAttachmentSequence(LandingSequence):
-    def __init__(self, patch, options, tool):
-        LandingSequence.__init__(self, patch, options, tool)
-
-    def run(self):
-        self.clean()
-        self.update()
-        self.apply_patch()
-        self.build()
-
-
-class BuildAttachment(AbstractPatchProcessingCommand):
-    name = "build-attachment"
-    def __init__(self):
-        options = WebKitLandingScripts.cleaning_options()
-        options += WebKitLandingScripts.build_options()
-        AbstractPatchProcessingCommand.__init__(self, "Builds patches from bugzilla", "ATTACHMENT_ID [ATTACHMENT_IDS]", options)
-
-    def _fetch_list_of_patches_to_process(self, options, args, tool):
-        return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
-
-    def _prepare_to_process(self, options, args, tool):
-        # Check the tree status first so we can fail early.
-        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
-
-    def _process_patch(self, patch, options, args, tool):
-        sequence = BuildAttachmentSequence(patch, options, tool)
-        sequence.run_and_handle_errors()
-
-
-class AbstractPatchLandingCommand(AbstractPatchProcessingCommand):
-    def __init__(self, help_text, args_description):
-        options = WebKitLandingScripts.cleaning_options()
-        options += WebKitLandingScripts.build_options()
-        options += WebKitLandingScripts.land_options()
-        AbstractPatchProcessingCommand.__init__(self, help_text, args_description, options)
-
-    def _prepare_to_process(self, options, args, tool):
-        # Check the tree status first so we can fail early.
-        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
-
-    def _process_patch(self, patch, options, args, tool):
-        sequence = ConditionalLandingSequence(patch, options, tool)
-        sequence.run_and_handle_errors()
-
-
-class LandAttachment(AbstractPatchLandingCommand):
-    name = "land-attachment"
-    def __init__(self):
-        AbstractPatchLandingCommand.__init__(self, "Lands patches from bugzilla, optionally building and testing them first", "ATTACHMENT_ID [ATTACHMENT_IDS]")
-
-    def _fetch_list_of_patches_to_process(self, options, args, tool):
-        return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
-
-
-class LandPatches(AbstractPatchLandingCommand):
-    name = "land-patches"
-    def __init__(self):
-        AbstractPatchLandingCommand.__init__(self, "Lands all patches on the given bugs, optionally building and testing them first", "BUGID [BUGIDS]")
-
-    def _fetch_list_of_patches_to_process(self, options, args, tool):
-        all_patches = []
-        for bug_id in args:
-            patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
-            log("%s found on bug %s." % (pluralize("reviewed patch", len(patches)), bug_id))
-            all_patches += patches
-        return all_patches
-
-
-class CommitMessageForCurrentDiff(Command):
-    name = "commit-message"
-    def __init__(self):
-        Command.__init__(self, "Prints a commit message suitable for the uncommitted changes.")
-
-    def execute(self, options, args, tool):
-        os.chdir(tool.scm().checkout_root)
-        print "%s" % commit_message_for_this_commit(tool.scm()).message()
-
-
-class ObsoleteAttachments(Command):
-    name = "obsolete-attachments"
-    def __init__(self):
-        Command.__init__(self, "Marks all attachments on a bug as obsolete.", "BUGID")
-
-    def execute(self, options, args, tool):
-        bug_id = args[0]
-        attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
-        for attachment in attachments:
-            if not attachment["is_obsolete"]:
-                tool.bugs.obsolete_attachment(attachment["id"])
-
-
-class PostDiff(Command):
-    name = "post-diff"
-    def __init__(self):
-        options = [
-            make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: \"patch\")"),
-        ]
-        options += self.posting_options()
-        Command.__init__(self, "Attaches the current working directory diff to a bug as a patch file.", "[BUGID]", options=options)
-
-    @staticmethod
-    def posting_options():
-        return [
-            make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."),
-            make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
-            make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
-        ]
-
-    @staticmethod
-    def obsolete_patches_on_bug(bug_id, bugs):
-        patches = bugs.fetch_patches_from_bug(bug_id)
-        if len(patches):
-            log("Obsoleting %s on bug %s" % (pluralize("old patch", len(patches)), bug_id))
-            for patch in patches:
-                bugs.obsolete_attachment(patch["id"])
-
-    def execute(self, options, args, tool):
-        # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
-        bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
-        if not bug_id:
-            error("No bug id passed and no bug url found in diff, can't post.")
-
-        if options.obsolete_patches:
-            self.obsolete_patches_on_bug(bug_id, tool.bugs)
-
-        diff = tool.scm().create_patch()
-        diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
-
-        description = options.description or "Patch"
-        tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
-
-
-class PostCommits(Command):
-    name = "post-commits"
-    def __init__(self):
-        options = [
-            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."),
-            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."),
-            make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
-        ]
-        options += PostDiff.posting_options()
-        Command.__init__(self, "Attaches a range of local commits to bugs as patch files.", "COMMITISH", options=options, requires_local_commits=True)
-
-    def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
-        comment_text = None
-        if (options.add_log_as_comment):
-            comment_text = commit_message.body(lstrip=True)
-            comment_text += "---\n"
-            comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
-        return comment_text
-
-    def _diff_file_for_commit(self, tool, commit_id):
-        diff = tool.scm().create_patch_from_local_commit(commit_id)
-        return StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
-
-    def execute(self, options, args, tool):
-        if not args:
-            error("%s argument is required" % self.argument_names)
-
-        commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
-        if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is.
-            error("bugzilla-tool does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids))))
-
-        have_obsoleted_patches = set()
-        for commit_id in commit_ids:
-            commit_message = tool.scm().commit_message_for_local_commit(commit_id)
-
-            # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
-            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))
-            if not bug_id:
-                log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
-                continue
-
-            if options.obsolete_patches and bug_id not in have_obsoleted_patches:
-                PostDiff.obsolete_patches_on_bug(bug_id, tool.bugs)
-                have_obsoleted_patches.add(bug_id)
-
-            diff_file = self._diff_file_for_commit(tool, commit_id)
-            description = options.description or commit_message.description(lstrip=True, strip_url=True)
-            comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
-            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)
-
-
-class Rollout(Command):
-    name = "rollout"
-    def __init__(self):
-        options = WebKitLandingScripts.cleaning_options()
-        options += WebKitLandingScripts.build_options()
-        options += WebKitLandingScripts.land_options()
-        options.append(make_option("--complete-rollout", action="store_true", dest="complete_rollout", help="Experimental support for complete unsupervised rollouts, including re-opening the bug.  Not recommended."))
-        Command.__init__(self, "Reverts the given revision and commits the revert and re-opens the original bug.", "REVISION [BUGID]", options=options)
-
-    @staticmethod
-    def _create_changelogs_for_revert(scm, revision):
-        # First, discard the ChangeLog changes from the rollout.
-        changelog_paths = scm.modified_changelogs()
-        scm.revert_files(changelog_paths)
-
-        # Second, make new ChangeLog entries for this rollout.
-        # This could move to prepare-ChangeLog by adding a --revert= option.
-        WebKitLandingScripts.run_webkit_script("prepare-ChangeLog")
-        for changelog_path in changelog_paths:
-            ChangeLog(changelog_path).update_for_revert(revision)
-
-    @staticmethod
-    def _parse_bug_id_from_revision_diff(tool, revision):
-        original_diff = tool.scm().diff_for_revision(revision)
-        return parse_bug_id(original_diff)
-
-    @staticmethod
-    def _reopen_bug_after_rollout(tool, bug_id, comment_text):
-        if bug_id:
-            tool.bugs.reopen_bug(bug_id, comment_text)
-        else:
-            log(comment_text)
-            log("No bugs were updated or re-opened to reflect this rollout.")
-
-    def execute(self, options, args, tool):
-        if not args:
-            error("REVISION is required, see --help.")
-        revision = args[0]
-        bug_id = self._parse_bug_id_from_revision_diff(tool, revision)
-        if options.complete_rollout:
-            if bug_id:
-                log("Will re-open bug %s after rollout." % bug_id)
-            else:
-                log("Failed to parse bug number from diff.  No bugs will be updated/reopened after the rollout.")
-
-        WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
-        tool.scm().update_webkit()
-        tool.scm().apply_reverse_diff(revision)
-        self._create_changelogs_for_revert(tool.scm(), revision)
-
-        # FIXME: Fully automated rollout is not 100% idiot-proof yet, so for now just log with instructions on how to complete the rollout.
-        # Once we trust rollout we will remove this option.
-        if not options.complete_rollout:
-            log("\nNOTE: Rollout support is experimental.\nPlease verify the rollout diff and use \"bugzilla-tool land-diff %s\" to commit the rollout." % bug_id)
-        else:
-            comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options)
-            self._reopen_bug_after_rollout(tool, bug_id, comment_text)
-
-
-class CreateBug(Command):
-    name = "create-bug"
-    def __init__(self):
-        options = [
-            make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."),
-            make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."),
-            make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
-            make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
-            make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
-        ]
-        Command.__init__(self, "Create a bug from local changes or local commits.", "[COMMITISH]", options=options)
-
-    def create_bug_from_commit(self, options, args, tool):
-        commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
-        if len(commit_ids) > 3:
-            error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
-
-        commit_id = commit_ids[0]
-
-        bug_title = ""
-        comment_text = ""
-        if options.prompt:
-            (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
-        else:
-            commit_message = tool.scm().commit_message_for_local_commit(commit_id)
-            bug_title = commit_message.description(lstrip=True, strip_url=True)
-            comment_text = commit_message.body(lstrip=True)
-            comment_text += "---\n"
-            comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
-
-        diff = tool.scm().create_patch_from_local_commit(commit_id)
-        diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
-        bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
-
-        if bug_id and len(commit_ids) > 1:
-            options.bug_id = bug_id
-            options.obsolete_patches = False
-            # FIXME: We should pass through --no-comment switch as well.
-            PostCommits.execute(self, options, commit_ids[1:], tool)
-
-    def create_bug_from_patch(self, options, args, tool):
-        bug_title = ""
-        comment_text = ""
-        if options.prompt:
-            (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
-        else:
-            commit_message = commit_message_for_this_commit(tool.scm())
-            bug_title = commit_message.description(lstrip=True, strip_url=True)
-            comment_text = commit_message.body(lstrip=True)
-
-        diff = tool.scm().create_patch()
-        diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
-        bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
-
-    def prompt_for_bug_title_and_comment(self):
-        bug_title = raw_input("Bug title: ")
-        print "Bug comment (hit ^D on blank line to end):"
-        lines = sys.stdin.readlines()
-        try:
-            sys.stdin.seek(0, os.SEEK_END)
-        except IOError:
-            # Cygwin raises an Illegal Seek (errno 29) exception when the above
-            # seek() call is made. Ignoring it seems to cause no harm.
-            # FIXME: Figure out a way to get avoid the exception in the first
-            # place.
-            pass
-        comment_text = "".join(lines)
-        return (bug_title, comment_text)
-
-    def execute(self, options, args, tool):
-        if len(args):
-            if (not tool.scm().supports_local_commits()):
-                error("Extra arguments not supported; patch is taken from working directory.")
-            self.create_bug_from_commit(options, args, tool)
-        else:
-            self.create_bug_from_patch(options, args, tool)
-
-
-class TreeStatus(Command):
-    name = "tree-status"
-    def __init__(self):
-        Command.__init__(self, "Print out the status of the webkit builders.")
-
-    def execute(self, options, args, tool):
-        for builder in tool.buildbot.builder_statuses():
-            status_string = "ok" if builder["is_green"] else "FAIL"
-            print "%s : %s" % (status_string.ljust(4), builder["name"])
-
-
-class AbstractQueue(Command, WorkQueueDelegate):
-    def __init__(self, options=[]):
-        options += [
-            make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
-            make_option("--status-host", action="store", type="string", dest="status_host", default=StatusBot.default_host, help="Hostname (e.g. localhost or commit.webkit.org) where status updates should be posted."),
-        ]
-        Command.__init__(self, "Run the %s." % self.name, options=options)
-
-    def queue_log_path(self):
-        return "%s.log" % self.name
-
-    def work_logs_directory(self):
-        return "%s-logs" % self.name
-
-    def status_host(self):
-        return self.options.status_host
-
-    def begin_work_queue(self):
-        log("CAUTION: %s will discard all local changes in %s" % (self.name, self.tool.scm().checkout_root))
-        if self.options.confirm:
-            response = raw_input("Are you sure?  Type \"yes\" to continue: ")
-            if (response != "yes"):
-                error("User declined.")
-        log("Running WebKit %s. %s" % (self.name, datetime.now().strftime(WorkQueue.log_date_format)))
-
-    def should_continue_work_queue(self):
-        return True
-
-    def next_work_item(self):
-        raise NotImplementedError, "subclasses must implement"
-
-    def should_proceed_with_work_item(self, work_item):
-        raise NotImplementedError, "subclasses must implement"
-
-    def process_work_item(self, work_item):
-        raise NotImplementedError, "subclasses must implement"
-
-    def handle_unexpected_error(self, work_item, message):
-        raise NotImplementedError, "subclasses must implement"
-
-    @staticmethod
-    def run_bugzilla_tool(args):
-        bugzilla_tool_path = __file__ # re-execute this script
-        bugzilla_tool_args = [bugzilla_tool_path] + args
-        WebKitLandingScripts.run_and_throw_if_fail(bugzilla_tool_args)
-
-    def log_progress(self, patch_ids):
-        log("%s in %s [%s]" % (pluralize("patch", len(patch_ids)), self.name, ", ".join(patch_ids)))
-
-    def execute(self, options, args, tool):
-        self.options = options
-        self.tool = tool
-        work_queue = WorkQueue(self)
-        work_queue.run()
-
-
-class CommitQueue(AbstractQueue):
-    name = "commit-queue"
-    def __init__(self):
-        AbstractQueue.__init__(self)
-
-    def begin_work_queue(self):
-        AbstractQueue.begin_work_queue(self)
-
-    def next_work_item(self):
-        patches = self.tool.bugs.fetch_patches_from_commit_queue(reject_invalid_patches=True)
-        if not patches:
-            return None
-        # Only bother logging if we have patches in the queue.
-        self.log_progress([patch['id'] for patch in patches])
-        return patches[0]
-
-    def should_proceed_with_work_item(self, patch):
-        red_builders_names = self.tool.buildbot.red_core_builders_names()
-        if red_builders_names:
-            red_builders_names = map(lambda name: "\"%s\"" % name, red_builders_names) # Add quotes around the names.
-            return (False, "Builders [%s] are red. See http://build.webkit.org." % ", ".join(red_builders_names), None)
-        return (True, "Landing patch %s from bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
-
-    def process_work_item(self, patch):
-        self.run_bugzilla_tool(["land-attachment", "--force-clean", "--non-interactive", "--quiet", patch["id"]])
-
-    def handle_unexpected_error(self, patch, message):
-        self.tool.bugs.reject_patch_from_commit_queue(patch["id"], message)
-
-
-class AbstractTryQueue(AbstractQueue):
-    def __init__(self, options=[]):
-        AbstractQueue.__init__(self, options)
-
-    def status_host(self):
-        return None # FIXME: A hack until we come up with a more generic status page.
-
-    def begin_work_queue(self):
-        AbstractQueue.begin_work_queue(self)
-        self._patches = PatchCollection(self.tool.bugs)
-        self._patches.add_patches(self.tool.bugs.fetch_patches_from_review_queue(limit=10))
-
-    def next_work_item(self):
-        self.log_progress(self._patches.patch_ids())
-        return self._patches.next()
-
-    def should_proceed_with_work_item(self, patch):
-        raise NotImplementedError, "subclasses must implement"
-
-    def process_work_item(self, patch):
-        raise NotImplementedError, "subclasses must implement"
-
-    def handle_unexpected_error(self, patch, message):
-        log(message)
-
-
-class StyleQueue(AbstractTryQueue):
-    name = "style-queue"
-    def __init__(self):
-        AbstractTryQueue.__init__(self)
-
-    def should_proceed_with_work_item(self, patch):
-        return (True, "Checking style for patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
-
-    def process_work_item(self, patch):
-        self.run_bugzilla_tool(["check-style", "--force-clean", patch["id"]])
-
-
-class BuildQueue(AbstractTryQueue):
-    name = "build-queue"
-    def __init__(self):
-        options = WebKitPort.port_options()
-        AbstractTryQueue.__init__(self, options)
-
-    def begin_work_queue(self):
-        AbstractTryQueue.begin_work_queue(self)
-        self.port = WebKitPort.port(self.options)
-
-    def should_proceed_with_work_item(self, patch):
-        try:
-            self.run_bugzilla_tool(["build", self.port.flag(), "--force-clean", "--quiet"])
-        except ScriptError, e:
-            return (False, "Unable to perform a build.", None)
-        return (True, "Building patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
-
-    def process_work_item(self, patch):
-        self.run_bugzilla_tool(["build-attachment", self.port.flag(), "--force-clean", "--quiet", "--no-update", patch["id"]])
 
+from modules.commands.download import *
+from modules.commands.queries import *
+from modules.commands.queues import *
+from modules.commands.upload import *
+from modules.logging import log
 
 class BugzillaTool(MultiCommandTool):
     def __init__(self):
diff --git a/WebKitTools/Scripts/modules/commands/__init__.py b/WebKitTools/Scripts/modules/commands/__init__.py
new file mode 100644 (file)
index 0000000..ef65bee
--- /dev/null
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/modules/commands/download.py b/WebKitTools/Scripts/modules/commands/download.py
new file mode 100644 (file)
index 0000000..d6ffdeb
--- /dev/null
@@ -0,0 +1,398 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# FIXME: Trim down this import list once we have unit tests.
+import os
+import re
+import StringIO
+import subprocess
+import sys
+import time
+
+from datetime import datetime, timedelta
+from optparse import make_option
+
+from modules.bugzilla import Bugzilla, parse_bug_id
+from modules.buildbot import BuildBot
+from modules.changelogs import ChangeLog
+from modules.comments import bug_comment_from_commit_text
+from modules.grammar import pluralize
+from modules.landingsequence import LandingSequence, ConditionalLandingSequence
+from modules.logging import error, log, tee
+from modules.multicommandtool import MultiCommandTool, Command
+from modules.patchcollection import PatchCollection
+from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
+from modules.statusbot import StatusBot
+from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
+from modules.webkitport import WebKitPort
+from modules.workqueue import WorkQueue, WorkQueueDelegate
+
+class CheckStyle(Command):
+    name = "check-style"
+    def __init__(self):
+        options = WebKitLandingScripts.cleaning_options()
+        Command.__init__(self, "Runs check-webkit-style on the specified attachment", "ATTACHMENT_ID", options=options)
+
+    @classmethod
+    def check_style(cls, patch, options, tool):
+        tool.scm().update_webkit()
+        log("Checking style for patch %s from bug %s." % (patch["id"], patch["bug_id"]))
+        try:
+            # FIXME: check-webkit-style shouldn't really have to apply the patch to check the style.
+            tool.scm().apply_patch(patch)
+            WebKitLandingScripts.run_webkit_script("check-webkit-style")
+        except ScriptError, e:
+            log("Patch %s from bug %s failed to apply and check style." % (patch["id"], patch["bug_id"]))
+            log(e.output)
+
+        # This is safe because in order to get here the working directory had to be
+        # clean at the beginning.  Clean it out again before we exit.
+        tool.scm().ensure_clean_working_directory(force_clean=True)
+
+    def execute(self, options, args, tool):
+        attachment_id = args[0]
+        attachment = tool.bugs.fetch_attachment(attachment_id)
+
+        WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
+        self.check_style(attachment, options, tool)
+
+
+class BuildSequence(ConditionalLandingSequence):
+    def __init__(self, options, tool):
+        ConditionalLandingSequence.__init__(self, None, options, tool)
+
+    def run(self):
+        self.clean()
+        self.update()
+        self.build()
+
+
+class Build(Command):
+    name = "build"
+    def __init__(self):
+        options = WebKitLandingScripts.cleaning_options()
+        options += WebKitLandingScripts.build_options()
+        options += WebKitLandingScripts.land_options()
+        Command.__init__(self, "Updates working copy and does a build.", "", options)
+
+    def execute(self, options, args, tool):
+        sequence = BuildSequence(options, tool)
+        sequence.run_and_handle_errors()
+
+
+class ApplyAttachment(Command):
+    name = "apply-attachment"
+    def __init__(self):
+        options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
+        Command.__init__(self, "Applies an attachment to the local working directory.", "ATTACHMENT_ID", options=options)
+
+    def execute(self, options, args, tool):
+        WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
+        attachment_id = args[0]
+        attachment = tool.bugs.fetch_attachment(attachment_id)
+        WebKitApplyingScripts.apply_patches_with_options(tool.scm(), [attachment], options)
+
+
+class ApplyPatches(Command):
+    name = "apply-patches"
+    def __init__(self):
+        options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
+        Command.__init__(self, "Applies all patches on a bug to the local working directory.", "BUGID", options=options)
+
+    def execute(self, options, args, tool):
+        WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
+        bug_id = args[0]
+        patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
+        WebKitApplyingScripts.apply_patches_with_options(tool.scm(), patches, options)
+
+
+class WebKitApplyingScripts:
+    @staticmethod
+    def apply_options():
+        return [
+            make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"),
+            make_option("--local-commit", action="store_true", dest="local_commit", default=False, help="Make a local commit for each applied patch"),
+        ]
+
+    @staticmethod
+    def setup_for_patch_apply(scm, options):
+        WebKitLandingScripts.prepare_clean_working_directory(scm, options, allow_local_commits=True)
+        if options.update:
+            scm.update_webkit()
+
+    @staticmethod
+    def apply_patches_with_options(scm, patches, options):
+        if options.local_commit and not scm.supports_local_commits():
+            error("--local-commit passed, but %s does not support local commits" % scm.display_name())
+
+        for patch in patches:
+            log("Applying attachment %s from bug %s" % (patch["id"], patch["bug_id"]))
+            scm.apply_patch(patch)
+            if options.local_commit:
+                commit_message = commit_message_for_this_commit(scm)
+                scm.commit_locally_with_message(commit_message.message() or patch["name"])
+
+
+class LandDiffSequence(ConditionalLandingSequence):
+    def __init__(self, patch, options, tool):
+        ConditionalLandingSequence.__init__(self, patch, options, tool)
+
+    def run(self):
+        self.build()
+        self.test()
+        commit_log = self.commit()
+        self.close_bug(commit_log)
+
+    def close_bug(self, commit_log):
+        comment_test = bug_comment_from_commit_text(self._tool.scm(), commit_log)
+        bug_id = self._patch["bug_id"]
+        if bug_id:
+            log("Updating bug %s" % bug_id)
+            if self._options.close_bug:
+                self._tool.bugs.close_bug_as_fixed(bug_id, comment_test)
+            else:
+                # FIXME: We should a smart way to figure out if the patch is attached
+                # to the bug, and if so obsolete it.
+                self._tool.bugs.post_comment_to_bug(bug_id, comment_test)
+        else:
+            log(comment_test)
+            log("No bug id provided.")
+
+
+class LandDiff(Command):
+    name = "land-diff"
+    def __init__(self):
+        options = [
+            make_option("-r", "--reviewer", action="store", type="string", dest="reviewer", help="Update ChangeLogs to say Reviewed by REVIEWER."),
+        ]
+        options += WebKitLandingScripts.build_options()
+        options += WebKitLandingScripts.land_options()
+        Command.__init__(self, "Lands the current working directory diff and updates the bug if provided.", "[BUGID]", options=options)
+
+    def guess_reviewer_from_bug(self, bugs, bug_id):
+        patches = bugs.fetch_reviewed_patches_from_bug(bug_id)
+        if len(patches) != 1:
+            log("%s on bug %s, cannot infer reviewer." % (pluralize("reviewed patch", len(patches)), bug_id))
+            return None
+        patch = patches[0]
+        reviewer = patch["reviewer"]
+        log("Guessing \"%s\" as reviewer from attachment %s on bug %s." % (reviewer, patch["id"], bug_id))
+        return reviewer
+
+    def update_changelogs_with_reviewer(self, reviewer, bug_id, tool):
+        if not reviewer:
+            if not bug_id:
+                log("No bug id provided and --reviewer= not provided.  Not updating ChangeLogs with reviewer.")
+                return
+            reviewer = self.guess_reviewer_from_bug(tool.bugs, bug_id)
+
+        if not reviewer:
+            log("Failed to guess reviewer from bug %s and --reviewer= not provided.  Not updating ChangeLogs with reviewer." % bug_id)
+            return
+
+        for changelog_path in tool.scm().modified_changelogs():
+            ChangeLog(changelog_path).set_reviewer(reviewer)
+
+    def execute(self, options, args, tool):
+        bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
+
+        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
+
+        os.chdir(tool.scm().checkout_root)
+        self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool)
+
+        fake_patch = {
+            "id": None,
+            "bug_id": bug_id
+        }
+
+        sequence = LandDiffSequence(fake_patch, options, tool)
+        sequence.run()
+
+
+class AbstractPatchProcessingCommand(Command):
+    def __init__(self, help_text, args_description, options):
+        Command.__init__(self, help_text, args_description, options=options)
+
+    def _fetch_list_of_patches_to_process(self, options, args, tool):
+        raise NotImplementedError, "subclasses must implement"
+
+    def _prepare_to_process(self, options, args, tool):
+        raise NotImplementedError, "subclasses must implement"
+
+    @staticmethod
+    def _collect_patches_by_bug(patches):
+        bugs_to_patches = {}
+        for patch in patches:
+            bug_id = patch["bug_id"]
+            bugs_to_patches[bug_id] = bugs_to_patches.get(bug_id, []).append(patch)
+        return bugs_to_patches
+
+    def execute(self, options, args, tool):
+        if not args:
+            error("%s required" % self.argument_names)
+
+        self._prepare_to_process(options, args, tool)
+        patches = self._fetch_list_of_patches_to_process(options, args, tool)
+
+        # It's nice to print out total statistics.
+        bugs_to_patches = self._collect_patches_by_bug(patches)
+        log("Processing %s from %s." % (pluralize("patch", len(patches)), pluralize("bug", len(bugs_to_patches))))
+
+        for patch in patches:
+            self._process_patch(patch, options, args, tool)
+
+
+class BuildAttachmentSequence(LandingSequence):
+    def __init__(self, patch, options, tool):
+        LandingSequence.__init__(self, patch, options, tool)
+
+    def run(self):
+        self.clean()
+        self.update()
+        self.apply_patch()
+        self.build()
+
+
+class BuildAttachment(AbstractPatchProcessingCommand):
+    name = "build-attachment"
+    def __init__(self):
+        options = WebKitLandingScripts.cleaning_options()
+        options += WebKitLandingScripts.build_options()
+        AbstractPatchProcessingCommand.__init__(self, "Builds patches from bugzilla", "ATTACHMENT_ID [ATTACHMENT_IDS]", options)
+
+    def _fetch_list_of_patches_to_process(self, options, args, tool):
+        return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
+
+    def _prepare_to_process(self, options, args, tool):
+        # Check the tree status first so we can fail early.
+        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
+
+    def _process_patch(self, patch, options, args, tool):
+        sequence = BuildAttachmentSequence(patch, options, tool)
+        sequence.run_and_handle_errors()
+
+
+class AbstractPatchLandingCommand(AbstractPatchProcessingCommand):
+    def __init__(self, help_text, args_description):
+        options = WebKitLandingScripts.cleaning_options()
+        options += WebKitLandingScripts.build_options()
+        options += WebKitLandingScripts.land_options()
+        AbstractPatchProcessingCommand.__init__(self, help_text, args_description, options)
+
+    def _prepare_to_process(self, options, args, tool):
+        # Check the tree status first so we can fail early.
+        WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
+
+    def _process_patch(self, patch, options, args, tool):
+        sequence = ConditionalLandingSequence(patch, options, tool)
+        sequence.run_and_handle_errors()
+
+
+class LandAttachment(AbstractPatchLandingCommand):
+    name = "land-attachment"
+    def __init__(self):
+        AbstractPatchLandingCommand.__init__(self, "Lands patches from bugzilla, optionally building and testing them first", "ATTACHMENT_ID [ATTACHMENT_IDS]")
+
+    def _fetch_list_of_patches_to_process(self, options, args, tool):
+        return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
+
+
+class LandPatches(AbstractPatchLandingCommand):
+    name = "land-patches"
+    def __init__(self):
+        AbstractPatchLandingCommand.__init__(self, "Lands all patches on the given bugs, optionally building and testing them first", "BUGID [BUGIDS]")
+
+    def _fetch_list_of_patches_to_process(self, options, args, tool):
+        all_patches = []
+        for bug_id in args:
+            patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
+            log("%s found on bug %s." % (pluralize("reviewed patch", len(patches)), bug_id))
+            all_patches += patches
+        return all_patches
+
+
+class Rollout(Command):
+    name = "rollout"
+    def __init__(self):
+        options = WebKitLandingScripts.cleaning_options()
+        options += WebKitLandingScripts.build_options()
+        options += WebKitLandingScripts.land_options()
+        options.append(make_option("--complete-rollout", action="store_true", dest="complete_rollout", help="Experimental support for complete unsupervised rollouts, including re-opening the bug.  Not recommended."))
+        Command.__init__(self, "Reverts the given revision and commits the revert and re-opens the original bug.", "REVISION [BUGID]", options=options)
+
+    @staticmethod
+    def _create_changelogs_for_revert(scm, revision):
+        # First, discard the ChangeLog changes from the rollout.
+        changelog_paths = scm.modified_changelogs()
+        scm.revert_files(changelog_paths)
+
+        # Second, make new ChangeLog entries for this rollout.
+        # This could move to prepare-ChangeLog by adding a --revert= option.
+        WebKitLandingScripts.run_webkit_script("prepare-ChangeLog")
+        for changelog_path in changelog_paths:
+            ChangeLog(changelog_path).update_for_revert(revision)
+
+    @staticmethod
+    def _parse_bug_id_from_revision_diff(tool, revision):
+        original_diff = tool.scm().diff_for_revision(revision)
+        return parse_bug_id(original_diff)
+
+    @staticmethod
+    def _reopen_bug_after_rollout(tool, bug_id, comment_text):
+        if bug_id:
+            tool.bugs.reopen_bug(bug_id, comment_text)
+        else:
+            log(comment_text)
+            log("No bugs were updated or re-opened to reflect this rollout.")
+
+    def execute(self, options, args, tool):
+        if not args:
+            error("REVISION is required, see --help.")
+        revision = args[0]
+        bug_id = self._parse_bug_id_from_revision_diff(tool, revision)
+        if options.complete_rollout:
+            if bug_id:
+                log("Will re-open bug %s after rollout." % bug_id)
+            else:
+                log("Failed to parse bug number from diff.  No bugs will be updated/reopened after the rollout.")
+
+        WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
+        tool.scm().update_webkit()
+        tool.scm().apply_reverse_diff(revision)
+        self._create_changelogs_for_revert(tool.scm(), revision)
+
+        # FIXME: Fully automated rollout is not 100% idiot-proof yet, so for now just log with instructions on how to complete the rollout.
+        # Once we trust rollout we will remove this option.
+        if not options.complete_rollout:
+            log("\nNOTE: Rollout support is experimental.\nPlease verify the rollout diff and use \"bugzilla-tool land-diff %s\" to commit the rollout." % bug_id)
+        else:
+            comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options)
+            self._reopen_bug_after_rollout(tool, bug_id, comment_text)
diff --git a/WebKitTools/Scripts/modules/commands/queries.py b/WebKitTools/Scripts/modules/commands/queries.py
new file mode 100644 (file)
index 0000000..78cac6d
--- /dev/null
@@ -0,0 +1,100 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# FIXME: Trim down this import list once we have unit tests.
+import os
+import re
+import StringIO
+import subprocess
+import sys
+import time
+
+from datetime import datetime, timedelta
+from optparse import make_option
+
+from modules.bugzilla import Bugzilla, parse_bug_id
+from modules.buildbot import BuildBot
+from modules.changelogs import ChangeLog
+from modules.comments import bug_comment_from_commit_text
+from modules.grammar import pluralize
+from modules.landingsequence import LandingSequence, ConditionalLandingSequence
+from modules.logging import error, log, tee
+from modules.multicommandtool import MultiCommandTool, Command
+from modules.patchcollection import PatchCollection
+from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
+from modules.statusbot import StatusBot
+from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
+from modules.webkitport import WebKitPort
+from modules.workqueue import WorkQueue, WorkQueueDelegate
+
+class BugsToCommit(Command):
+    name = "bugs-to-commit"
+    def __init__(self):
+        Command.__init__(self, "Bugs in the commit queue")
+
+    def execute(self, options, args, tool):
+        bug_ids = tool.bugs.fetch_bug_ids_from_commit_queue()
+        for bug_id in bug_ids:
+            print "%s" % bug_id
+
+
+class PatchesToCommit(Command):
+    name = "patches-to-commit"
+    def __init__(self):
+        Command.__init__(self, "Patches in the commit queue")
+
+    def execute(self, options, args, tool):
+        patches = tool.bugs.fetch_patches_from_commit_queue()
+        log("Patches in commit queue:")
+        for patch in patches:
+            print "%s" % patch["url"]
+
+
+class ReviewedPatches(Command):
+    name = "reviewed-patches"
+    def __init__(self):
+        Command.__init__(self, "r+'d patches on a bug", "BUGID")
+
+    def execute(self, options, args, tool):
+        bug_id = args[0]
+        patches_to_land = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
+        for patch in patches_to_land:
+            print "%s" % patch["url"]
+
+
+class TreeStatus(Command):
+    name = "tree-status"
+    def __init__(self):
+        Command.__init__(self, "Print out the status of the webkit builders.")
+
+    def execute(self, options, args, tool):
+        for builder in tool.buildbot.builder_statuses():
+            status_string = "ok" if builder["is_green"] else "FAIL"
+            print "%s : %s" % (status_string.ljust(4), builder["name"])
diff --git a/WebKitTools/Scripts/modules/commands/queues.py b/WebKitTools/Scripts/modules/commands/queues.py
new file mode 100644 (file)
index 0000000..cbd4f8f
--- /dev/null
@@ -0,0 +1,199 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# FIXME: Trim down this import list once we have unit tests.
+import os
+import re
+import StringIO
+import subprocess
+import sys
+import time
+
+from datetime import datetime, timedelta
+from optparse import make_option
+
+from modules.bugzilla import Bugzilla, parse_bug_id
+from modules.buildbot import BuildBot
+from modules.changelogs import ChangeLog
+from modules.comments import bug_comment_from_commit_text
+from modules.grammar import pluralize
+from modules.landingsequence import LandingSequence, ConditionalLandingSequence
+from modules.logging import error, log, tee
+from modules.multicommandtool import MultiCommandTool, Command
+from modules.patchcollection import PatchCollection
+from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
+from modules.statusbot import StatusBot
+from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
+from modules.webkitport import WebKitPort
+from modules.workqueue import WorkQueue, WorkQueueDelegate
+
+class AbstractQueue(Command, WorkQueueDelegate):
+    def __init__(self, options=[]):
+        options += [
+            make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
+            make_option("--status-host", action="store", type="string", dest="status_host", default=StatusBot.default_host, help="Hostname (e.g. localhost or commit.webkit.org) where status updates should be posted."),
+        ]
+        Command.__init__(self, "Run the %s." % self.name, options=options)
+
+    def queue_log_path(self):
+        return "%s.log" % self.name
+
+    def work_logs_directory(self):
+        return "%s-logs" % self.name
+
+    def status_host(self):
+        return self.options.status_host
+
+    def begin_work_queue(self):
+        log("CAUTION: %s will discard all local changes in %s" % (self.name, self.tool.scm().checkout_root))
+        if self.options.confirm:
+            response = raw_input("Are you sure?  Type \"yes\" to continue: ")
+            if (response != "yes"):
+                error("User declined.")
+        log("Running WebKit %s. %s" % (self.name, datetime.now().strftime(WorkQueue.log_date_format)))
+
+    def should_continue_work_queue(self):
+        return True
+
+    def next_work_item(self):
+        raise NotImplementedError, "subclasses must implement"
+
+    def should_proceed_with_work_item(self, work_item):
+        raise NotImplementedError, "subclasses must implement"
+
+    def process_work_item(self, work_item):
+        raise NotImplementedError, "subclasses must implement"
+
+    def handle_unexpected_error(self, work_item, message):
+        raise NotImplementedError, "subclasses must implement"
+
+    @staticmethod
+    def run_bugzilla_tool(args):
+        bugzilla_tool_path = __file__ # re-execute this script
+        bugzilla_tool_args = [bugzilla_tool_path] + args
+        WebKitLandingScripts.run_and_throw_if_fail(bugzilla_tool_args)
+
+    def log_progress(self, patch_ids):
+        log("%s in %s [%s]" % (pluralize("patch", len(patch_ids)), self.name, ", ".join(patch_ids)))
+
+    def execute(self, options, args, tool):
+        self.options = options
+        self.tool = tool
+        work_queue = WorkQueue(self)
+        work_queue.run()
+
+
+class CommitQueue(AbstractQueue):
+    name = "commit-queue"
+    def __init__(self):
+        AbstractQueue.__init__(self)
+
+    def begin_work_queue(self):
+        AbstractQueue.begin_work_queue(self)
+
+    def next_work_item(self):
+        patches = self.tool.bugs.fetch_patches_from_commit_queue(reject_invalid_patches=True)
+        if not patches:
+            return None
+        # Only bother logging if we have patches in the queue.
+        self.log_progress([patch['id'] for patch in patches])
+        return patches[0]
+
+    def should_proceed_with_work_item(self, patch):
+        red_builders_names = self.tool.buildbot.red_core_builders_names()
+        if red_builders_names:
+            red_builders_names = map(lambda name: "\"%s\"" % name, red_builders_names) # Add quotes around the names.
+            return (False, "Builders [%s] are red. See http://build.webkit.org." % ", ".join(red_builders_names), None)
+        return (True, "Landing patch %s from bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
+
+    def process_work_item(self, patch):
+        self.run_bugzilla_tool(["land-attachment", "--force-clean", "--non-interactive", "--quiet", patch["id"]])
+
+    def handle_unexpected_error(self, patch, message):
+        self.tool.bugs.reject_patch_from_commit_queue(patch["id"], message)
+
+
+class AbstractTryQueue(AbstractQueue):
+    def __init__(self, options=[]):
+        AbstractQueue.__init__(self, options)
+
+    def status_host(self):
+        return None # FIXME: A hack until we come up with a more generic status page.
+
+    def begin_work_queue(self):
+        AbstractQueue.begin_work_queue(self)
+        self._patches = PatchCollection(self.tool.bugs)
+        self._patches.add_patches(self.tool.bugs.fetch_patches_from_review_queue(limit=10))
+
+    def next_work_item(self):
+        self.log_progress(self._patches.patch_ids())
+        return self._patches.next()
+
+    def should_proceed_with_work_item(self, patch):
+        raise NotImplementedError, "subclasses must implement"
+
+    def process_work_item(self, patch):
+        raise NotImplementedError, "subclasses must implement"
+
+    def handle_unexpected_error(self, patch, message):
+        log(message)
+
+
+class StyleQueue(AbstractTryQueue):
+    name = "style-queue"
+    def __init__(self):
+        AbstractTryQueue.__init__(self)
+
+    def should_proceed_with_work_item(self, patch):
+        return (True, "Checking style for patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
+
+    def process_work_item(self, patch):
+        self.run_bugzilla_tool(["check-style", "--force-clean", patch["id"]])
+
+
+class BuildQueue(AbstractTryQueue):
+    name = "build-queue"
+    def __init__(self):
+        options = WebKitPort.port_options()
+        AbstractTryQueue.__init__(self, options)
+
+    def begin_work_queue(self):
+        AbstractTryQueue.begin_work_queue(self)
+        self.port = WebKitPort.port(self.options)
+
+    def should_proceed_with_work_item(self, patch):
+        try:
+            self.run_bugzilla_tool(["build", self.port.flag(), "--force-clean", "--quiet"])
+        except ScriptError, e:
+            return (False, "Unable to perform a build.", None)
+        return (True, "Building patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
+
+    def process_work_item(self, patch):
+        self.run_bugzilla_tool(["build-attachment", self.port.flag(), "--force-clean", "--quiet", "--no-update", patch["id"]])
diff --git a/WebKitTools/Scripts/modules/commands/upload.py b/WebKitTools/Scripts/modules/commands/upload.py
new file mode 100644 (file)
index 0000000..42e23d3
--- /dev/null
@@ -0,0 +1,247 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# FIXME: Trim down this import list once we have unit tests.
+import os
+import re
+import StringIO
+import subprocess
+import sys
+import time
+
+from datetime import datetime, timedelta
+from optparse import make_option
+
+from modules.bugzilla import Bugzilla, parse_bug_id
+from modules.buildbot import BuildBot
+from modules.changelogs import ChangeLog
+from modules.comments import bug_comment_from_commit_text
+from modules.grammar import pluralize
+from modules.landingsequence import LandingSequence, ConditionalLandingSequence
+from modules.logging import error, log, tee
+from modules.multicommandtool import MultiCommandTool, Command
+from modules.patchcollection import PatchCollection
+from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
+from modules.statusbot import StatusBot
+from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
+from modules.webkitport import WebKitPort
+from modules.workqueue import WorkQueue, WorkQueueDelegate
+
+class CommitMessageForCurrentDiff(Command):
+    name = "commit-message"
+    def __init__(self):
+        Command.__init__(self, "Prints a commit message suitable for the uncommitted changes.")
+
+    def execute(self, options, args, tool):
+        os.chdir(tool.scm().checkout_root)
+        print "%s" % commit_message_for_this_commit(tool.scm()).message()
+
+
+class ObsoleteAttachments(Command):
+    name = "obsolete-attachments"
+    def __init__(self):
+        Command.__init__(self, "Marks all attachments on a bug as obsolete.", "BUGID")
+
+    def execute(self, options, args, tool):
+        bug_id = args[0]
+        attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
+        for attachment in attachments:
+            if not attachment["is_obsolete"]:
+                tool.bugs.obsolete_attachment(attachment["id"])
+
+
+class PostDiff(Command):
+    name = "post-diff"
+    def __init__(self):
+        options = [
+            make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: \"patch\")"),
+        ]
+        options += self.posting_options()
+        Command.__init__(self, "Attaches the current working directory diff to a bug as a patch file.", "[BUGID]", options=options)
+
+    @staticmethod
+    def posting_options():
+        return [
+            make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."),
+            make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
+            make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
+        ]
+
+    @staticmethod
+    def obsolete_patches_on_bug(bug_id, bugs):
+        patches = bugs.fetch_patches_from_bug(bug_id)
+        if len(patches):
+            log("Obsoleting %s on bug %s" % (pluralize("old patch", len(patches)), bug_id))
+            for patch in patches:
+                bugs.obsolete_attachment(patch["id"])
+
+    def execute(self, options, args, tool):
+        # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
+        bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
+        if not bug_id:
+            error("No bug id passed and no bug url found in diff, can't post.")
+
+        if options.obsolete_patches:
+            self.obsolete_patches_on_bug(bug_id, tool.bugs)
+
+        diff = tool.scm().create_patch()
+        diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
+
+        description = options.description or "Patch"
+        tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
+
+
+class PostCommits(Command):
+    name = "post-commits"
+    def __init__(self):
+        options = [
+            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."),
+            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."),
+            make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
+        ]
+        options += PostDiff.posting_options()
+        Command.__init__(self, "Attaches a range of local commits to bugs as patch files.", "COMMITISH", options=options, requires_local_commits=True)
+
+    def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
+        comment_text = None
+        if (options.add_log_as_comment):
+            comment_text = commit_message.body(lstrip=True)
+            comment_text += "---\n"
+            comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
+        return comment_text
+
+    def _diff_file_for_commit(self, tool, commit_id):
+        diff = tool.scm().create_patch_from_local_commit(commit_id)
+        return StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
+
+    def execute(self, options, args, tool):
+        if not args:
+            error("%s argument is required" % self.argument_names)
+
+        commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
+        if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is.
+            error("bugzilla-tool does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids))))
+
+        have_obsoleted_patches = set()
+        for commit_id in commit_ids:
+            commit_message = tool.scm().commit_message_for_local_commit(commit_id)
+
+            # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
+            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))
+            if not bug_id:
+                log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
+                continue
+
+            if options.obsolete_patches and bug_id not in have_obsoleted_patches:
+                PostDiff.obsolete_patches_on_bug(bug_id, tool.bugs)
+                have_obsoleted_patches.add(bug_id)
+
+            diff_file = self._diff_file_for_commit(tool, commit_id)
+            description = options.description or commit_message.description(lstrip=True, strip_url=True)
+            comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
+            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)
+
+
+class CreateBug(Command):
+    name = "create-bug"
+    def __init__(self):
+        options = [
+            make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."),
+            make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."),
+            make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
+            make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
+            make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
+        ]
+        Command.__init__(self, "Create a bug from local changes or local commits.", "[COMMITISH]", options=options)
+
+    def create_bug_from_commit(self, options, args, tool):
+        commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
+        if len(commit_ids) > 3:
+            error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
+
+        commit_id = commit_ids[0]
+
+        bug_title = ""
+        comment_text = ""
+        if options.prompt:
+            (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
+        else:
+            commit_message = tool.scm().commit_message_for_local_commit(commit_id)
+            bug_title = commit_message.description(lstrip=True, strip_url=True)
+            comment_text = commit_message.body(lstrip=True)
+            comment_text += "---\n"
+            comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
+
+        diff = tool.scm().create_patch_from_local_commit(commit_id)
+        diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
+        bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
+
+        if bug_id and len(commit_ids) > 1:
+            options.bug_id = bug_id
+            options.obsolete_patches = False
+            # FIXME: We should pass through --no-comment switch as well.
+            PostCommits.execute(self, options, commit_ids[1:], tool)
+
+    def create_bug_from_patch(self, options, args, tool):
+        bug_title = ""
+        comment_text = ""
+        if options.prompt:
+            (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
+        else:
+            commit_message = commit_message_for_this_commit(tool.scm())
+            bug_title = commit_message.description(lstrip=True, strip_url=True)
+            comment_text = commit_message.body(lstrip=True)
+
+        diff = tool.scm().create_patch()
+        diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
+        bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
+
+    def prompt_for_bug_title_and_comment(self):
+        bug_title = raw_input("Bug title: ")
+        print "Bug comment (hit ^D on blank line to end):"
+        lines = sys.stdin.readlines()
+        try:
+            sys.stdin.seek(0, os.SEEK_END)
+        except IOError:
+            # Cygwin raises an Illegal Seek (errno 29) exception when the above
+            # seek() call is made. Ignoring it seems to cause no harm.
+            # FIXME: Figure out a way to get avoid the exception in the first
+            # place.
+            pass
+        comment_text = "".join(lines)
+        return (bug_title, comment_text)
+
+    def execute(self, options, args, tool):
+        if len(args):
+            if (not tool.scm().supports_local_commits()):
+                error("Extra arguments not supported; patch is taken from working directory.")
+            self.create_bug_from_commit(options, args, tool)
+        else:
+            self.create_bug_from_patch(options, args, tool)
diff --git a/WebKitTools/Scripts/modules/grammar.py b/WebKitTools/Scripts/modules/grammar.py
new file mode 100644 (file)
index 0000000..dd2967a
--- /dev/null
@@ -0,0 +1,43 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import re
+
+def plural(noun):
+    # This is a dumb plural() implementation which was just enough for our uses.
+    if re.search("h$", noun):
+        return noun + "es"
+    else:
+        return noun + "s"
+
+def pluralize(noun, count):
+    if count != 1:
+        noun = plural(noun)
+    return "%d %s" % (count, noun)