blob: 33d40ac70e16472f24cca84fa027cd0389ca3e9d [file] [log] [blame]
#!/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 <eric@webkit.org>
changelog_date_line_regexp = re.compile('^(\d{4}-\d{2}-\d{2})' # Consume the date.
+ '\s+(.+)\s+' # Consume the name.
+ '<([^<>]+)>$') # And finally the email address.
entry_lines = []
changelog = open(changelog_path)
try:
log("Parsing ChangeLog: " + changelog_path)
# The first line should be a date line.
first_line = changelog.readline()
if not changelog_date_line_regexp.match(first_line):
return None
entry_lines.append(first_line)
for line in changelog:
# If we've hit the next entry, return.
if changelog_date_line_regexp.match(line):
return ''.join(entry_lines)
entry_lines.append(line)
finally:
changelog.close()
# We never found a date line!
return None
def set_reviewer_in_changelog(changelog_path, reviewer):
# inplace=1 creates a backup file and re-directs stdout to the file
for line in fileinput.FileInput(changelog_path, inplace=1):
print line.replace("NOBODY (OOPS!)", reviewer),
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<bug_id>\d+)", commit_message, re.MULTILINE)
if match:
return match.group('bug_id')
match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\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()