1#!/usr/bin/python 2# Copyright (c) 2009, Google Inc. All rights reserved. 3# Copyright (c) 2009 Apple Inc. All rights reserved. 4# 5# Redistribution and use in source and binary forms, with or without 6# modification, are permitted provided that the following conditions are 7# met: 8# 9# * Redistributions of source code must retain the above copyright 10# notice, this list of conditions and the following disclaimer. 11# * Redistributions in binary form must reproduce the above 12# copyright notice, this list of conditions and the following disclaimer 13# in the documentation and/or other materials provided with the 14# distribution. 15# * Neither the name of Google Inc. nor the names of its 16# contributors may be used to endorse or promote products derived from 17# this software without specific prior written permission. 18# 19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30# 31# A tool for automating dealing with bugzilla, posting patches, committing patches, etc. 32 33import fileinput # inplace file editing for set_reviewer_in_changelog 34import os 35import re 36import StringIO # for add_patch_to_bug file wrappers 37import subprocess 38import sys 39 40from optparse import OptionParser, IndentedHelpFormatter, SUPPRESS_USAGE, make_option 41 42# Import WebKit-specific modules. 43from modules.bugzilla import Bugzilla 44from modules.logging import error, log 45from modules.scm import CommitMessage, detect_scm_system, ScriptError 46 47def plural(noun): 48 # This is a dumb plural() implementation which was just enough for our uses. 49 if re.search('h$', noun): 50 return noun + 'es' 51 else: 52 return noun + 's' 53 54def pluralize(noun, count): 55 if count != 1: 56 noun = plural(noun) 57 return "%d %s" % (count, noun) 58 59# These could be put in some sort of changelogs.py. 60def latest_changelog_entry(changelog_path): 61 # e.g. 2009-06-03 Eric Seidel <eric@webkit.org> 62 changelog_date_line_regexp = re.compile('^(\d{4}-\d{2}-\d{2})' # Consume the date. 63 + '\s+(.+)\s+' # Consume the name. 64 + '<([^<>]+)>$') # And finally the email address. 65 66 entry_lines = [] 67 changelog = open(changelog_path) 68 try: 69 log("Parsing ChangeLog: " + changelog_path) 70 # The first line should be a date line. 71 first_line = changelog.readline() 72 if not changelog_date_line_regexp.match(first_line): 73 return None 74 entry_lines.append(first_line) 75 76 for line in changelog: 77 # If we've hit the next entry, return. 78 if changelog_date_line_regexp.match(line): 79 return ''.join(entry_lines) 80 entry_lines.append(line) 81 finally: 82 changelog.close() 83 # We never found a date line! 84 return None 85 86def set_reviewer_in_changelog(changelog_path, reviewer): 87 # inplace=1 creates a backup file and re-directs stdout to the file 88 for line in fileinput.FileInput(changelog_path, inplace=1): 89 print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")), # Trailing comma suppresses printing newline 90 91def modified_changelogs(scm): 92 changelog_paths = [] 93 paths = scm.changed_files() 94 for path in paths: 95 if os.path.basename(path) == "ChangeLog": 96 changelog_paths.append(path) 97 return changelog_paths 98 99def parse_bug_id(commit_message): 100 message = commit_message.message() 101 match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) 102 if match: 103 return match.group('bug_id') 104 match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", message) 105 if match: 106 return match.group('bug_id') 107 return None 108 109def commit_message_for_this_commit(scm): 110 changelog_paths = modified_changelogs(scm) 111 if not len(changelog_paths): 112 raise ScriptError("Found no modified ChangeLogs, cannot create a commit message.\n" 113 "All changes require a ChangeLog. See:\n" 114 "http://webkit.org/coding/contributing.html") 115 116 changelog_messages = [] 117 for path in changelog_paths: 118 changelog_entry = latest_changelog_entry(path) 119 if not changelog_entry: 120 error("Failed to parse ChangeLog: " + os.path.abspath(path)) 121 changelog_messages.append(changelog_entry) 122 123 # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does. 124 return CommitMessage(''.join(changelog_messages).splitlines()) 125 126 127class Command: 128 def __init__(self, help_text, argument_names="", options=[], requires_local_commits=False): 129 self.help_text = help_text 130 self.argument_names = argument_names 131 self.options = options 132 self.option_parser = HelpPrintingOptionParser(usage=SUPPRESS_USAGE, add_help_option=False, option_list=self.options) 133 self.requires_local_commits = requires_local_commits 134 135 def name_with_arguments(self, command_name): 136 usage_string = command_name 137 if len(self.options) > 0: 138 usage_string += " [options]" 139 if self.argument_names: 140 usage_string += " " + self.argument_names 141 return usage_string 142 143 def parse_args(self, args): 144 return self.option_parser.parse_args(args) 145 146 def execute(self, options, args, tool): 147 raise NotImplementedError, "subclasses must implement" 148 149 150class BugsInCommitQueue(Command): 151 def __init__(self): 152 Command.__init__(self, 'Bugs in the commit queue') 153 154 def execute(self, options, args, tool): 155 bug_ids = tool.bugs.fetch_bug_ids_from_commit_queue() 156 for bug_id in bug_ids: 157 print "%s" % bug_id 158 159 160class PatchesInCommitQueue(Command): 161 def __init__(self): 162 Command.__init__(self, 'Patches in the commit queue') 163 164 def execute(self, options, args, tool): 165 patches = tool.bugs.fetch_patches_from_commit_queue() 166 log("Patches in commit queue:") 167 for patch in patches: 168 print "%s" % patch['url'] 169 170 171class ReviewedPatchesOnBug(Command): 172 def __init__(self): 173 Command.__init__(self, 'r+\'d patches on a bug', 'BUGID') 174 175 def execute(self, options, args, tool): 176 bug_id = args[0] 177 patches_to_land = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) 178 for patch in patches_to_land: 179 print "%s" % patch['url'] 180 181 182class ApplyPatchesFromBug(Command): 183 def __init__(self): 184 options = [ 185 make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"), 186 make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"), 187 make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"), 188 make_option("--local-commit", action="store_true", dest="local_commit", default=False, help="Make a local commit for each applied patch"), 189 ] 190 Command.__init__(self, 'Applies all patches on a bug to the local working directory without committing.', 'BUGID', options=options) 191 192 @staticmethod 193 def apply_patches(patches, scm, commit_each): 194 for patch in patches: 195 scm.apply_patch(patch) 196 if commit_each: 197 commit_message = commit_message_for_this_commit(scm) 198 scm.commit_locally_with_message(commit_message.message() or patch['name']) 199 200 def execute(self, options, args, tool): 201 bug_id = args[0] 202 patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) 203 os.chdir(tool.scm().checkout_root) 204 if options.clean: 205 tool.scm().ensure_clean_working_directory(options.force_clean) 206 if options.update: 207 tool.scm().update_webkit() 208 209 if options.local_commit and not tool.scm().supports_local_commits(): 210 error("--local-commit passed, but %s does not support local commits" % tool.scm().display_name()) 211 212 self.apply_patches(patches, tool.scm(), options.local_commit) 213 214 215def bug_comment_from_commit_text(scm, commit_text): 216 match = re.search(scm.commit_success_regexp(), commit_text, re.MULTILINE) 217 svn_revision = match.group('svn_revision') 218 commit_text += ("\nhttp://trac.webkit.org/changeset/%s" % svn_revision) 219 return commit_text 220 221 222class LandAndUpdateBug(Command): 223 def __init__(self): 224 options = [ 225 make_option("-r", "--reviewer", action="store", type="string", dest="reviewer", help="Update ChangeLogs to say Reviewed by REVIEWER."), 226 make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), 227 make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), 228 make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), 229 make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), 230 make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), 231 ] 232 Command.__init__(self, 'Lands the current working directory diff and updates the bug if provided.', '[BUGID]', options=options) 233 234 def guess_reviewer_from_bug(self, bugs, bug_id): 235 patches = bugs.fetch_reviewed_patches_from_bug(bug_id) 236 if len(patches) != 1: 237 log("%s on bug %s, cannot infer reviewer." % (pluralize("reviewed patch", len(patches)), bug_id)) 238 return None 239 patch = patches[0] 240 reviewer = patch['reviewer'] 241 log('Guessing "%s" as reviewer from attachment %s on bug %s.' % (reviewer, patch['id'], bug_id)) 242 return reviewer 243 244 def update_changelogs_with_reviewer(self, reviewer, bug_id, tool): 245 if not reviewer: 246 if not bug_id: 247 log("No bug id provided and --reviewer= not provided. Not updating ChangeLogs with reviewer.") 248 return 249 reviewer = self.guess_reviewer_from_bug(tool.bugs, bug_id) 250 251 if not reviewer: 252 log("Failed to guess reviewer from bug %s and --reviewer= not provided. Not updating ChangeLogs with reviewer." % bug_id) 253 return 254 255 changelogs = modified_changelogs(tool.scm()) 256 for changelog in changelogs: 257 set_reviewer_in_changelog(changelog, reviewer) 258 259 def execute(self, options, args, tool): 260 bug_id = args[0] if len(args) else None 261 os.chdir(tool.scm().checkout_root) 262 263 self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool) 264 265 comment_text = LandPatchesFromBugs.build_and_commit(tool.scm(), options) 266 if bug_id: 267 log("Updating bug %s" % bug_id) 268 if options.close_bug: 269 tool.bugs.close_bug_as_fixed(bug_id, comment_text) 270 else: 271 # FIXME: We should a smart way to figure out if the patch is attached 272 # to the bug, and if so obsolete it. 273 tool.bugs.post_comment_to_bug(bug_id, comment_text) 274 else: 275 log(comment_text) 276 log("No bug id provided.") 277 278 279class LandPatchesFromBugs(Command): 280 def __init__(self): 281 options = [ 282 make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"), 283 make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"), 284 make_option("--no-close", action="store_false", dest="close_bug", default=True, help="Leave bug open after landing."), 285 make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."), 286 make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without running run-webkit-tests."), 287 make_option("--quiet", action="store_true", dest="quiet", default=False, help="Produce less console output."), 288 make_option("--commit-queue", action="store_true", dest="commit_queue", default=False, help="Run in commit queue mode (no user interaction)."), 289 ] 290 Command.__init__(self, 'Lands all patches on a bug optionally testing them first', 'BUGID', options=options) 291 292 @staticmethod 293 def run_and_throw_if_fail(args, quiet=False): 294 child_stdout = subprocess.PIPE if quiet else None 295 child_process = subprocess.Popen(args, stdout=child_stdout) 296 if child_process.stdout: 297 child_process.communicate() 298 return_code = child_process.wait() 299 if return_code: 300 raise ScriptError("%s failed with exit code %d" % (" ".join(args), return_code)) 301 302 # We might need to pass scm into this function for scm.checkout_root 303 @staticmethod 304 def webkit_script_path(script_name): 305 return os.path.join("WebKitTools", "Scripts", script_name) 306 307 @classmethod 308 def run_webkit_script(cls, script_name, quiet=False): 309 print "Running WebKit Script " + script_name 310 cls.run_and_throw_if_fail(cls.webkit_script_path(script_name), quiet) 311 312 @classmethod 313 def build_webkit(cls, quiet=False): 314 cls.run_webkit_script("build-webkit", quiet) 315 316 @classmethod 317 def run_webkit_tests(cls, launch_safari, quiet=False): 318 args = [cls.webkit_script_path("run-webkit-tests")] 319 if not launch_safari: 320 args.append("--no-launch-safari") 321 if quiet: 322 args.append("--quiet") 323 cls.run_and_throw_if_fail(args) 324 325 @staticmethod 326 def setup_for_landing(scm, options): 327 os.chdir(scm.checkout_root) 328 scm.ensure_no_local_commits(options.force_clean) 329 if options.clean: 330 scm.ensure_clean_working_directory(options.force_clean) 331 332 @classmethod 333 def build_and_commit(cls, scm, options): 334 if options.build: 335 cls.build_webkit(quiet=options.quiet) 336 if options.test: 337 cls.run_webkit_tests(launch_safari=not options.commit_queue, quiet=options.quiet) 338 commit_message = commit_message_for_this_commit(scm) 339 commit_log = scm.commit_with_message(commit_message.message()) 340 return bug_comment_from_commit_text(scm, commit_log) 341 342 @classmethod 343 def land_patches(cls, bug_id, patches, options, tool): 344 try: 345 comment_text = "" 346 for patch in patches: 347 tool.scm().update_webkit() # Update before every patch in case the tree has changed 348 tool.scm().apply_patch(patch, force=options.commit_queue) 349 comment_text = cls.build_and_commit(tool.scm(), options) 350 tool.bugs.clear_attachment_review_flag(patch['id'], comment_text) 351 352 if options.close_bug: 353 tool.bugs.close_bug_as_fixed(bug_id, "All reviewed patches have been landed. Closing bug.") 354 except ScriptError, e: 355 # We should add a comment to the bug, and r- the patch on failure 356 error(e) 357 358 def execute(self, options, args, tool): 359 if not len(args): 360 error("bug-id(s) required") 361 362 bugs_to_patches = {} 363 patch_count = 0 364 for bug_id in args: 365 patches = [] 366 if options.commit_queue: 367 patches = tool.bugs.fetch_commit_queue_patches_from_bug(bug_id) 368 else: 369 patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id) 370 if not len(patches): 371 log("No reviewed patches found on %s." % bug_id) 372 continue 373 patch_count += len(patches) 374 bugs_to_patches[bug_id] = patches 375 376 log("Landing %s from %s." % (pluralize("patch", patch_count), pluralize("bug", len(args)))) 377 378 self.setup_for_landing(tool.scm(), options) 379 380 for bug_id in bugs_to_patches.keys(): 381 self.land_patches(bug_id, bugs_to_patches[bug_id], options, tool) 382 383 384class CommitMessageForCurrentDiff(Command): 385 def __init__(self): 386 Command.__init__(self, 'Prints a commit message suitable for the uncommitted changes.') 387 388 def execute(self, options, args, tool): 389 os.chdir(tool.scm().checkout_root) 390 print "%s" % commit_message_for_this_commit(tool.scm()).message() 391 392 393class ObsoleteAttachmentsOnBug(Command): 394 def __init__(self): 395 Command.__init__(self, 'Marks all attachments on a bug as obsolete.', 'BUGID') 396 397 def execute(self, options, args, tool): 398 bug_id = args[0] 399 attachments = tool.bugs.fetch_attachments_from_bug(bug_id) 400 for attachment in attachments: 401 if not attachment['is_obsolete']: 402 tool.bugs.obsolete_attachment(attachment['id']) 403 404 405class PostDiffAsPatchToBug(Command): 406 def __init__(self): 407 options = [ 408 make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."), 409 make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), 410 make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"), 411 ] 412 Command.__init__(self, 'Attaches the current working directory diff to a bug as a patch file.', 'BUGID', options=options) 413 414 @staticmethod 415 def obsolete_patches_on_bug(bug_id, bugs): 416 patches = bugs.fetch_patches_from_bug(bug_id) 417 if len(patches): 418 log("Obsoleting %s on bug %s" % (pluralize('old patch', len(patches)), bug_id)) 419 for patch in patches: 420 bugs.obsolete_attachment(patch['id']) 421 422 def execute(self, options, args, tool): 423 bug_id = args[0] 424 425 if options.obsolete_patches: 426 self.obsolete_patches_on_bug(bug_id, tool.bugs) 427 428 diff = tool.scm().create_patch() 429 diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object 430 431 description = options.description or "Patch v1" 432 tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review) 433 434 435class PostCommitsAsPatchesToBug(Command): 436 def __init__(self): 437 options = [ 438 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."), 439 make_option("--no-comment", action="store_false", dest="comment", default=True, help="Do not use commit log message as a comment for the patch."), 440 make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting new ones."), 441 make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), 442 make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"), 443 ] 444 Command.__init__(self, 'Attaches a range of local commits to bugs as patch files.', 'COMMITISH', options=options, requires_local_commits=True) 445 446 def execute(self, options, args, tool): 447 if not args: 448 error("%s argument is required" % self.argument_names) 449 450 commit_ids = tool.scm().commit_ids_from_commitish_arguments(args) 451 if len(commit_ids) > 10: 452 error("Are you sure you want to attach %s patches?" % (pluralize('patch', len(commit_ids)))) 453 # Could add a --patches-limit option. 454 455 have_obsoleted_patches = set() 456 for commit_id in commit_ids: 457 # FIXME: commit_message is the wrong place to look for the bug_id 458 # the ChangeLogs should have the bug id, but the local commit message might not. 459 commit_message = tool.scm().commit_message_for_local_commit(commit_id) 460 461 bug_id = options.bug_id or parse_bug_id(commit_message) 462 if not bug_id: 463 log("Skipping %s: No bug id found in commit log or specified with --bug-id." % commit_id) 464 continue 465 466 if options.obsolete_patches and bug_id not in have_obsoleted_patches: 467 PostDiffAsPatchToBug.obsolete_patches_on_bug(bug_id, tool.bugs) 468 have_obsoleted_patches.add(bug_id) 469 470 description = options.description or commit_message.description(lstrip=True, strip_url=True) 471 comment_text = None 472 if (options.comment): 473 comment_text = commit_message.body(lstrip=True) 474 comment_text += "---\n" 475 comment_text += tool.scm().files_changed_summary_for_commit(commit_id) 476 477 diff = tool.scm().create_patch_from_local_commit(commit_id) 478 diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object 479 tool.bugs.add_patch_to_bug(bug_id, diff_file, description, comment_text, mark_for_review=options.review) 480 481 482class CreateBug(Command): 483 def __init__(self): 484 options = [ 485 make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."), 486 make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."), 487 make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."), 488 make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."), 489 ] 490 Command.__init__(self, 'Create a bug from local changes or local commits.', '[COMMITISH]', options=options) 491 492 def create_bug_from_commit(self, options, args, tool): 493 commit_ids = tool.scm().commit_ids_from_commitish_arguments(args) 494 if len(commit_ids) > 3: 495 error("Are you sure you want to create one bug with %s patches?" % len(commit_ids)) 496 497 commit_id = commit_ids[0] 498 499 bug_title = "" 500 comment_text = "" 501 if options.prompt: 502 (bug_title, comment_text) = self.prompt_for_bug_title_and_comment() 503 else: 504 commit_message = tool.scm().commit_message_for_local_commit(commit_id) 505 bug_title = commit_message.description(lstrip=True, strip_url=True) 506 comment_text = commit_message.body(lstrip=True) 507 comment_text += "---\n" 508 comment_text += tool.scm().files_changed_summary_for_commit(commit_id) 509 510 diff = tool.scm().create_patch_from_local_commit(commit_id) 511 diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object 512 bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch v1", cc=options.cc, mark_for_review=options.review) 513 514 if bug_id and len(commit_ids) > 1: 515 options.bug_id = bug_id 516 options.obsolete_patches = False 517 # FIXME: We should pass through --no-comment switch as well. 518 PostCommitsAsPatchesToBug.execute(self, options, commit_ids[1:], tool) 519 520 def create_bug_from_patch(self, options, args, tool): 521 bug_title = "" 522 comment_text = "" 523 if options.prompt: 524 (bug_title, comment_text) = self.prompt_for_bug_title_and_comment() 525 else: 526 commit_message = commit_message_for_this_commit(tool.scm()) 527 bug_title = commit_message.description(lstrip=True, strip_url=True) 528 comment_text = commit_message.body(lstrip=True) 529 530 diff = tool.scm().create_patch() 531 diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object 532 bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch v1", cc=options.cc, mark_for_review=options.review) 533 534 def prompt_for_bug_title_and_comment(self): 535 bug_title = raw_input("Bug title: ") 536 print("Bug comment (hit ^D on blank line to end):") 537 lines = sys.stdin.readlines() 538 sys.stdin.seek(0, os.SEEK_END) 539 comment_text = ''.join(lines) 540 return (bug_title, comment_text) 541 542 def execute(self, options, args, tool): 543 if len(args): 544 if (not tool.scm().supports_local_commits()): 545 error("Extra arguments not supported; patch is taken from working directory.") 546 self.create_bug_from_commit(options, args, tool) 547 else: 548 self.create_bug_from_patch(options, args, tool) 549 550 551class NonWrappingEpilogIndentedHelpFormatter(IndentedHelpFormatter): 552 def __init__(self): 553 IndentedHelpFormatter.__init__(self) 554 555 # The standard IndentedHelpFormatter paragraph-wraps the epilog, killing our custom formatting. 556 def format_epilog(self, epilog): 557 if epilog: 558 return "\n" + epilog + "\n" 559 return "" 560 561 562class HelpPrintingOptionParser(OptionParser): 563 def error(self, msg): 564 self.print_usage(sys.stderr) 565 error_message = "%s: error: %s\n" % (self.get_prog_name(), msg) 566 error_message += "\nType '" + self.get_prog_name() + " --help' to see usage.\n" 567 self.exit(2, error_message) 568 569 570class BugzillaTool: 571 def __init__(self): 572 self.cached_scm = None 573 self.bugs = Bugzilla() 574 self.commands = [ 575 { 'name' : 'bugs-to-commit', 'object' : BugsInCommitQueue() }, 576 { 'name' : 'patches-to-commit', 'object' : PatchesInCommitQueue() }, 577 { 'name' : 'reviewed-patches', 'object' : ReviewedPatchesOnBug() }, 578 { 'name' : 'create-bug', 'object' : CreateBug() }, 579 { 'name' : 'apply-patches', 'object' : ApplyPatchesFromBug() }, 580 { 'name' : 'land-diff', 'object' : LandAndUpdateBug() }, 581 { 'name' : 'land-patches', 'object' : LandPatchesFromBugs() }, 582 { 'name' : 'commit-message', 'object' : CommitMessageForCurrentDiff() }, 583 { 'name' : 'obsolete-attachments', 'object' : ObsoleteAttachmentsOnBug() }, 584 { 'name' : 'post-diff', 'object' : PostDiffAsPatchToBug() }, 585 { 'name' : 'post-commits', 'object' : PostCommitsAsPatchesToBug() }, 586 ] 587 588 self.global_option_parser = HelpPrintingOptionParser(usage=self.usage_line(), formatter=NonWrappingEpilogIndentedHelpFormatter(), epilog=self.commands_usage()) 589 self.global_option_parser.add_option("--dry-run", action="store_true", dest="dryrun", help="do not touch remote servers", default=False) 590 591 def scm(self): 592 # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands). 593 original_cwd = os.path.abspath('.') 594 if not self.cached_scm: 595 self.cached_scm = detect_scm_system(original_cwd) 596 597 if not self.cached_scm: 598 script_directory = os.path.abspath(sys.path[0]) 599 webkit_directory = os.path.abspath(os.path.join(script_directory, "../..")) 600 self.cached_scm = detect_scm_system(webkit_directory) 601 if self.cached_scm: 602 log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory)) 603 else: 604 error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory)) 605 606 return self.cached_scm 607 608 @staticmethod 609 def usage_line(): 610 return "Usage: %prog [options] command [command-options] [command-arguments]" 611 612 def commands_usage(self): 613 commands_text = "Commands:\n" 614 longest_name_length = 0 615 command_rows = [] 616 scm_supports_local_commits = self.scm().supports_local_commits() 617 for command in self.commands: 618 command_object = command['object'] 619 if command_object.requires_local_commits and not scm_supports_local_commits: 620 continue 621 command_name_and_args = command_object.name_with_arguments(command['name']) 622 command_rows.append({ 'name-and-args': command_name_and_args, 'object': command_object }) 623 longest_name_length = max([longest_name_length, len(command_name_and_args)]) 624 625 # Use our own help formatter so as to indent enough. 626 formatter = IndentedHelpFormatter() 627 formatter.indent() 628 formatter.indent() 629 630 for row in command_rows: 631 command_object = row['object'] 632 commands_text += " " + row['name-and-args'].ljust(longest_name_length + 3) + command_object.help_text + "\n" 633 commands_text += command_object.option_parser.format_option_help(formatter) 634 return commands_text 635 636 def handle_global_args(self, args): 637 (options, args) = self.global_option_parser.parse_args(args) 638 if len(args): 639 # We'll never hit this because split_args splits at the first arg without a leading '-' 640 self.global_option_parser.error("Extra arguments before command: " + args) 641 642 if options.dryrun: 643 self.scm().dryrun = True 644 self.bugs.dryrun = True 645 646 @staticmethod 647 def split_args(args): 648 # Assume the first argument which doesn't start with '-' is the command name. 649 command_index = 0 650 for arg in args: 651 if arg[0] != '-': 652 break 653 command_index += 1 654 else: 655 return (args[:], None, []) 656 657 global_args = args[:command_index] 658 command = args[command_index] 659 command_args = args[command_index + 1:] 660 return (global_args, command, command_args) 661 662 def command_by_name(self, command_name): 663 for command in self.commands: 664 if command_name == command['name']: 665 return command 666 return None 667 668 def main(self): 669 (global_args, command_name, args_after_command_name) = self.split_args(sys.argv[1:]) 670 671 # Handle --help, etc: 672 self.handle_global_args(global_args) 673 674 if not command_name: 675 self.global_option_parser.error("No command specified") 676 677 command = self.command_by_name(command_name) 678 if not command: 679 self.global_option_parser.error(command_name + " is not a recognized command") 680 681 command_object = command['object'] 682 683 if command_object.requires_local_commits and not self.scm().supports_local_commits(): 684 error(command_name + " requires local commits using %s in %s." % (self.scm().display_name(), self.scm().checkout_root)) 685 686 (command_options, command_args) = command_object.parse_args(args_after_command_name) 687 return command_object.execute(command_options, command_args, self) 688 689 690def main(): 691 tool = BugzillaTool() 692 return tool.main() 693 694if __name__ == "__main__": 695 main() 696