#!/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.scm import detect_scm_system, ScriptError def log(string): print >> sys.stderr, string def error(string): log(string) exit(1) 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 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), 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 bug_id_from_commit_message(commit_message): match = re.search("http\://webkit\.org/b/(?P\d+)", commit_message, re.MULTILINE) if match: return match.group('bug_id') match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P\d+)", commit_message, re.MULTILINE) 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): error("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 ''.join(changelog_messages) class Command: def __init__(self, help_text, argument_names="", options=[]): 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) 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" % tool.bugs.bug_url_for_bug_id(bug_id) class PatchesInCommitQueue(Command): def __init__(self): Command.__init__(self, 'Patches attached to bugs 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 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."), ] 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-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."), ] Command.__init__(self, 'Lands all patches on a bug optionally testing them first', 'BUGID', options=options) @staticmethod def run_and_throw_if_fail(script_name): build_webkit_process = subprocess.Popen(script_name) return_code = build_webkit_process.wait() if return_code: raise ScriptError("%s failed with exit code %d" % (script_name, return_code)) @classmethod def run_webkit_script(cls, script_name): # We might need to pass scm into this function for scm.checkout_root cls.run_and_throw_if_fail(os.path.join("WebKitTools", "Scripts", script_name)) @classmethod def build_webkit(cls): cls.run_webkit_script("build-webkit") @classmethod def run_webkit_tests(cls): cls.run_webkit_script("run-webkit-tests") @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() if options.test: cls.run_webkit_tests() commit_message = commit_message_for_this_commit(scm) commit_log = scm.commit_with_message(commit_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) comment_text = cls.build_and_commit(tool.scm(), options) # If we're commiting more than one patch, update the bug as we go. if len(patches) > 1: tool.bugs.obsolete_attachment(patch['id'], comment_text) if len(patches) > 1: comment_text = "All reviewed patches landed, closing." tool.bugs.close_bug_as_fixed(bug_id, comment_text) 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 = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) if not len(patches): exit("No reviewed patches found on %s" % bug_id) 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 args: 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()) 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" 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."), ] Command.__init__(self, 'Attaches a range of local commits to bugs as patch files.', 'COMMITISH', options=options) def execute(self, options, args, tool): if not tool.scm().supports_local_commits(): error(tool.scm().display_name() + " does not support local commits.") commit_ids = tool.scm().commit_ids_from_range_arguments(args, cherry_pick=True) 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: commit_message = tool.scm().commit_message_for_commit(commit_id) commit_lines = commit_message.splitlines() bug_id = options.bug_id or bug_id_from_commit_message(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.update(bug_id) description = commit_lines[0] comment_text = None if (options.comment): comment_text = "\n".join(commit_lines[1:]) comment_text += "\n---\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 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' : '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 = [] for command in self.commands: command_object = command['object'] 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'] (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()