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