diff options
Diffstat (limited to 'WebKitTools/Scripts/bugzilla-tool')
-rwxr-xr-x | WebKitTools/Scripts/bugzilla-tool | 695 |
1 files changed, 695 insertions, 0 deletions
diff --git a/WebKitTools/Scripts/bugzilla-tool b/WebKitTools/Scripts/bugzilla-tool new file mode 100755 index 0000000..b3c0d67 --- /dev/null +++ b/WebKitTools/Scripts/bugzilla-tool @@ -0,0 +1,695 @@ +#!/usr/bin/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. +# +# A tool for automating dealing with bugzilla, posting patches, committing patches, etc. + +import fileinput # inplace file editing for set_reviewer_in_changelog +import os +import re +import StringIO # for add_patch_to_bug file wrappers +import subprocess +import sys + +from optparse import OptionParser, IndentedHelpFormatter, SUPPRESS_USAGE, make_option + +# Import WebKit-specific modules. +from modules.bugzilla import Bugzilla +from modules.logging import error, log +from modules.scm import CommitMessage, detect_scm_system, ScriptError + +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) + +# These could be put in some sort of changelogs.py. +def latest_changelog_entry(changelog_path): + # e.g. 2009-06-03 Eric Seidel <eric@webkit.org> + changelog_date_line_regexp = re.compile('^(\d{4}-\d{2}-\d{2})' # Consume the date. + + '\s+(.+)\s+' # Consume the name. + + '<([^<>]+)>$') # And finally the email address. + + entry_lines = [] + changelog = open(changelog_path) + try: + log("Parsing ChangeLog: " + changelog_path) + # The first line should be a date line. + first_line = changelog.readline() + if not changelog_date_line_regexp.match(first_line): + return None + entry_lines.append(first_line) + + for line in changelog: + # If we've hit the next entry, return. + if changelog_date_line_regexp.match(line): + return ''.join(entry_lines) + entry_lines.append(line) + finally: + changelog.close() + # We never found a date line! + return None + +def set_reviewer_in_changelog(changelog_path, reviewer): + # inplace=1 creates a backup file and re-directs stdout to the file + for line in fileinput.FileInput(changelog_path, inplace=1): + print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")), # Trailing comma suppresses printing newline + +def modified_changelogs(scm): + changelog_paths = [] + paths = scm.changed_files() + for path in paths: + if os.path.basename(path) == "ChangeLog": + changelog_paths.append(path) + return changelog_paths + +def parse_bug_id(commit_message): + message = commit_message.message() + match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) + if match: + return match.group('bug_id') + match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", message) + if match: + return match.group('bug_id') + return None + +def commit_message_for_this_commit(scm): + changelog_paths = modified_changelogs(scm) + if not len(changelog_paths): + raise ScriptError("Found no modified ChangeLogs, cannot create a commit message.\n" + "All changes require a ChangeLog. See:\n" + "http://webkit.org/coding/contributing.html") + + changelog_messages = [] + for path in changelog_paths: + changelog_entry = latest_changelog_entry(path) + if not changelog_entry: + error("Failed to parse ChangeLog: " + os.path.abspath(path)) + changelog_messages.append(changelog_entry) + + # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does. + return CommitMessage(''.join(changelog_messages).splitlines()) + + +class Command: + def __init__(self, help_text, argument_names="", options=[], requires_local_commits=False): + self.help_text = help_text + self.argument_names = argument_names + self.options = options + self.option_parser = HelpPrintingOptionParser(usage=SUPPRESS_USAGE, add_help_option=False, option_list=self.options) + self.requires_local_commits = requires_local_commits + + def name_with_arguments(self, command_name): + usage_string = command_name + if len(self.options) > 0: + usage_string += " [options]" + if self.argument_names: + usage_string += " " + self.argument_names + return usage_string + + def parse_args(self, args): + return self.option_parser.parse_args(args) + + def execute(self, options, args, tool): + raise NotImplementedError, "subclasses must implement" + + +class BugsInCommitQueue(Command): + 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 PatchesInCommitQueue(Command): + 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 ReviewedPatchesOnBug(Command): + 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 ApplyPatchesFromBug(Command): + def __init__(self): + options = [ + make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"), + make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"), + make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"), + make_option("--local-commit", action="store_true", dest="local_commit", default=False, help="Make a local commit for each applied patch"), + ] + Command.__init__(self, 'Applies all patches on a bug to the local working directory without committing.', 'BUGID', options=options) + + @staticmethod + def apply_patches(patches, scm, commit_each): + for patch in patches: + scm.apply_patch(patch) + if commit_each: + commit_message = commit_message_for_this_commit(scm) + scm.commit_locally_with_message(commit_message.message() or patch['name']) + + def execute(self, options, args, tool): + bug_id = args[0] + patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) + os.chdir(tool.scm().checkout_root) + if options.clean: + tool.scm().ensure_clean_working_directory(options.force_clean) + if options.update: + tool.scm().update_webkit() + + if options.local_commit and not tool.scm().supports_local_commits(): + error("--local-commit passed, but %s does not support local commits" % tool.scm().display_name()) + + self.apply_patches(patches, tool.scm(), options.local_commit) + + +def bug_comment_from_commit_text(scm, commit_text): + match = re.search(scm.commit_success_regexp(), commit_text, re.MULTILINE) + svn_revision = match.group('svn_revision') + commit_text += ("\nhttp://trac.webkit.org/changeset/%s" % svn_revision) + return commit_text + + +class LandAndUpdateBug(Command): + def __init__(self): + options = [ + make_option("-r", "--reviewer", action="store", type="string", dest="reviewer", help="Update ChangeLogs to say Reviewed by REVIEWER."), + make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), + make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), + make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), + make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), + make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), + ] + 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 + + changelogs = modified_changelogs(tool.scm()) + for changelog in changelogs: + set_reviewer_in_changelog(changelog, reviewer) + + def execute(self, options, args, tool): + bug_id = args[0] if len(args) else None + os.chdir(tool.scm().checkout_root) + + self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool) + + comment_text = LandPatchesFromBugs.build_and_commit(tool.scm(), options) + if bug_id: + log("Updating bug %s" % bug_id) + if options.close_bug: + tool.bugs.close_bug_as_fixed(bug_id, comment_text) + else: + # FIXME: We should a smart way to figure out if the patch is attached + # to the bug, and if so obsolete it. + tool.bugs.post_comment_to_bug(bug_id, comment_text) + else: + log(comment_text) + log("No bug id provided.") + + +class LandPatchesFromBugs(Command): + def __init__(self): + options = [ + make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"), + make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"), + make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), + make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), + make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), + make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), + make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), + ] + Command.__init__(self, 'Lands all patches on a bug optionally testing them first', 'BUGID', options=options) + + @staticmethod + def run_and_throw_if_fail(args, quiet=False): + child_stdout = subprocess.PIPE if quiet else None + child_process = subprocess.Popen(args, stdout=child_stdout) + if child_process.stdout: + child_process.communicate() + return_code = child_process.wait() + if return_code: + raise ScriptError("%s failed with exit code %d" % (" ".join(args), return_code)) + + # We might need to pass scm into this function for scm.checkout_root + @staticmethod + def webkit_script_path(script_name): + return os.path.join("WebKitTools", "Scripts", script_name) + + @classmethod + def run_webkit_script(cls, script_name, quiet=False): + print "Running WebKit Script " + script_name + cls.run_and_throw_if_fail(cls.webkit_script_path(script_name), quiet) + + @classmethod + def build_webkit(cls, quiet=False): + cls.run_webkit_script("build-webkit", quiet) + + @classmethod + def run_webkit_tests(cls, launch_safari, quiet=False): + args = [cls.webkit_script_path("run-webkit-tests")] + if not launch_safari: + args.append("--no-launch-safari") + if quiet: + args.append("--quiet") + cls.run_and_throw_if_fail(args) + + @staticmethod + def setup_for_landing(scm, options): + os.chdir(scm.checkout_root) + scm.ensure_no_local_commits(options.force_clean) + if options.clean: + scm.ensure_clean_working_directory(options.force_clean) + + @classmethod + def build_and_commit(cls, scm, options): + if options.build: + cls.build_webkit(quiet=options.quiet) + if options.test: + cls.run_webkit_tests(launch_safari=not options.commit_queue, quiet=options.quiet) + commit_message = commit_message_for_this_commit(scm) + commit_log = scm.commit_with_message(commit_message.message()) + return bug_comment_from_commit_text(scm, commit_log) + + @classmethod + def land_patches(cls, bug_id, patches, options, tool): + try: + comment_text = "" + for patch in patches: + tool.scm().update_webkit() # Update before every patch in case the tree has changed + tool.scm().apply_patch(patch, force=options.commit_queue) + comment_text = cls.build_and_commit(tool.scm(), options) + tool.bugs.clear_attachment_review_flag(patch['id'], comment_text) + + if options.close_bug: + tool.bugs.close_bug_as_fixed(bug_id, "All reviewed patches have been landed. Closing bug.") + except ScriptError, e: + # We should add a comment to the bug, and r- the patch on failure + error(e) + + def execute(self, options, args, tool): + if not len(args): + error("bug-id(s) required") + + bugs_to_patches = {} + patch_count = 0 + for bug_id in args: + patches = [] + if options.commit_queue: + patches = tool.bugs.fetch_commit_queue_patches_from_bug(bug_id) + else: + patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) + if not len(patches): + log("No reviewed patches found on %s." % bug_id) + continue + patch_count += len(patches) + bugs_to_patches[bug_id] = patches + + log("Landing %s from %s." % (pluralize("patch", patch_count), pluralize("bug", len(args)))) + + self.setup_for_landing(tool.scm(), options) + + for bug_id in bugs_to_patches.keys(): + self.land_patches(bug_id, bugs_to_patches[bug_id], options, tool) + + +class CommitMessageForCurrentDiff(Command): + 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 ObsoleteAttachmentsOnBug(Command): + 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 PostDiffAsPatchToBug(Command): + def __init__(self): + options = [ + 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("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"), + ] + Command.__init__(self, 'Attaches the current working directory diff to a bug as a patch file.', 'BUGID', options=options) + + @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): + bug_id = args[0] + + 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 v1" + tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review) + + +class PostCommitsAsPatchesToBug(Command): + 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("--no-comment", action="store_false", dest="comment", default=True, help="Do not use commit log message as a comment for the patch."), + make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting new ones."), + make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), + make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"), + ] + Command.__init__(self, 'Attaches a range of local commits to bugs as patch files.', 'COMMITISH', options=options, requires_local_commits=True) + + 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: + error("Are you sure you want to attach %s patches?" % (pluralize('patch', len(commit_ids)))) + # Could add a --patches-limit option. + + have_obsoleted_patches = set() + for commit_id in commit_ids: + # FIXME: commit_message is the wrong place to look for the bug_id + # the ChangeLogs should have the bug id, but the local commit message might not. + commit_message = tool.scm().commit_message_for_local_commit(commit_id) + + bug_id = options.bug_id or parse_bug_id(commit_message) + if not bug_id: + log("Skipping %s: No bug id found in commit log or specified with --bug-id." % commit_id) + continue + + if options.obsolete_patches and bug_id not in have_obsoleted_patches: + PostDiffAsPatchToBug.obsolete_patches_on_bug(bug_id, tool.bugs) + have_obsoleted_patches.add(bug_id) + + description = options.description or commit_message.description(lstrip=True, strip_url=True) + comment_text = None + if (options.comment): + 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) # add_patch_to_bug expects a file-like object + tool.bugs.add_patch_to_bug(bug_id, diff_file, description, comment_text, mark_for_review=options.review) + + +class CreateBug(Command): + 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."), + ] + 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 v1", cc=options.cc, mark_for_review=options.review) + + 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. + PostCommitsAsPatchesToBug.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 v1", cc=options.cc, mark_for_review=options.review) + + 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() + sys.stdin.seek(0, os.SEEK_END) + 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 NonWrappingEpilogIndentedHelpFormatter(IndentedHelpFormatter): + def __init__(self): + IndentedHelpFormatter.__init__(self) + + # The standard IndentedHelpFormatter paragraph-wraps the epilog, killing our custom formatting. + def format_epilog(self, epilog): + if epilog: + return "\n" + epilog + "\n" + return "" + + +class HelpPrintingOptionParser(OptionParser): + def error(self, msg): + self.print_usage(sys.stderr) + error_message = "%s: error: %s\n" % (self.get_prog_name(), msg) + error_message += "\nType '" + self.get_prog_name() + " --help' to see usage.\n" + self.exit(2, error_message) + + +class BugzillaTool: + def __init__(self): + self.cached_scm = None + self.bugs = Bugzilla() + self.commands = [ + { 'name' : 'bugs-to-commit', 'object' : BugsInCommitQueue() }, + { 'name' : 'patches-to-commit', 'object' : PatchesInCommitQueue() }, + { 'name' : 'reviewed-patches', 'object' : ReviewedPatchesOnBug() }, + { 'name' : 'create-bug', 'object' : CreateBug() }, + { 'name' : 'apply-patches', 'object' : ApplyPatchesFromBug() }, + { 'name' : 'land-diff', 'object' : LandAndUpdateBug() }, + { 'name' : 'land-patches', 'object' : LandPatchesFromBugs() }, + { 'name' : 'commit-message', 'object' : CommitMessageForCurrentDiff() }, + { 'name' : 'obsolete-attachments', 'object' : ObsoleteAttachmentsOnBug() }, + { 'name' : 'post-diff', 'object' : PostDiffAsPatchToBug() }, + { 'name' : 'post-commits', 'object' : PostCommitsAsPatchesToBug() }, + ] + + self.global_option_parser = HelpPrintingOptionParser(usage=self.usage_line(), formatter=NonWrappingEpilogIndentedHelpFormatter(), epilog=self.commands_usage()) + self.global_option_parser.add_option("--dry-run", action="store_true", dest="dryrun", help="do not touch remote servers", default=False) + + def scm(self): + # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands). + original_cwd = os.path.abspath('.') + if not self.cached_scm: + self.cached_scm = detect_scm_system(original_cwd) + + if not self.cached_scm: + script_directory = os.path.abspath(sys.path[0]) + webkit_directory = os.path.abspath(os.path.join(script_directory, "../..")) + self.cached_scm = detect_scm_system(webkit_directory) + if self.cached_scm: + log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory)) + else: + error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory)) + + return self.cached_scm + + @staticmethod + def usage_line(): + return "Usage: %prog [options] command [command-options] [command-arguments]" + + def commands_usage(self): + commands_text = "Commands:\n" + longest_name_length = 0 + command_rows = [] + scm_supports_local_commits = self.scm().supports_local_commits() + for command in self.commands: + command_object = command['object'] + if command_object.requires_local_commits and not scm_supports_local_commits: + continue + command_name_and_args = command_object.name_with_arguments(command['name']) + command_rows.append({ 'name-and-args': command_name_and_args, 'object': command_object }) + longest_name_length = max([longest_name_length, len(command_name_and_args)]) + + # Use our own help formatter so as to indent enough. + formatter = IndentedHelpFormatter() + formatter.indent() + formatter.indent() + + for row in command_rows: + command_object = row['object'] + commands_text += " " + row['name-and-args'].ljust(longest_name_length + 3) + command_object.help_text + "\n" + commands_text += command_object.option_parser.format_option_help(formatter) + return commands_text + + def handle_global_args(self, args): + (options, args) = self.global_option_parser.parse_args(args) + if len(args): + # We'll never hit this because split_args splits at the first arg without a leading '-' + self.global_option_parser.error("Extra arguments before command: " + args) + + if options.dryrun: + self.scm().dryrun = True + self.bugs.dryrun = True + + @staticmethod + def split_args(args): + # Assume the first argument which doesn't start with '-' is the command name. + command_index = 0 + for arg in args: + if arg[0] != '-': + break + command_index += 1 + else: + return (args[:], None, []) + + global_args = args[:command_index] + command = args[command_index] + command_args = args[command_index + 1:] + return (global_args, command, command_args) + + def command_by_name(self, command_name): + for command in self.commands: + if command_name == command['name']: + return command + return None + + def main(self): + (global_args, command_name, args_after_command_name) = self.split_args(sys.argv[1:]) + + # Handle --help, etc: + self.handle_global_args(global_args) + + if not command_name: + self.global_option_parser.error("No command specified") + + command = self.command_by_name(command_name) + if not command: + self.global_option_parser.error(command_name + " is not a recognized command") + + command_object = command['object'] + + if command_object.requires_local_commits and not self.scm().supports_local_commits(): + error(command_name + " requires local commits using %s in %s." % (self.scm().display_name(), self.scm().checkout_root)) + + (command_options, command_args) = command_object.parse_args(args_after_command_name) + return command_object.execute(command_options, command_args, self) + + +def main(): + tool = BugzillaTool() + return tool.main() + +if __name__ == "__main__": + main() |