2009-11-20 Adam Barth <abarth@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / modules / commands / upload.py
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 # FIXME: Trim down this import list once we have unit tests.
32 import os
33 import re
34 import StringIO
35 import subprocess
36 import sys
37 import time
38
39 from datetime import datetime, timedelta
40 from optparse import make_option
41
42 from modules.bugzilla import Bugzilla, parse_bug_id
43 from modules.buildbot import BuildBot
44 from modules.changelogs import ChangeLog
45 from modules.comments import bug_comment_from_commit_text
46 from modules.grammar import pluralize
47 from modules.landingsequence import LandingSequence, 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 class CommitMessageForCurrentDiff(Command):
58     name = "commit-message"
59     def __init__(self):
60         Command.__init__(self, "Prints a commit message suitable for the uncommitted changes.")
61
62     def execute(self, options, args, tool):
63         os.chdir(tool.scm().checkout_root)
64         print "%s" % commit_message_for_this_commit(tool.scm()).message()
65
66
67 class ObsoleteAttachments(Command):
68     name = "obsolete-attachments"
69     def __init__(self):
70         Command.__init__(self, "Marks all attachments on a bug as obsolete.", "BUGID")
71
72     def execute(self, options, args, tool):
73         bug_id = args[0]
74         attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
75         for attachment in attachments:
76             if not attachment["is_obsolete"]:
77                 tool.bugs.obsolete_attachment(attachment["id"])
78
79
80 class PostDiff(Command):
81     name = "post-diff"
82     def __init__(self):
83         options = [
84             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: \"patch\")"),
85         ]
86         options += self.posting_options()
87         Command.__init__(self, "Attaches the current working directory diff to a bug as a patch file.", "[BUGID]", options=options)
88
89     @staticmethod
90     def posting_options():
91         return [
92             make_option("--no-obsolete", action="store_false", dest="obsolete_patches", default=True, help="Do not obsolete old patches before posting this one."),
93             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
94             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
95         ]
96
97     @staticmethod
98     def obsolete_patches_on_bug(bug_id, bugs):
99         patches = bugs.fetch_patches_from_bug(bug_id)
100         if len(patches):
101             log("Obsoleting %s on bug %s" % (pluralize("old patch", len(patches)), bug_id))
102             for patch in patches:
103                 bugs.obsolete_attachment(patch["id"])
104
105     def execute(self, options, args, tool):
106         # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
107         bug_id = (args and args[0]) or parse_bug_id(tool.scm().create_patch())
108         if not bug_id:
109             error("No bug id passed and no bug url found in diff, can't post.")
110
111         if options.obsolete_patches:
112             self.obsolete_patches_on_bug(bug_id, tool.bugs)
113
114         diff = tool.scm().create_patch()
115         diff_file = StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
116
117         description = options.description or "Patch"
118         tool.bugs.add_patch_to_bug(bug_id, diff_file, description, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
119
120
121 class PostCommits(Command):
122     name = "post-commits"
123     def __init__(self):
124         options = [
125             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."),
126             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."),
127             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
128         ]
129         options += PostDiff.posting_options()
130         Command.__init__(self, "Attaches a range of local commits to bugs as patch files.", "COMMITISH", options=options, requires_local_commits=True)
131
132     def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
133         comment_text = None
134         if (options.add_log_as_comment):
135             comment_text = commit_message.body(lstrip=True)
136             comment_text += "---\n"
137             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
138         return comment_text
139
140     def _diff_file_for_commit(self, tool, commit_id):
141         diff = tool.scm().create_patch_from_local_commit(commit_id)
142         return StringIO.StringIO(diff) # add_patch_to_bug expects a file-like object
143
144     def execute(self, options, args, tool):
145         if not args:
146             error("%s argument is required" % self.argument_names)
147
148         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
149         if len(commit_ids) > 10: # We could lower this limit, 10 is too many for one bug as-is.
150             error("bugzilla-tool does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize("patch", len(commit_ids))))
151
152         have_obsoleted_patches = set()
153         for commit_id in commit_ids:
154             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
155
156             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
157             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))
158             if not bug_id:
159                 log("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
160                 continue
161
162             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
163                 PostDiff.obsolete_patches_on_bug(bug_id, tool.bugs)
164                 have_obsoleted_patches.add(bug_id)
165
166             diff_file = self._diff_file_for_commit(tool, commit_id)
167             description = options.description or commit_message.description(lstrip=True, strip_url=True)
168             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
169             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)
170
171
172 class CreateBug(Command):
173     name = "create-bug"
174     def __init__(self):
175         options = [
176             make_option("--cc", action="store", type="string", dest="cc", help="Comma-separated list of email addresses to carbon-copy."),
177             make_option("--component", action="store", type="string", dest="component", help="Component for the new bug."),
178             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
179             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
180             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
181         ]
182         Command.__init__(self, "Create a bug from local changes or local commits.", "[COMMITISH]", options=options)
183
184     def create_bug_from_commit(self, options, args, tool):
185         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
186         if len(commit_ids) > 3:
187             error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
188
189         commit_id = commit_ids[0]
190
191         bug_title = ""
192         comment_text = ""
193         if options.prompt:
194             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
195         else:
196             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
197             bug_title = commit_message.description(lstrip=True, strip_url=True)
198             comment_text = commit_message.body(lstrip=True)
199             comment_text += "---\n"
200             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
201
202         diff = tool.scm().create_patch_from_local_commit(commit_id)
203         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
204         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)
205
206         if bug_id and len(commit_ids) > 1:
207             options.bug_id = bug_id
208             options.obsolete_patches = False
209             # FIXME: We should pass through --no-comment switch as well.
210             PostCommits.execute(self, options, commit_ids[1:], tool)
211
212     def create_bug_from_patch(self, options, args, tool):
213         bug_title = ""
214         comment_text = ""
215         if options.prompt:
216             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
217         else:
218             commit_message = commit_message_for_this_commit(tool.scm())
219             bug_title = commit_message.description(lstrip=True, strip_url=True)
220             comment_text = commit_message.body(lstrip=True)
221
222         diff = tool.scm().create_patch()
223         diff_file = StringIO.StringIO(diff) # create_bug_with_patch expects a file-like object
224         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)
225
226     def prompt_for_bug_title_and_comment(self):
227         bug_title = raw_input("Bug title: ")
228         print "Bug comment (hit ^D on blank line to end):"
229         lines = sys.stdin.readlines()
230         try:
231             sys.stdin.seek(0, os.SEEK_END)
232         except IOError:
233             # Cygwin raises an Illegal Seek (errno 29) exception when the above
234             # seek() call is made. Ignoring it seems to cause no harm.
235             # FIXME: Figure out a way to get avoid the exception in the first
236             # place.
237             pass
238         comment_text = "".join(lines)
239         return (bug_title, comment_text)
240
241     def execute(self, options, args, tool):
242         if len(args):
243             if (not tool.scm().supports_local_commits()):
244                 error("Extra arguments not supported; patch is taken from working directory.")
245             self.create_bug_from_commit(options, args, tool)
246         else:
247             self.create_bug_from_patch(options, args, tool)