From 34c415c2ff9eafdea1ae287b4b706c729f5e1860 Mon Sep 17 00:00:00 2001 From: "abarth@webkit.org" Date: Sat, 21 Nov 2009 00:15:23 +0000 Subject: [PATCH] 2009-11-20 Adam Barth 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 | 17 + WebKitTools/Scripts/bugzilla-tool | 768 +----------------- .../Scripts/modules/commands/__init__.py | 1 + .../Scripts/modules/commands/download.py | 398 +++++++++ .../Scripts/modules/commands/queries.py | 100 +++ .../Scripts/modules/commands/queues.py | 199 +++++ .../Scripts/modules/commands/upload.py | 247 ++++++ WebKitTools/Scripts/modules/grammar.py | 43 + 8 files changed, 1010 insertions(+), 763 deletions(-) create mode 100644 WebKitTools/Scripts/modules/commands/__init__.py create mode 100644 WebKitTools/Scripts/modules/commands/download.py create mode 100644 WebKitTools/Scripts/modules/commands/queries.py create mode 100644 WebKitTools/Scripts/modules/commands/queues.py create mode 100644 WebKitTools/Scripts/modules/commands/upload.py create mode 100644 WebKitTools/Scripts/modules/grammar.py diff --git a/WebKitTools/ChangeLog b/WebKitTools/ChangeLog index d50ed832c5d0..271532dbf6a6 100644 --- a/WebKitTools/ChangeLog +++ b/WebKitTools/ChangeLog @@ -1,3 +1,20 @@ +2009-11-20 Adam Barth + + 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 Reviewed by Eric Seidel. diff --git a/WebKitTools/Scripts/bugzilla-tool b/WebKitTools/Scripts/bugzilla-tool index d057a5ea197e..275e1f190ae0 100755 --- a/WebKitTools/Scripts/bugzilla-tool +++ b/WebKitTools/Scripts/bugzilla-tool @@ -31,770 +31,12 @@ # 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 index 000000000000..ef65bee5bb77 --- /dev/null +++ b/WebKitTools/Scripts/modules/commands/__init__.py @@ -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 index 000000000000..d6ffdeb9eec0 --- /dev/null +++ b/WebKitTools/Scripts/modules/commands/download.py @@ -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 index 000000000000..78cac6d5749c --- /dev/null +++ b/WebKitTools/Scripts/modules/commands/queries.py @@ -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 index 000000000000..cbd4f8fc655a --- /dev/null +++ b/WebKitTools/Scripts/modules/commands/queues.py @@ -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 index 000000000000..42e23d3ab9c9 --- /dev/null +++ b/WebKitTools/Scripts/modules/commands/upload.py @@ -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 index 000000000000..dd2967a98025 --- /dev/null +++ b/WebKitTools/Scripts/modules/grammar.py @@ -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) -- 2.36.0