2009-11-20 Adam Barth <abarth@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / bugzilla-tool
1 #!/usr/bin/env 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
33 import os
34 import re
35 import StringIO # for add_patch_to_bug file wrappers
36 import subprocess
37 import sys
38 import time
39
40 from datetime import datetime, timedelta
41 from optparse import make_option
42
43 # Import WebKit-specific modules.
44 from modules.comments import bug_comment_from_commit_text
45 from modules.bugzilla import Bugzilla, parse_bug_id
46 from modules.buildbot import BuildBot
47 from modules.changelogs import ChangeLog
48 from modules.landingsequence import LandingSequence, ConditionalLandingSequence
49 from modules.logging import error, log, tee
50 from modules.multicommandtool import MultiCommandTool, Command
51 from modules.patchcollection import PatchCollection
52 from modules.scm import CommitMessage, detect_scm_system, ScriptError, CheckoutNeedsUpdate
53 from modules.statusbot import StatusBot
54 from modules.webkitlandingscripts import WebKitLandingScripts, commit_message_for_this_commit
55 from modules.webkitport import WebKitPort
56 from modules.workqueue import WorkQueue, WorkQueueDelegate
57
58 def plural(noun):
59     # This is a dumb plural() implementation which was just enough for our uses.
60     if re.search("h$", noun):
61         return noun + "es"
62     else:
63         return noun + "s"
64
65 def pluralize(noun, count):
66     if count != 1:
67         noun = plural(noun)
68     return "%d %s" % (count, noun)
69
70
71 class BugsToCommit(Command):
72     name = "bugs-to-commit"
73     def __init__(self):
74         Command.__init__(self, "Bugs in the commit queue")
75
76     def execute(self, options, args, tool):
77         bug_ids = tool.bugs.fetch_bug_ids_from_commit_queue()
78         for bug_id in bug_ids:
79             print "%s" % bug_id
80
81
82 class PatchesToCommit(Command):
83     name = "patches-to-commit"
84     def __init__(self):
85         Command.__init__(self, "Patches in the commit queue")
86
87     def execute(self, options, args, tool):
88         patches = tool.bugs.fetch_patches_from_commit_queue()
89         log("Patches in commit queue:")
90         for patch in patches:
91             print "%s" % patch["url"]
92
93
94 class ReviewedPatches(Command):
95     name = "reviewed-patches"
96     def __init__(self):
97         Command.__init__(self, "r+'d patches on a bug", "BUGID")
98
99     def execute(self, options, args, tool):
100         bug_id = args[0]
101         patches_to_land = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
102         for patch in patches_to_land:
103             print "%s" % patch["url"]
104
105
106 class CheckStyle(Command):
107     name = "check-style"
108     def __init__(self):
109         options = WebKitLandingScripts.cleaning_options()
110         Command.__init__(self, "Runs check-webkit-style on the specified attachment", "ATTACHMENT_ID", options=options)
111
112     @classmethod
113     def check_style(cls, patch, options, tool):
114         tool.scm().update_webkit()
115         log("Checking style for patch %s from bug %s." % (patch["id"], patch["bug_id"]))
116         try:
117             # FIXME: check-webkit-style shouldn't really have to apply the patch to check the style.
118             tool.scm().apply_patch(patch)
119             WebKitLandingScripts.run_webkit_script("check-webkit-style")
120         except ScriptError, e:
121             log("Patch %s from bug %s failed to apply and check style." % (patch["id"], patch["bug_id"]))
122             log(e.output)
123
124         # This is safe because in order to get here the working directory had to be
125         # clean at the beginning.  Clean it out again before we exit.
126         tool.scm().ensure_clean_working_directory(force_clean=True)
127
128     def execute(self, options, args, tool):
129         attachment_id = args[0]
130         attachment = tool.bugs.fetch_attachment(attachment_id)
131
132         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
133         self.check_style(attachment, options, tool)
134
135
136 class ApplyAttachment(Command):
137     name = "apply-attachment"
138     def __init__(self):
139         options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
140         Command.__init__(self, "Applies an attachment to the local working directory.", "ATTACHMENT_ID", options=options)
141
142     def execute(self, options, args, tool):
143         WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
144         attachment_id = args[0]
145         attachment = tool.bugs.fetch_attachment(attachment_id)
146         WebKitApplyingScripts.apply_patches_with_options(tool.scm(), [attachment], options)
147
148
149 class ApplyPatches(Command):
150     name = "apply-patches"
151     def __init__(self):
152         options = WebKitApplyingScripts.apply_options() + WebKitLandingScripts.cleaning_options()
153         Command.__init__(self, "Applies all patches on a bug to the local working directory.", "BUGID", options=options)
154
155     def execute(self, options, args, tool):
156         WebKitApplyingScripts.setup_for_patch_apply(tool.scm(), options)
157         bug_id = args[0]
158         patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
159         WebKitApplyingScripts.apply_patches_with_options(tool.scm(), patches, options)
160
161
162 class WebKitApplyingScripts:
163     @staticmethod
164     def apply_options():
165         return [
166             make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"),
167             make_option("--local-commit", action="store_true", dest="local_commit", default=False, help="Make a local commit for each applied patch"),
168         ]
169
170     @staticmethod
171     def setup_for_patch_apply(scm, options):
172         WebKitLandingScripts.prepare_clean_working_directory(scm, options, allow_local_commits=True)
173         if options.update:
174             scm.update_webkit()
175
176     @staticmethod
177     def apply_patches_with_options(scm, patches, options):
178         if options.local_commit and not scm.supports_local_commits():
179             error("--local-commit passed, but %s does not support local commits" % scm.display_name())
180
181         for patch in patches:
182             log("Applying attachment %s from bug %s" % (patch["id"], patch["bug_id"]))
183             scm.apply_patch(patch)
184             if options.local_commit:
185                 commit_message = commit_message_for_this_commit(scm)
186                 scm.commit_locally_with_message(commit_message.message() or patch["name"])
187
188
189 class LandDiffSequence(ConditionalLandingSequence):
190     def __init__(self, patch, options, tool):
191         ConditionalLandingSequence.__init__(self, patch, options, tool)
192
193     def update(self):
194         pass
195
196     def apply_patch(self):
197         pass
198
199     def close_patch(self, commit_log):
200         self._comment_test = bug_comment_from_commit_text(self._tool.scm(), commit_log)
201         # There is no patch to close.
202
203     def close_bug(self):
204         bug_id = self._patch["bug_id"]
205         if bug_id:
206             log("Updating bug %s" % bug_id)
207             if self._options.close_bug:
208                 self._tool.bugs.close_bug_as_fixed(bug_id, self._comment_test)
209             else:
210                 # FIXME: We should a smart way to figure out if the patch is attached
211                 # to the bug, and if so obsolete it.
212                 self._tool.bugs.post_comment_to_bug(bug_id, self._comment_test)
213         else:
214             log(self._comment_test)
215             log("No bug id provided.")
216
217
218 class LandDiff(Command):
219     name = "land-diff"
220     def __init__(self):
221         options = [
222             make_option("-r", "--reviewer", action="store", type="string", dest="reviewer", help="Update ChangeLogs to say Reviewed by REVIEWER."),
223         ]
224         options += WebKitLandingScripts.build_options()
225         options += WebKitLandingScripts.land_options()
226         Command.__init__(self, "Lands the current working directory diff and updates the bug if provided.", "[BUGID]", options=options)
227
228     def guess_reviewer_from_bug(self, bugs, bug_id):
229         patches = bugs.fetch_reviewed_patches_from_bug(bug_id)
230         if len(patches) != 1:
231             log("%s on bug %s, cannot infer reviewer." % (pluralize("reviewed patch", len(patches)), bug_id))
232             return None
233         patch = patches[0]
234         reviewer = patch["reviewer"]
235         log("Guessing \"%s\" as reviewer from attachment %s on bug %s." % (reviewer, patch["id"], bug_id))
236         return reviewer
237
238     def update_changelogs_with_reviewer(self, reviewer, bug_id, tool):
239         if not reviewer:
240             if not bug_id:
241                 log("No bug id provided and --reviewer= not provided.  Not updating ChangeLogs with reviewer.")
242                 return
243             reviewer = self.guess_reviewer_from_bug(tool.bugs, bug_id)
244
245         if not reviewer:
246             log("Failed to guess reviewer from bug %s and --reviewer= not provided.  Not updating ChangeLogs with reviewer." % bug_id)
247             return
248
249         for changelog_path in tool.scm().modified_changelogs():
250             ChangeLog(changelog_path).set_reviewer(reviewer)
251
252     def execute(self, options, args, tool):
253         bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
254
255         WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
256
257         os.chdir(tool.scm().checkout_root)
258         self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool)
259
260         fake_patch = {
261             "id": None,
262             "bug_id": bug_id
263         }
264
265         sequence = LandDiffSequence(fake_patch, options, tool)
266         sequence.run()
267
268
269 class AbstractPatchProcessingCommand(Command):
270     def __init__(self, help_text, args_description, options):
271         Command.__init__(self, help_text, args_description, options=options)
272
273     def _fetch_list_of_patches_to_process(self, options, args, tool):
274         raise NotImplementedError, "subclasses must implement"
275
276     def _prepare_to_process(self, options, args, tool):
277         raise NotImplementedError, "subclasses must implement"
278
279     @staticmethod
280     def _collect_patches_by_bug(patches):
281         bugs_to_patches = {}
282         for patch in patches:
283             bug_id = patch["bug_id"]
284             bugs_to_patches[bug_id] = bugs_to_patches.get(bug_id, []).append(patch)
285         return bugs_to_patches
286
287     def execute(self, options, args, tool):
288         if not args:
289             error("%s required" % self.argument_names)
290
291         self._prepare_to_process(options, args, tool)
292         patches = self._fetch_list_of_patches_to_process(options, args, tool)
293
294         # It's nice to print out total statistics.
295         bugs_to_patches = self._collect_patches_by_bug(patches)
296         log("Processing %s from %s." % (pluralize("patch", len(patches)), pluralize("bug", len(bugs_to_patches))))
297
298         for patch in patches:
299             self._process_patch(patch, options, args, tool)
300
301
302 class BuildAttachmentSequence(LandingSequence):
303     def __init__(self, patch, options, tool):
304         LandingSequence.__init__(self, patch, options, tool)
305
306     def run(self):
307         self.update()
308         self.apply_patch()
309         self.build()
310
311
312 class BuildAttachment(AbstractPatchProcessingCommand):
313     name = "build-attachment"
314     def __init__(self):
315         options = WebKitLandingScripts.cleaning_options()
316         options += WebKitLandingScripts.build_options()
317         AbstractPatchProcessingCommand.__init__(self, "Builds patches from bugzilla", "ATTACHMENT_ID [ATTACHMENT_IDS]", options)
318
319     def _fetch_list_of_patches_to_process(self, options, args, tool):
320         return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
321
322     def _prepare_to_process(self, options, args, tool):
323         # Check the tree status first so we can fail early.
324         WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
325         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
326
327     def _process_patch(self, patch, options, args, tool):
328         sequence = BuildAttachmentSequence(patch, options, tool)
329         sequence.run_and_handle_errors()
330
331
332 class AbstractPatchLandingCommand(AbstractPatchProcessingCommand):
333     def __init__(self, help_text, args_description):
334         options = WebKitLandingScripts.cleaning_options()
335         options += WebKitLandingScripts.build_options()
336         options += WebKitLandingScripts.land_options()
337         AbstractPatchProcessingCommand.__init__(self, help_text, args_description, options)
338
339     def _prepare_to_process(self, options, args, tool):
340         # Check the tree status first so we can fail early.
341         WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
342         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
343
344     def _process_patch(self, patch, options, args, tool):
345         sequence = ConditionalLandingSequence(patch, options, tool)
346         sequence.run_and_handle_errors()
347
348 class LandAttachment(AbstractPatchLandingCommand):
349     name = "land-attachment"
350     def __init__(self):
351         AbstractPatchLandingCommand.__init__(self, "Lands patches from bugzilla, optionally building and testing them first", "ATTACHMENT_ID [ATTACHMENT_IDS]")
352
353     def _fetch_list_of_patches_to_process(self, options, args, tool):
354         return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
355
356
357 class LandPatches(AbstractPatchLandingCommand):
358     name = "land-patches"
359     def __init__(self):
360         AbstractPatchLandingCommand.__init__(self, "Lands all patches on the given bugs, optionally building and testing them first", "BUGID [BUGIDS]")
361
362     def _fetch_list_of_patches_to_process(self, options, args, tool):
363         all_patches = []
364         for bug_id in args:
365             patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
366             log("%s found on bug %s." % (pluralize("reviewed patch", len(patches)), bug_id))
367             all_patches += patches
368         return all_patches
369
370
371 class CommitMessageForCurrentDiff(Command):
372     name = "commit-message"
373     def __init__(self):
374         Command.__init__(self, "Prints a commit message suitable for the uncommitted changes.")
375
376     def execute(self, options, args, tool):
377         os.chdir(tool.scm().checkout_root)
378         print "%s" % commit_message_for_this_commit(tool.scm()).message()
379
380
381 class ObsoleteAttachments(Command):
382     name = "obsolete-attachments"
383     def __init__(self):
384         Command.__init__(self, "Marks all attachments on a bug as obsolete.", "BUGID")
385
386     def execute(self, options, args, tool):
387         bug_id = args[0]
388         attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
389         for attachment in attachments:
390             if not attachment["is_obsolete"]:
391                 tool.bugs.obsolete_attachment(attachment["id"])
392
393
394 class PostDiff(Command):
395     name = "post-diff"
396     def __init__(self):
397         options = [
398             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: \"patch\")"),
399         ]
400         options += self.posting_options()
401         Command.__init__(self, "Attaches the current working directory diff to a bug as a patch file.", "[BUGID]", options=options)
402
403     @staticmethod
404     def posting_options():
405         return [
406             make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."),
407             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
408             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
409         ]
410
411     @staticmethod
412     def obsolete_patches_on_bug(bug_id, bugs):
413         patches = bugs.fetch_patches_from_bug(bug_id)
414         if len(patches):
415             log("Obsoleting %s on bug %s" % (pluralize("old patch", len(patches)), bug_id))
416             for patch in patches:
417                 bugs.obsolete_attachment(patch["id"])
418
419     def execute(self, options, args, tool):
420         # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
421         bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
422         if not bug_id:
423             error("No bug id passed and no bug url found in diff, can't post.")
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"
432         tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
433
434
435 class PostCommits(Command):
436     name = "post-commits"
437     def __init__(self):
438         options = [
439             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."),
440             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."),
441             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
442         ]
443         options += PostDiff.posting_options()
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 _comment_text_for_commit(self, options, commit_message, tool, commit_id):
447         comment_text = None
448         if (options.add_log_as_comment):
449             comment_text = commit_message.body(lstrip=True)
450             comment_text += "---\n"
451             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
452         return comment_text
453
454     def _diff_file_for_commit(self, tool, commit_id):
455         diff = tool.scm().create_patch_from_local_commit(commit_id)
456         return StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
457
458     def execute(self, options, args, tool):
459         if not args:
460             error("%s argument is required" % self.argument_names)
461
462         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
463         if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is.
464             error("bugzilla-tool does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids))))
465
466         have_obsoleted_patches = set()
467         for commit_id in commit_ids:
468             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
469
470             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
471             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))
472             if not bug_id:
473                 log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
474                 continue
475
476             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
477                 PostDiff.obsolete_patches_on_bug(bug_id, tool.bugs)
478                 have_obsoleted_patches.add(bug_id)
479
480             diff_file = self._diff_file_for_commit(tool, commit_id)
481             description = options.description or commit_message.description(lstrip=True, strip_url=True)
482             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
483             tool.bugs.add_patch_to_bug(bug_id, diff_file, description, comment_text, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
484
485
486 class Rollout(Command):
487     name = "rollout"
488     def __init__(self):
489         options = WebKitLandingScripts.cleaning_options()
490         options += WebKitLandingScripts.build_options()
491         options += WebKitLandingScripts.land_options()
492         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."))
493         Command.__init__(self, "Reverts the given revision and commits the revert and re-opens the original bug.", "REVISION [BUGID]", options=options)
494
495     @staticmethod
496     def _create_changelogs_for_revert(scm, revision):
497         # First, discard the ChangeLog changes from the rollout.
498         changelog_paths = scm.modified_changelogs()
499         scm.revert_files(changelog_paths)
500
501         # Second, make new ChangeLog entries for this rollout.
502         # This could move to prepare-ChangeLog by adding a --revert= option.
503         WebKitLandingScripts.run_webkit_script("prepare-ChangeLog")
504         for changelog_path in changelog_paths:
505             ChangeLog(changelog_path).update_for_revert(revision)
506
507     @staticmethod
508     def _parse_bug_id_from_revision_diff(tool, revision):
509         original_diff = tool.scm().diff_for_revision(revision)
510         return parse_bug_id(original_diff)
511
512     @staticmethod
513     def _reopen_bug_after_rollout(tool, bug_id, comment_text):
514         if bug_id:
515             tool.bugs.reopen_bug(bug_id, comment_text)
516         else:
517             log(comment_text)
518             log("No bugs were updated or re-opened to reflect this rollout.")
519
520     def execute(self, options, args, tool):
521         if not args:
522             error("REVISION is required, see --help.")
523         revision = args[0]
524         bug_id = self._parse_bug_id_from_revision_diff(tool, revision)
525         if options.complete_rollout:
526             if bug_id:
527                 log("Will re-open bug %s after rollout." % bug_id)
528             else:
529                 log("Failed to parse bug number from diff.  No bugs will be updated/reopened after the rollout.")
530
531         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
532         tool.scm().update_webkit()
533         tool.scm().apply_reverse_diff(revision)
534         self._create_changelogs_for_revert(tool.scm(), revision)
535
536         # FIXME: Fully automated rollout is not 100% idiot-proof yet, so for now just log with instructions on how to complete the rollout.
537         # Once we trust rollout we will remove this option.
538         if not options.complete_rollout:
539             log("\nNOTE: Rollout support is experimental.\nPlease verify the rollout diff and use \"bugzilla-tool land-diff %s\" to commit the rollout." % bug_id)
540         else:
541             comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options)
542             self._reopen_bug_after_rollout(tool, bug_id, comment_text)
543
544
545 class CreateBug(Command):
546     name = "create-bug"
547     def __init__(self):
548         options = [
549             make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."),
550             make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."),
551             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
552             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
553             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
554         ]
555         Command.__init__(self, "Create a bug from local changes or local commits.", "[COMMITISH]", options=options)
556
557     def create_bug_from_commit(self, options, args, tool):
558         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
559         if len(commit_ids) > 3:
560             error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
561
562         commit_id = commit_ids[0]
563
564         bug_title = ""
565         comment_text = ""
566         if options.prompt:
567             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
568         else:
569             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
570             bug_title = commit_message.description(lstrip=True, strip_url=True)
571             comment_text = commit_message.body(lstrip=True)
572             comment_text += "---\n"
573             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
574
575         diff = tool.scm().create_patch_from_local_commit(commit_id)
576         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
577         bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
578
579         if bug_id and len(commit_ids) > 1:
580             options.bug_id = bug_id
581             options.obsolete_patches = False
582             # FIXME: We should pass through --no-comment switch as well.
583             PostCommits.execute(self, options, commit_ids[1:], tool)
584
585     def create_bug_from_patch(self, options, args, tool):
586         bug_title = ""
587         comment_text = ""
588         if options.prompt:
589             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
590         else:
591             commit_message = commit_message_for_this_commit(tool.scm())
592             bug_title = commit_message.description(lstrip=True, strip_url=True)
593             comment_text = commit_message.body(lstrip=True)
594
595         diff = tool.scm().create_patch()
596         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
597         bug_id = tool.bugs.create_bug_with_patch(bug_title, comment_text, options.component, diff_file, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
598
599     def prompt_for_bug_title_and_comment(self):
600         bug_title = raw_input("Bug title: ")
601         print "Bug comment (hit ^D on blank line to end):"
602         lines = sys.stdin.readlines()
603         try:
604             sys.stdin.seek(0, os.SEEK_END)
605         except IOError:
606             # Cygwin raises an Illegal Seek (errno 29) exception when the above
607             # seek() call is made. Ignoring it seems to cause no harm.
608             # FIXME: Figure out a way to get avoid the exception in the first
609             # place.
610             pass
611         comment_text = "".join(lines)
612         return (bug_title, comment_text)
613
614     def execute(self, options, args, tool):
615         if len(args):
616             if (not tool.scm().supports_local_commits()):
617                 error("Extra arguments not supported; patch is taken from working directory.")
618             self.create_bug_from_commit(options, args, tool)
619         else:
620             self.create_bug_from_patch(options, args, tool)
621
622
623 class TreeStatus(Command):
624     name = "tree-status"
625     def __init__(self):
626         Command.__init__(self, "Print out the status of the webkit builders.")
627
628     def execute(self, options, args, tool):
629         for builder in tool.buildbot.builder_statuses():
630             status_string = "ok" if builder["is_green"] else "FAIL"
631             print "%s : %s" % (status_string.ljust(4), builder["name"])
632
633
634 class AbstractQueue(Command, WorkQueueDelegate):
635     def __init__(self, name):
636         self._name = name
637         options = [
638             make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
639             make_option("--status-host", action="store", type="string", dest="status_host", default=StatusBot.default_host, help="Hostname (e.g. localhost or commit.webkit.org) where status updates should be posted."),
640         ]
641         Command.__init__(self, "Run the %s." % self._name, options=options)
642
643     def queue_log_path(self):
644         return "%s.log" % self._name
645
646     def work_logs_directory(self):
647         return "%s-logs" % self._name
648
649     def status_host(self):
650         return self.options.status_host
651
652     def begin_work_queue(self):
653         log("CAUTION: %s will discard all local changes in %s" % (self._name, self.tool.scm().checkout_root))
654         if self.options.confirm:
655             response = raw_input("Are you sure?  Type \"yes\" to continue: ")
656             if (response != "yes"):
657                 error("User declined.")
658         log("Running WebKit %s. %s" % (self._name, datetime.now().strftime(WorkQueue.log_date_format)))
659
660     def should_continue_work_queue(self):
661         return True
662
663     def next_work_item(self):
664         raise NotImplementedError, "subclasses must implement"
665
666     def should_proceed_with_work_item(self, work_item):
667         raise NotImplementedError, "subclasses must implement"
668
669     def process_work_item(self, work_item):
670         raise NotImplementedError, "subclasses must implement"
671
672     def handle_unexpected_error(self, work_item, message):
673         raise NotImplementedError, "subclasses must implement"
674
675     @staticmethod
676     def run_bugzilla_tool(args):
677         bugzilla_tool_path = __file__ # re-execute this script
678         bugzilla_tool_args = [bugzilla_tool_path] + args
679         WebKitLandingScripts.run_and_throw_if_fail(bugzilla_tool_args)
680
681     def log_progress(self, patch_ids):
682         log("%s in %s [%s]" % (pluralize("patch", len(patch_ids)), self._name, ", ".join(patch_ids)))
683
684     def execute(self, options, args, tool):
685         self.options = options
686         self.tool = tool
687         work_queue = WorkQueue(self)
688         work_queue.run()
689
690
691 class CommitQueue(AbstractQueue):
692     def __init__(self):
693         AbstractQueue.__init__(self, "commit-queue")
694
695     def begin_work_queue(self):
696         AbstractQueue.begin_work_queue(self)
697
698     def next_work_item(self):
699         patches = self.tool.bugs.fetch_patches_from_commit_queue(reject_invalid_patches=True)
700         if not patches:
701             return None
702         # Only bother logging if we have patches in the queue.
703         self.log_progress([patch['id'] for patch in patches])
704         return patches[0]
705
706     def should_proceed_with_work_item(self, patch):
707         red_builders_names = self.tool.buildbot.red_core_builders_names()
708         if red_builders_names:
709             red_builders_names = map(lambda name: "\"%s\"" % name, red_builders_names) # Add quotes around the names.
710             return (False, "Builders [%s] are red. See http://build.webkit.org." % ", ".join(red_builders_names), None)
711         return (True, "Landing patch %s from bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
712
713     def process_work_item(self, patch):
714         self.run_bugzilla_tool(["land-attachment", "--force-clean", "--non-interactive", "--quiet", patch["id"]])
715
716     def handle_unexpected_error(self, patch, message):
717         self.tool.bugs.reject_patch_from_commit_queue(patch["id"], message)
718
719
720 class StyleQueue(AbstractQueue):
721     def __init__(self):
722         AbstractQueue.__init__(self, "style-queue")
723
724     def status_host(self):
725         return None # FIXME: A hack until we come up with a more generic status page.
726
727     def begin_work_queue(self):
728         AbstractQueue.begin_work_queue(self)
729         self._patches = PatchCollection(self.tool.bugs)
730         self._patches.add_patches(self.tool.bugs.fetch_patches_from_review_queue(limit=10))
731
732     def next_work_item(self):
733         self.log_progress(self._patches.patch_ids())
734         return self._patches.next()
735
736     def should_proceed_with_work_item(self, patch):
737         return (True, "Checking style for patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
738
739     def process_work_item(self, patch):
740         self.run_bugzilla_tool(["check-style", "--force-clean", patch["id"]])
741
742     def handle_unexpected_error(self, patch, message):
743         log(message)
744
745
746 class BugzillaTool(MultiCommandTool):
747     def __init__(self):
748         # HACK: Set self.cached_scm before calling MultiCommandTool.__init__ because
749         # MultiCommandTool._commands_usage() will call self.should_show_command_help which uses scm().
750         # This hack can be removed by overriding usage() printing in HelpPrintingOptionParser
751         # so that we don't need to create 'epilog' before constructing HelpPrintingOptionParser.
752         self.cached_scm = None
753         MultiCommandTool.__init__(self)
754         self.global_option_parser.add_option("--dry-run", action="callback", help="do not touch remote servers", callback=self.dry_run_callback)
755
756         self.bugs = Bugzilla()
757         self.buildbot = BuildBot()
758
759     def dry_run_callback(self, option, opt, value, parser):
760         self.scm().dryrun = True
761         self.bugs.dryrun = True
762
763     def scm(self):
764         # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands).
765         original_cwd = os.path.abspath(".")
766         if not self.cached_scm:
767             self.cached_scm = detect_scm_system(original_cwd)
768
769         if not self.cached_scm:
770             script_directory = os.path.abspath(sys.path[0])
771             webkit_directory = os.path.abspath(os.path.join(script_directory, "../.."))
772             self.cached_scm = detect_scm_system(webkit_directory)
773             if self.cached_scm:
774                 log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory))
775             else:
776                 error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory))
777
778         return self.cached_scm
779
780     def should_show_command_help(self, command):
781         if command.requires_local_commits:
782             return self.scm().supports_local_commits()
783         return True
784
785     def should_execute_command(self, command):
786         if command.requires_local_commits and not self.scm().supports_local_commits():
787             failure_reason = "%s requires local commits using %s in %s." % (command.name, self.scm().display_name(), self.scm().checkout_root)
788             return (False, failure_reason)
789         return (True, None)
790
791
792 if __name__ == "__main__":
793     BugzillaTool().main()