• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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