Fixes <http://webkit.org/b/31699>.
[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 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 LandDiffLandingSequence(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.land_options()
225         Command.__init__(self, "Lands the current working directory diff and updates the bug if provided.", "[BUGID]", options=options)
226
227     def guess_reviewer_from_bug(self, bugs, bug_id):
228         patches = bugs.fetch_reviewed_patches_from_bug(bug_id)
229         if len(patches) != 1:
230             log("%s on bug %s, cannot infer reviewer." % (pluralize("reviewed patch", len(patches)), bug_id))
231             return None
232         patch = patches[0]
233         reviewer = patch["reviewer"]
234         log("Guessing \"%s\" as reviewer from attachment %s on bug %s." % (reviewer, patch["id"], bug_id))
235         return reviewer
236
237     def update_changelogs_with_reviewer(self, reviewer, bug_id, tool):
238         if not reviewer:
239             if not bug_id:
240                 log("No bug id provided and --reviewer= not provided.  Not updating ChangeLogs with reviewer.")
241                 return
242             reviewer = self.guess_reviewer_from_bug(tool.bugs, bug_id)
243
244         if not reviewer:
245             log("Failed to guess reviewer from bug %s and --reviewer= not provided.  Not updating ChangeLogs with reviewer." % bug_id)
246             return
247
248         for changelog_path in tool.scm().modified_changelogs():
249             ChangeLog(changelog_path).set_reviewer(reviewer)
250
251     def execute(self, options, args, tool):
252         bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
253
254         WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
255
256         os.chdir(tool.scm().checkout_root)
257         self.update_changelogs_with_reviewer(options.reviewer, bug_id, tool)
258
259         fake_patch = {
260             "id": None,
261             "bug_id": bug_id
262         }
263
264         sequence = LandDiffLandingSequence(fake_patch, options, tool)
265         sequence.run()
266
267
268 class AbstractPatchProcessingCommand(Command):
269     def __init__(self, help_text, args_description, options):
270         Command.__init__(self, help_text, args_description, options=options)
271
272     def _fetch_list_of_patches_to_process(self, options, args, tool):
273         raise NotImplementedError, "subclasses must implement"
274
275     def _prepare_to_process(self, options, args, tool):
276         raise NotImplementedError, "subclasses must implement"
277
278     @staticmethod
279     def _collect_patches_by_bug(patches):
280         bugs_to_patches = {}
281         for patch in patches:
282             bug_id = patch["bug_id"]
283             bugs_to_patches[bug_id] = bugs_to_patches.get(bug_id, []).append(patch)
284         return bugs_to_patches
285
286     def execute(self, options, args, tool):
287         if not args:
288             error("%s required" % self.argument_names)
289
290         self._prepare_to_process(options, args, tool)
291         patches = self._fetch_list_of_patches_to_process(options, args, tool)
292
293         # It's nice to print out total statistics.
294         bugs_to_patches = self._collect_patches_by_bug(patches)
295         log("Processing %s from %s." % (pluralize("patch", len(patches)), pluralize("bug", len(bugs_to_patches))))
296
297         for patch in patches:
298             self._process_patch(patch, options, args, tool)
299
300
301 class AbstractPatchLandingCommand(AbstractPatchProcessingCommand):
302     def __init__(self, help_text, args_description):
303         options = WebKitLandingScripts.cleaning_options() + WebKitLandingScripts.land_options()
304         AbstractPatchProcessingCommand.__init__(self, help_text, args_description, options)
305
306     def _prepare_to_process(self, options, args, tool):
307         # Check the tree status first so we can fail early.
308         WebKitLandingScripts.ensure_builders_are_green(tool.buildbot, options)
309         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
310
311     def _process_patch(self, patch, options, args, tool):
312         sequence = ConditionalLandingSequence(patch, options, tool)
313         sequence.run_and_handle_errors()
314
315 class LandAttachment(AbstractPatchLandingCommand):
316     name = "land-attachment"
317     def __init__(self):
318         AbstractPatchLandingCommand.__init__(self, "Lands a patches from bugzilla, optionally building and testing them first", "ATTACHMENT_ID [ATTACHMENT_IDS]")
319
320     def _fetch_list_of_patches_to_process(self, options, args, tool):
321         return map(lambda patch_id: tool.bugs.fetch_attachment(patch_id), args)
322
323
324 class LandPatches(AbstractPatchLandingCommand):
325     name = "land-patches"
326     def __init__(self):
327         AbstractPatchLandingCommand.__init__(self, "Lands all patches on the given bugs, optionally building and testing them first", "BUGID [BUGIDS]")
328
329     def _fetch_list_of_patches_to_process(self, options, args, tool):
330         all_patches = []
331         for bug_id in args:
332             patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
333             log("%s found on bug %s." % (pluralize("reviewed patch", len(patches)), bug_id))
334             all_patches += patches
335         return all_patches
336
337
338 class CommitMessageForCurrentDiff(Command):
339     name = "commit-message"
340     def __init__(self):
341         Command.__init__(self, "Prints a commit message suitable for the uncommitted changes.")
342
343     def execute(self, options, args, tool):
344         os.chdir(tool.scm().checkout_root)
345         print "%s" % commit_message_for_this_commit(tool.scm()).message()
346
347
348 class ObsoleteAttachments(Command):
349     name = "obsolete-attachments"
350     def __init__(self):
351         Command.__init__(self, "Marks all attachments on a bug as obsolete.", "BUGID")
352
353     def execute(self, options, args, tool):
354         bug_id = args[0]
355         attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
356         for attachment in attachments:
357             if not attachment["is_obsolete"]:
358                 tool.bugs.obsolete_attachment(attachment["id"])
359
360
361 class PostDiff(Command):
362     name = "post-diff"
363     def __init__(self):
364         options = [
365             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: \"patch\")"),
366         ]
367         options += self.posting_options()
368         Command.__init__(self, "Attaches the current working directory diff to a bug as a patch file.", "[BUGID]", options=options)
369
370     @staticmethod
371     def posting_options():
372         return [
373             make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."),
374             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
375             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
376         ]
377
378     @staticmethod
379     def obsolete_patches_on_bug(bug_id, bugs):
380         patches = bugs.fetch_patches_from_bug(bug_id)
381         if len(patches):
382             log("Obsoleting %s on bug %s" % (pluralize("old patch", len(patches)), bug_id))
383             for patch in patches:
384                 bugs.obsolete_attachment(patch["id"])
385
386     def execute(self, options, args, tool):
387         # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
388         bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
389         if not bug_id:
390             error("No bug id passed and no bug url found in diff, can't post.")
391
392         if options.obsolete_patches:
393             self.obsolete_patches_on_bug(bug_id, tool.bugs)
394
395         diff = tool.scm().create_patch()
396         diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
397
398         description = options.description or "Patch"
399         tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
400
401
402 class PostCommits(Command):
403     name = "post-commits"
404     def __init__(self):
405         options = [
406             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."),
407             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."),
408             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
409         ]
410         options += PostDiff.posting_options()
411         Command.__init__(self, "Attaches a range of local commits to bugs as patch files.", "COMMITISH", options=options, requires_local_commits=True)
412
413     def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
414         comment_text = None
415         if (options.add_log_as_comment):
416             comment_text = commit_message.body(lstrip=True)
417             comment_text += "---\n"
418             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
419         return comment_text
420
421     def _diff_file_for_commit(self, tool, commit_id):
422         diff = tool.scm().create_patch_from_local_commit(commit_id)
423         return StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
424
425     def execute(self, options, args, tool):
426         if not args:
427             error("%s argument is required" % self.argument_names)
428
429         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
430         if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is.
431             error("bugzilla-tool does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids))))
432
433         have_obsoleted_patches = set()
434         for commit_id in commit_ids:
435             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
436
437             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
438             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))
439             if not bug_id:
440                 log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
441                 continue
442
443             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
444                 PostDiff.obsolete_patches_on_bug(bug_id, tool.bugs)
445                 have_obsoleted_patches.add(bug_id)
446
447             diff_file = self._diff_file_for_commit(tool, commit_id)
448             description = options.description or commit_message.description(lstrip=True, strip_url=True)
449             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
450             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)
451
452
453 class Rollout(Command):
454     name = "rollout"
455     def __init__(self):
456         options = WebKitLandingScripts.land_options()
457         options += WebKitLandingScripts.cleaning_options()
458         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."))
459         Command.__init__(self, "Reverts the given revision and commits the revert and re-opens the original bug.", "REVISION [BUGID]", options=options)
460
461     @staticmethod
462     def _create_changelogs_for_revert(scm, revision):
463         # First, discard the ChangeLog changes from the rollout.
464         changelog_paths = scm.modified_changelogs()
465         scm.revert_files(changelog_paths)
466
467         # Second, make new ChangeLog entries for this rollout.
468         # This could move to prepare-ChangeLog by adding a --revert= option.
469         WebKitLandingScripts.run_webkit_script("prepare-ChangeLog")
470         for changelog_path in changelog_paths:
471             ChangeLog(changelog_path).update_for_revert(revision)
472
473     @staticmethod
474     def _parse_bug_id_from_revision_diff(tool, revision):
475         original_diff = tool.scm().diff_for_revision(revision)
476         return parse_bug_id(original_diff)
477
478     @staticmethod
479     def _reopen_bug_after_rollout(tool, bug_id, comment_text):
480         if bug_id:
481             tool.bugs.reopen_bug(bug_id, comment_text)
482         else:
483             log(comment_text)
484             log("No bugs were updated or re-opened to reflect this rollout.")
485
486     def execute(self, options, args, tool):
487         if not args:
488             error("REVISION is required, see --help.")
489         revision = args[0]
490         bug_id = self._parse_bug_id_from_revision_diff(tool, revision)
491         if options.complete_rollout:
492             if bug_id:
493                 log("Will re-open bug %s after rollout." % bug_id)
494             else:
495                 log("Failed to parse bug number from diff.  No bugs will be updated/reopened after the rollout.")
496
497         WebKitLandingScripts.prepare_clean_working_directory(tool.scm(), options)
498         tool.scm().update_webkit()
499         tool.scm().apply_reverse_diff(revision)
500         self._create_changelogs_for_revert(tool.scm(), revision)
501
502         # FIXME: Fully automated rollout is not 100% idiot-proof yet, so for now just log with instructions on how to complete the rollout.
503         # Once we trust rollout we will remove this option.
504         if not options.complete_rollout:
505             log("\nNOTE: Rollout support is experimental.\nPlease verify the rollout diff and use \"bugzilla-tool land-diff %s\" to commit the rollout." % bug_id)
506         else:
507             comment_text = WebKitLandingScripts.build_and_commit(tool.scm(), options)
508             self._reopen_bug_after_rollout(tool, bug_id, comment_text)
509
510
511 class CreateBug(Command):
512     name = "create-bug"
513     def __init__(self):
514         options = [
515             make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."),
516             make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."),
517             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
518             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
519             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
520         ]
521         Command.__init__(self, "Create a bug from local changes or local commits.", "[COMMITISH]", options=options)
522
523     def create_bug_from_commit(self, options, args, tool):
524         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
525         if len(commit_ids) > 3:
526             error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
527
528         commit_id = commit_ids[0]
529
530         bug_title = ""
531         comment_text = ""
532         if options.prompt:
533             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
534         else:
535             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
536             bug_title = commit_message.description(lstrip=True, strip_url=True)
537             comment_text = commit_message.body(lstrip=True)
538             comment_text += "---\n"
539             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
540
541         diff = tool.scm().create_patch_from_local_commit(commit_id)
542         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
543         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)
544
545         if bug_id and len(commit_ids) > 1:
546             options.bug_id = bug_id
547             options.obsolete_patches = False
548             # FIXME: We should pass through --no-comment switch as well.
549             PostCommits.execute(self, options, commit_ids[1:], tool)
550
551     def create_bug_from_patch(self, options, args, tool):
552         bug_title = ""
553         comment_text = ""
554         if options.prompt:
555             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
556         else:
557             commit_message = commit_message_for_this_commit(tool.scm())
558             bug_title = commit_message.description(lstrip=True, strip_url=True)
559             comment_text = commit_message.body(lstrip=True)
560
561         diff = tool.scm().create_patch()
562         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
563         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)
564
565     def prompt_for_bug_title_and_comment(self):
566         bug_title = raw_input("Bug title: ")
567         print "Bug comment (hit ^D on blank line to end):"
568         lines = sys.stdin.readlines()
569         try:
570             sys.stdin.seek(0, os.SEEK_END)
571         except IOError:
572             # Cygwin raises an Illegal Seek (errno 29) exception when the above
573             # seek() call is made. Ignoring it seems to cause no harm.
574             # FIXME: Figure out a way to get avoid the exception in the first
575             # place.
576             pass
577         comment_text = "".join(lines)
578         return (bug_title, comment_text)
579
580     def execute(self, options, args, tool):
581         if len(args):
582             if (not tool.scm().supports_local_commits()):
583                 error("Extra arguments not supported; patch is taken from working directory.")
584             self.create_bug_from_commit(options, args, tool)
585         else:
586             self.create_bug_from_patch(options, args, tool)
587
588
589 class TreeStatus(Command):
590     name = "tree-status"
591     def __init__(self):
592         Command.__init__(self, "Print out the status of the webkit builders.")
593
594     def execute(self, options, args, tool):
595         for builder in tool.buildbot.builder_statuses():
596             status_string = "ok" if builder["is_green"] else "FAIL"
597             print "%s : %s" % (status_string.ljust(4), builder["name"])
598
599
600 class AbstractQueue(Command, WorkQueueDelegate):
601     def __init__(self, name):
602         self._name = name
603         options = [
604             make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
605             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."),
606         ]
607         Command.__init__(self, "Run the %s." % self._name, options=options)
608
609     def queue_log_path(self):
610         return "%s.log" % self._name
611
612     def work_logs_directory(self):
613         return "%s-logs" % self._name
614
615     def status_host(self):
616         return self.options.status_host
617
618     def begin_work_queue(self):
619         log("CAUTION: %s will discard all local changes in %s" % (self._name, self.tool.scm().checkout_root))
620         if self.options.confirm:
621             response = raw_input("Are you sure?  Type \"yes\" to continue: ")
622             if (response != "yes"):
623                 error("User declined.")
624         log("Running WebKit %s. %s" % (self._name, datetime.now().strftime(WorkQueue.log_date_format)))
625
626     def should_continue_work_queue(self):
627         return True
628
629     def next_work_item(self):
630         raise NotImplementedError, "subclasses must implement"
631
632     def should_proceed_with_work_item(self, work_item):
633         raise NotImplementedError, "subclasses must implement"
634
635     def process_work_item(self, work_item):
636         raise NotImplementedError, "subclasses must implement"
637
638     def handle_unexpected_error(self, work_item, message):
639         raise NotImplementedError, "subclasses must implement"
640
641     @staticmethod
642     def run_bugzilla_tool(args):
643         bugzilla_tool_path = __file__ # re-execute this script
644         bugzilla_tool_args = [bugzilla_tool_path] + args
645         WebKitLandingScripts.run_and_throw_if_fail(bugzilla_tool_args)
646
647     def log_progress(self, patch_ids):
648         log("%s in %s [%s]" % (pluralize("patch", len(patch_ids)), self._name, ", ".join(patch_ids)))
649
650     def execute(self, options, args, tool):
651         self.options = options
652         self.tool = tool
653         work_queue = WorkQueue(self)
654         work_queue.run()
655
656
657 class CommitQueue(AbstractQueue):
658     def __init__(self):
659         AbstractQueue.__init__(self, "commit-queue")
660
661     def begin_work_queue(self):
662         AbstractQueue.begin_work_queue(self)
663
664     def next_work_item(self):
665         patches = self.tool.bugs.fetch_patches_from_commit_queue(reject_invalid_patches=True)
666         if not patches:
667             return None
668         # Only bother logging if we have patches in the queue.
669         self.log_progress([patch['id'] for patch in patches])
670         return patches[0]
671
672     def should_proceed_with_work_item(self, patch):
673         red_builders_names = self.tool.buildbot.red_core_builders_names()
674         if red_builders_names:
675             red_builders_names = map(lambda name: "\"%s\"" % name, red_builders_names) # Add quotes around the names.
676             return (False, "Builders [%s] are red. See http://build.webkit.org." % ", ".join(red_builders_names), None)
677         return (True, "Landing patch %s from bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
678
679     def process_work_item(self, patch):
680         self.run_bugzilla_tool(["land-attachment", "--force-clean", "--non-interactive", "--quiet", patch["id"]])
681
682     def handle_unexpected_error(self, patch, message):
683         self.tool.bugs.reject_patch_from_commit_queue(patch["id"], message)
684
685
686 class StyleQueue(AbstractQueue):
687     def __init__(self):
688         AbstractQueue.__init__(self, "style-queue")
689
690     def status_host(self):
691         return None # FIXME: A hack until we come up with a more generic status page.
692
693     def begin_work_queue(self):
694         AbstractQueue.begin_work_queue(self)
695         self._patches = PatchCollection(self.tool.bugs)
696         self._patches.add_patches(self.tool.bugs.fetch_patches_from_review_queue(limit=10))
697
698     def next_work_item(self):
699         self.log_progress(self._patches.patch_ids())
700         return self._patches.next()
701
702     def should_proceed_with_work_item(self, patch):
703         return (True, "Checking style for patch %s on bug %s." % (patch["id"], patch["bug_id"]), patch["bug_id"])
704
705     def process_work_item(self, patch):
706         self.run_bugzilla_tool(["check-style", "--force-clean", patch["id"]])
707
708     def handle_unexpected_error(self, patch, message):
709         log(message)
710
711
712 class BugzillaTool(MultiCommandTool):
713     def __init__(self):
714         # HACK: Set self.cached_scm before calling MultiCommandTool.__init__ because
715         # MultiCommandTool._commands_usage() will call self.should_show_command_help which uses scm().
716         # This hack can be removed by overriding usage() printing in HelpPrintingOptionParser
717         # so that we don't need to create 'epilog' before constructing HelpPrintingOptionParser.
718         self.cached_scm = None
719         MultiCommandTool.__init__(self)
720         self.global_option_parser.add_option("--dry-run", action="callback", help="do not touch remote servers", callback=self.dry_run_callback)
721
722         self.bugs = Bugzilla()
723         self.buildbot = BuildBot()
724
725     def dry_run_callback(self, option, opt, value, parser):
726         self.scm().dryrun = True
727         self.bugs.dryrun = True
728
729     def scm(self):
730         # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands).
731         original_cwd = os.path.abspath(".")
732         if not self.cached_scm:
733             self.cached_scm = detect_scm_system(original_cwd)
734
735         if not self.cached_scm:
736             script_directory = os.path.abspath(sys.path[0])
737             webkit_directory = os.path.abspath(os.path.join(script_directory, "../.."))
738             self.cached_scm = detect_scm_system(webkit_directory)
739             if self.cached_scm:
740                 log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory))
741             else:
742                 error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory))
743
744         return self.cached_scm
745
746     def should_show_command_help(self, command):
747         if command.requires_local_commits:
748             return self.scm().supports_local_commits()
749         return True
750
751     def should_execute_command(self, command):
752         if command.requires_local_commits and not self.scm().supports_local_commits():
753             failure_reason = "%s requires local commits using %s in %s." % (command.name, self.scm().display_name(), self.scm().checkout_root)
754             return (False, failure_reason)
755         return (True, None)
756
757
758 if __name__ == "__main__":
759     BugzillaTool().main()