diff options
Diffstat (limited to 'WebKitTools/Scripts/bugzilla-tool')
-rwxr-xr-x | WebKitTools/Scripts/bugzilla-tool | 555 |
1 files changed, 381 insertions, 174 deletions
diff --git a/WebKitTools/Scripts/bugzilla-tool b/WebKitTools/Scripts/bugzilla-tool index b3c0d67..ec5aa0d 100755 --- a/WebKitTools/Scripts/bugzilla-tool +++ b/WebKitTools/Scripts/bugzilla-tool @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python # Copyright (c) 2009, Google Inc. All rights reserved. # Copyright (c) 2009 Apple Inc. All rights reserved. # @@ -30,19 +30,24 @@ # # 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 +import time +from datetime import datetime, timedelta from optparse import OptionParser, IndentedHelpFormatter, SUPPRESS_USAGE, make_option # Import WebKit-specific modules. -from modules.bugzilla import Bugzilla -from modules.logging import error, log -from modules.scm import CommitMessage, detect_scm_system, ScriptError +from modules.bugzilla import Bugzilla, parse_bug_id +from modules.changelogs import ChangeLog +from modules.comments import bug_comment_from_commit_text +from modules.logging import error, log, tee +from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate +from modules.buildbot import BuildBot +from modules.statusbot import StatusBot def plural(noun): # This is a dumb plural() implementation which was just enough for our uses. @@ -56,70 +61,21 @@ def pluralize(noun, count): noun = plural(noun) return "%d %s" % (count, noun) -# These could be put in some sort of changelogs.py. -def latest_changelog_entry(changelog_path): - # e.g. 2009-06-03 Eric Seidel <eric@webkit.org> - changelog_date_line_regexp = re.compile('^(\d{4}-\d{2}-\d{2})' # Consume the date. - + '\s+(.+)\s+' # Consume the name. - + '<([^<>]+)>$') # And finally the email address. - - entry_lines = [] - changelog = open(changelog_path) - try: - log("Parsing ChangeLog: " + changelog_path) - # The first line should be a date line. - first_line = changelog.readline() - if not changelog_date_line_regexp.match(first_line): - return None - entry_lines.append(first_line) - - for line in changelog: - # If we've hit the next entry, return. - if changelog_date_line_regexp.match(line): - return ''.join(entry_lines) - entry_lines.append(line) - finally: - changelog.close() - # We never found a date line! - return None - -def set_reviewer_in_changelog(changelog_path, reviewer): - # inplace=1 creates a backup file and re-directs stdout to the file - for line in fileinput.FileInput(changelog_path, inplace=1): - print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")), # Trailing comma suppresses printing newline - -def modified_changelogs(scm): - changelog_paths = [] - paths = scm.changed_files() - for path in paths: - if os.path.basename(path) == "ChangeLog": - changelog_paths.append(path) - return changelog_paths - -def parse_bug_id(commit_message): - message = commit_message.message() - match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) - if match: - return match.group('bug_id') - match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", message) - if match: - return match.group('bug_id') - return None - def commit_message_for_this_commit(scm): - changelog_paths = modified_changelogs(scm) + changelog_paths = scm.modified_changelogs() if not len(changelog_paths): - raise ScriptError("Found no modified ChangeLogs, cannot create a commit message.\n" + raise ScriptError(message="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) + for changelog_path in changelog_paths: + log("Parsing ChangeLog: %s" % changelog_path) + changelog_entry = ChangeLog(changelog_path).latest_entry() if not changelog_entry: - error("Failed to parse ChangeLog: " + os.path.abspath(path)) + error("Failed to parse ChangeLog: " + os.path.abspath(changelog_path)) changelog_messages.append(changelog_entry) - + # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does. return CommitMessage(''.join(changelog_messages).splitlines()) @@ -183,10 +139,9 @@ 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"), ] + options += WebKitLandingScripts.cleaning_options() Command.__init__(self, 'Applies all patches on a bug to the local working directory without committing.', 'BUGID', options=options) @staticmethod @@ -212,23 +167,110 @@ class ApplyPatchesFromBug(Command): 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 WebKitLandingScripts: + @staticmethod + def cleaning_options(): + return [ + 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"), + ] -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."), + @staticmethod + def land_options(): + return [ + make_option("--ignore-builders", action="store_false", dest="check_builders", default=True, help="Don't check to see if the build.webkit.org builders are green before landing."), make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), ] + + @staticmethod + def run_command_with_teed_output(args, teed_output): + child_process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + # Use our own custom wait loop because Popen ignores a tee'd stderr/stdout. + # FIXME: This could be improved not to flatten output to stdout. + while True: + output_line = child_process.stdout.readline() + if output_line == '' and child_process.poll() != None: + return child_process.poll() + teed_output.write(output_line) + + @staticmethod + def run_and_throw_if_fail(args, quiet=False): + # Cache the child's output locally so it can be used for error reports. + child_out_file = StringIO.StringIO() + if quiet: + dev_null = open(os.devnull, "w") + child_stdout = tee(child_out_file, dev_null if quiet else sys.stdout) + exit_code = WebKitLandingScripts.run_command_with_teed_output(args, child_stdout) + if quiet: + dev_null.close() + + child_output = child_out_file.getvalue() + child_out_file.close() + + if exit_code: + raise ScriptError(script_args=args, exit_code=exit_code, output=child_output) + + # We might need to pass scm into this function for scm.checkout_root + @staticmethod + def webkit_script_path(script_name): + return os.path.join("WebKitTools", "Scripts", script_name) + + @classmethod + def run_webkit_script(cls, script_name, quiet=False): + log("Running %s" % script_name) + cls.run_and_throw_if_fail(cls.webkit_script_path(script_name), quiet) + + @classmethod + def build_webkit(cls, quiet=False): + cls.run_webkit_script("build-webkit", quiet) + + @staticmethod + def ensure_builders_are_green(buildbot, options): + if not options.check_builders or buildbot.core_builders_are_green(): + return + error("Builders at %s are red, please do not commit. Pass --ignore-builders to bypass this check." % (buildbot.buildbot_host)) + + @classmethod + def run_webkit_tests(cls, launch_safari, fail_fast=False, quiet=False): + args = [cls.webkit_script_path("run-webkit-tests")] + if not launch_safari: + args.append("--no-launch-safari") + if quiet: + args.append("--quiet") + if fail_fast: + args.append("--exit-after-n-failures=1") + cls.run_and_throw_if_fail(args) + + @staticmethod + def setup_for_landing(scm, options): + os.chdir(scm.checkout_root) + scm.ensure_no_local_commits(options.force_clean) + if options.clean: + scm.ensure_clean_working_directory(options.force_clean) + + @classmethod + def build_and_commit(cls, scm, options): + if options.build: + cls.build_webkit(quiet=options.quiet) + if options.test: + # When running the commit-queue we don't want to launch Safari and we want to exit after the first failure. + cls.run_webkit_tests(launch_safari=not options.commit_queue, fail_fast=options.commit_queue, quiet=options.quiet) + commit_message = commit_message_for_this_commit(scm) + commit_log = scm.commit_with_message(commit_message.message()) + return bug_comment_from_commit_text(scm, commit_log) + + +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."), + ] + 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): @@ -252,17 +294,18 @@ class LandAndUpdateBug(Command): 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) + for changelog_path in tool.scm().modified_changelogs(): + ChangeLog(changelog_path).set_reviewer(reviewer) def execute(self, options, args, tool): bug_id = args[0] if len(args) else None os.chdir(tool.scm().checkout_root) + WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options) + self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool) - comment_text = LandPatchesFromBugs.build_and_commit(tool.scm(), options) + comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options) if bug_id: log("Updating bug %s" % bug_id) if options.close_bug: @@ -278,104 +321,66 @@ class LandAndUpdateBug(Command): class LandPatchesFromBugs(Command): def __init__(self): - options = [ - make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"), - make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"), - make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), - make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), - make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), - make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), - make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), - ] + options = WebKitLandingScripts.cleaning_options() + options += WebKitLandingScripts.land_options() Command.__init__(self, 'Lands all patches on a bug optionally testing them first', 'BUGID', options=options) - @staticmethod - def run_and_throw_if_fail(args, quiet=False): - child_stdout = subprocess.PIPE if quiet else None - child_process = subprocess.Popen(args, stdout=child_stdout) - if child_process.stdout: - child_process.communicate() - return_code = child_process.wait() - if return_code: - raise ScriptError("%s failed with exit code %d" % (" ".join(args), return_code)) - - # We might need to pass scm into this function for scm.checkout_root - @staticmethod - def webkit_script_path(script_name): - return os.path.join("WebKitTools", "Scripts", script_name) - - @classmethod - def run_webkit_script(cls, script_name, quiet=False): - print "Running WebKit Script " + script_name - cls.run_and_throw_if_fail(cls.webkit_script_path(script_name), quiet) - - @classmethod - def build_webkit(cls, quiet=False): - cls.run_webkit_script("build-webkit", quiet) - - @classmethod - def run_webkit_tests(cls, launch_safari, quiet=False): - args = [cls.webkit_script_path("run-webkit-tests")] - if not launch_safari: - args.append("--no-launch-safari") - if quiet: - args.append("--quiet") - cls.run_and_throw_if_fail(args) - - @staticmethod - def setup_for_landing(scm, options): - os.chdir(scm.checkout_root) - scm.ensure_no_local_commits(options.force_clean) - if options.clean: - scm.ensure_clean_working_directory(options.force_clean) - - @classmethod - def build_and_commit(cls, scm, options): - if options.build: - cls.build_webkit(quiet=options.quiet) - if options.test: - cls.run_webkit_tests(launch_safari=not options.commit_queue, quiet=options.quiet) - commit_message = commit_message_for_this_commit(scm) - commit_log = scm.commit_with_message(commit_message.message()) - return bug_comment_from_commit_text(scm, commit_log) - @classmethod def land_patches(cls, bug_id, patches, options, tool): try: comment_text = "" for patch in patches: tool.scm().update_webkit() # Update before every patch in case the tree has changed + log("Applying %s from bug %s." % (patch['id'], bug_id)) tool.scm().apply_patch(patch, force=options.commit_queue) - comment_text = cls.build_and_commit(tool.scm(), options) - tool.bugs.clear_attachment_review_flag(patch['id'], comment_text) + # Make sure the tree is still green after updating, before building this patch. + # The first patch ends up checking tree status twice, but that's OK. + WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options) + comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options) + tool.bugs.clear_attachment_flags(patch['id'], comment_text) if options.close_bug: tool.bugs.close_bug_as_fixed(bug_id, "All reviewed patches have been landed. Closing bug.") + except CheckoutNeedsUpdate, e: + log("Commit was rejected because the checkout is out of date. Please update and try again.") + log("You can pass --no-build to skip building/testing after update if you believe the new commits did not affect the results.") + error(e) except ScriptError, e: - # We should add a comment to the bug, and r- the patch on failure + # Mark the patch as commit-queue- and comment in the bug. + tool.bugs.reject_patch_from_commit_queue(patch['id'], e.message_with_output()) error(e) - def execute(self, options, args, tool): - if not len(args): - error("bug-id(s) required") - + @staticmethod + def _fetch_list_of_patches_to_land(options, args, tool): bugs_to_patches = {} patch_count = 0 for bug_id in args: patches = [] if options.commit_queue: - patches = tool.bugs.fetch_commit_queue_patches_from_bug(bug_id) + patches = tool.bugs.fetch_commit_queue_patches_from_bug(bug_id, reject_invalid_patches=True) else: patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) - if not len(patches): - log("No reviewed patches found on %s." % bug_id) - continue - patch_count += len(patches) - bugs_to_patches[bug_id] = patches + + patches_found = len(patches) + log("%s found on bug %s." % (pluralize("reviewed patch", patches_found), bug_id)) + + patch_count += patches_found + if patches_found: + bugs_to_patches[bug_id] = patches log("Landing %s from %s." % (pluralize("patch", patch_count), pluralize("bug", len(args)))) + return bugs_to_patches + + def execute(self, options, args, tool): + if not len(args): + error("bug-id(s) required") - self.setup_for_landing(tool.scm(), options) + # Check the tree status here so we can fail early + WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options) + + bugs_to_patches = self._fetch_list_of_patches_to_land(options, args, tool) + + WebKitLandingScripts.setup_for_landing(tool.scm(), options) for bug_id in bugs_to_patches.keys(): self.land_patches(bug_id, bugs_to_patches[bug_id], options, tool) @@ -405,11 +410,17 @@ class ObsoleteAttachmentsOnBug(Command): class PostDiffAsPatchToBug(Command): 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("-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): @@ -420,7 +431,10 @@ class PostDiffAsPatchToBug(Command): bugs.obsolete_attachment(patch['id']) def execute(self, options, args, tool): - bug_id = args[0] + # 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) @@ -436,49 +450,109 @@ class PostCommitsAsPatchesToBug(Command): def __init__(self): options = [ make_option("-b", "--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."), - make_option("--no-comment", action="store_false", dest="comment", default=True, help="Do not use commit log message as a comment for the patch."), - make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting new ones."), - make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), - make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"), + 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 += PostDiffAsPatchToBug.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: - error("Are you sure you want to attach %s patches?" % (pluralize('patch', len(commit_ids)))) - # Could add a --patches-limit option. + 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: - # FIXME: commit_message is the wrong place to look for the bug_id - # the ChangeLogs should have the bug id, but the local commit message might not. commit_message = tool.scm().commit_message_for_local_commit(commit_id) - bug_id = options.bug_id or parse_bug_id(commit_message) + # 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 log or specified with --bug-id." % commit_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: PostDiffAsPatchToBug.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 = None - if (options.comment): - comment_text = commit_message.body(lstrip=True) - comment_text += "---\n" - comment_text += tool.scm().files_changed_summary_for_commit(commit_id) - - diff = tool.scm().create_patch_from_local_commit(commit_id) - diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object + 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) +class RolloutCommit(Command): + def __init__(self): + options = WebKitLandingScripts.land_options() + options += WebKitLandingScripts.cleaning_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.setup_for_landing(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): def __init__(self): options = [ @@ -533,9 +607,18 @@ class CreateBug(Command): def prompt_for_bug_title_and_comment(self): bug_title = raw_input("Bug title: ") - print("Bug comment (hit ^D on blank line to end):") + print "Bug comment (hit ^D on blank line to end):" lines = sys.stdin.readlines() - sys.stdin.seek(0, os.SEEK_END) + 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 + else: + raise comment_text = ''.join(lines) return (bug_title, comment_text) @@ -548,6 +631,126 @@ class CreateBug(Command): self.create_bug_from_patch(options, args, tool) +class CheckTreeStatus(Command): + 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 LandPatchesFromCommitQueue(Command): + def __init__(self): + 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="Do not ask the user for confirmation before running the queue. Dangerous!"), + ] + Command.__init__(self, 'Run the commit queue.', options=options) + self._original_stdout = None + self._original_stderr = None + self._files_for_output = [] + + queue_log_path = 'commit_queue.log' + bug_logs_directory = 'commit_queue_logs' + + log_date_format = "%Y-%m-%d %H:%M:%S" + sleep_duration_text = "5 mins" + seconds_to_sleep = 300 + + def _tee_outputs_to_files(self, files): + if not self._original_stdout: + self._original_stdout = sys.stdout + self._original_stderr = sys.stderr + if files and len(files): + sys.stdout = tee(self._original_stdout, *files) + sys.stderr = tee(self._original_stderr, *files) + else: + sys.stdout = self._original_stdout + sys.stderr = self._original_stderr + + @classmethod + def _sleep_message(cls, message): + wake_time = datetime.now() + timedelta(seconds=cls.seconds_to_sleep) + return "%s Sleeping until %s (%s)." % (message, wake_time.strftime(cls.log_date_format), cls.sleep_duration_text) + + @classmethod + def _sleep(cls, message): + log(cls._sleep_message(message)) + time.sleep(cls.seconds_to_sleep) + + def _update_status_and_sleep(self, message): + status_message = self._sleep_message(message) + self.status_bot.update_status(status_message) + log(status_message) + time.sleep(self.seconds_to_sleep) + + @staticmethod + def _open_log_file(log_path): + (log_directory, log_name) = os.path.split(log_path) + if log_directory and not os.path.exists(log_directory): + os.makedirs(log_directory) + return open(log_path, 'a+') + + def _add_log_to_output_tee(self, path): + log_file = self._open_log_file(path) + self._files_for_output.append(log_file) + self._tee_outputs_to_files(self._files_for_output) + return log_file + + def _remove_log_from_output_tee(self, log_file): + self._files_for_output.remove(log_file) + self._tee_outputs_to_files(self._files_for_output) + log_file.close() + + def execute(self, options, args, tool): + log("CAUTION: commit-queue will discard all local changes in %s" % tool.scm().checkout_root) + if options.confirm: + response = raw_input("Are you sure? Type 'yes' to continue: ") + if (response != 'yes'): + error("User declined.") + + queue_log = self._add_log_to_output_tee(self.queue_log_path) + log("Running WebKit Commit Queue. %s" % datetime.now().strftime(self.log_date_format)) + + self.status_bot = StatusBot(host=options.status_host) + + while (True): + # Either of these calls could throw URLError which shouldn't stop the queue. + # We catch all exceptions just in case. + try: + # Fetch patches instead of just bug ids to that we validate reviewer/committer flags on every patch. + patches = tool.bugs.fetch_patches_from_commit_queue(reject_invalid_patches=True) + if not len(patches): + self._update_status_and_sleep("Empty queue.") + continue + patch_ids = map(lambda patch: patch['id'], patches) + first_bug_id = patches[0]['bug_id'] + log("%s in commit queue [%s]" % (pluralize('patch', len(patches)), ", ".join(patch_ids))) + + if not tool.buildbot.core_builders_are_green(): + self._update_status_and_sleep("Builders (http://build.webkit.org) are red.") + continue + + self.status_bot.update_status("Landing patches from bug %s." % first_bug_id, bug_id=first_bug_id) + except Exception, e: + # Don't try tell the status bot, in case telling it causes an exception. + self._sleep("Exception while checking queue and bots: %s." % e) + continue + + # Try to land patches on the first bug in the queue before looping + bug_log_path = os.path.join(self.bug_logs_directory, "%s.log" % first_bug_id) + bug_log = self._add_log_to_output_tee(bug_log_path) + bugzilla_tool_path = __file__ # re-execute this script + bugzilla_tool_args = [bugzilla_tool_path, 'land-patches', '--force-clean', '--commit-queue', '--quiet', first_bug_id] + WebKitLandingScripts.run_command_with_teed_output(bugzilla_tool_args, sys.stdout) + self._remove_log_from_output_tee(bug_log) + + log("Finished WebKit Commit Queue. %s" % datetime.now().strftime(self.log_date_format)) + self._remove_log_from_output_tee(queue_log) + + class NonWrappingEpilogIndentedHelpFormatter(IndentedHelpFormatter): def __init__(self): IndentedHelpFormatter.__init__(self) @@ -571,6 +774,7 @@ class BugzillaTool: def __init__(self): self.cached_scm = None self.bugs = Bugzilla() + self.buildbot = BuildBot() self.commands = [ { 'name' : 'bugs-to-commit', 'object' : BugsInCommitQueue() }, { 'name' : 'patches-to-commit', 'object' : PatchesInCommitQueue() }, @@ -583,6 +787,9 @@ class BugzillaTool: { 'name' : 'obsolete-attachments', 'object' : ObsoleteAttachmentsOnBug() }, { 'name' : 'post-diff', 'object' : PostDiffAsPatchToBug() }, { 'name' : 'post-commits', 'object' : PostCommitsAsPatchesToBug() }, + { 'name' : 'tree-status', 'object' : CheckTreeStatus() }, + { 'name' : 'commit-queue', 'object' : LandPatchesFromCommitQueue() }, + { 'name' : 'rollout', 'object' : RolloutCommit() }, ] self.global_option_parser = HelpPrintingOptionParser(usage=self.usage_line(), formatter=NonWrappingEpilogIndentedHelpFormatter(), epilog=self.commands_usage()) |