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