2009-06-23 Eric Seidel <eric@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / bugzilla-tool
1 #!/usr/bin/python
2 # Copyright (c) 2009, Google Inc. All rights reserved.
3 #
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7
8 #     * Redistributions of source code must retain the above copyright
9 # notice, this list of conditions and the following disclaimer.
10 #     * Redistributions in binary form must reproduce the above
11 # copyright notice, this list of conditions and the following disclaimer
12 # in the documentation and/or other materials provided with the
13 # distribution.
14 #     * Neither the name of Google Inc. nor the names of its
15 # contributors may be used to endorse or promote products derived from
16 # this software without specific prior written permission.
17
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 #
30 # A tool for automating dealing with bugzilla, posting patches, committing patches, etc.
31
32 import os
33 import re
34 import subprocess
35 import sys
36
37 from optparse import OptionParser, IndentedHelpFormatter, SUPPRESS_USAGE, make_option
38
39 # Import WebKit-specific modules.
40 from modules.bugzilla import Bugzilla
41 from modules.scm import detect_scm_system, ScriptError
42
43 def log(string):
44     print >> sys.stderr, string
45
46 def error(string):
47     log(string)
48     exit(1)
49
50 # These could be put in some sort of changelogs.py.
51 def latest_changelog_entry(changelog_path):
52     # e.g. 2009-06-03  Eric Seidel  <eric@webkit.org>
53     changelog_date_line_regexp = re.compile('^(\d{4}-\d{2}-\d{2})' # Consume the date.
54                                   + '\s+(.+)\s+' # Consume the name.
55                                   + '<([^<>]+)>$') # And finally the email address.
56     
57     entry_lines = []
58     changelog = open(changelog_path)
59     try:
60         log("Parsing ChangeLog: " + changelog_path)
61         # The first line should be a date line.
62         first_line = changelog.readline()
63         if not changelog_date_line_regexp.match(first_line):
64             return None
65         entry_lines.append(first_line)
66         
67         for line in changelog:
68             # If we've hit the next entry, return.
69             if changelog_date_line_regexp.match(line):
70                 return ''.join(entry_lines)
71             entry_lines.append(line)
72     finally:
73         changelog.close()
74     # We never found a date line!
75     return None
76
77 def modified_changelogs(scm):
78     changelog_paths = []
79     paths = scm.changed_files()
80     for path in paths:
81         if os.path.basename(path) == "ChangeLog":
82             changelog_paths.append(path)
83     return changelog_paths
84
85 def commit_message_for_this_commit(scm):
86     changelog_paths = modified_changelogs(scm)
87     if not len(changelog_paths):
88         error("Found no modified ChangeLogs, can't create a commit message.")
89
90     changelog_messages = []
91     for path in changelog_paths:
92         changelog_entry = latest_changelog_entry(path)
93         if not changelog_entry:
94             error("Failed to parse ChangeLog: " + os.path.abspath(path))
95         changelog_messages.append(changelog_entry)
96     
97     # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does.
98     return ''.join(changelog_messages)
99
100
101 class Command:
102     def __init__(self, help_text, argument_names="", options=[]):
103         self.help_text = help_text
104         self.argument_names = argument_names
105         self.options = options
106         self.option_parser = OptionParser(usage=SUPPRESS_USAGE, add_help_option=False, option_list=self.options)
107     
108     def name_with_arguments(self, command_name):
109         usage_string = command_name
110         if len(self.options) > 0:
111             usage_string += " [options]"
112         if self.argument_names:
113             usage_string += " " + self.argument_names
114         return usage_string
115
116     def parse_args(self, args):
117         return self.option_parser.parse_args(args)
118
119     def execute(self, options, args, tool):
120         raise NotImplementedError, "subclasses must implement"
121
122
123 class BugsInCommitQueue(Command):
124     def __init__(self):
125         Command.__init__(self, 'Bugs in the commit queue')
126
127     def execute(self, options, args, tool):
128         bug_ids = tool.bugs.fetch_bug_ids_from_commit_queue()
129         for bug_id in bug_ids:
130             print "%s" % tool.bugs.bug_url_for_bug_id(bug_id)
131
132
133 class PatchesInCommitQueue(Command):
134     def __init__(self):
135         Command.__init__(self, 'Patches attached to bugs in the commit queue')
136
137     def execute(self, options, args, tool):
138         patches = tool.bugs.fetch_patches_from_commit_queue()
139         log("Patches in commit queue:")
140         for patch in patches:
141             print "%s" % patch['url']
142
143
144 class ReviewedPatchesOnBug(Command):
145     def __init__(self):
146         Command.__init__(self, 'r+\'d patches on a bug', 'BUGID')
147
148     def execute(self, options, args, tool):
149         bug_id = args[0]
150         patches_to_land = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
151         for patch in patches_to_land:
152             print "%s" % patch['url']
153
154
155 class ApplyPatchesFromBug(Command):
156     def __init__(self):
157         options = [
158             make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"),
159             make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"),
160             make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"),
161         ]
162         Command.__init__(self, 'Applies all patches on a bug to the local working directory without committing.', 'BUGID', options=options)
163
164     def execute(self, options, args, tool):
165         bug_id = args[0]
166         patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
167         os.chdir(tool.scm().checkout_root)
168         if options.clean:
169             tool.scm().ensure_clean_working_directory(options.force_clean)
170         if options.update:
171             tool.scm().update_webkit()
172         
173         for patch in patches:
174             # FIXME: Should have an option to local-commit each patch after application.
175             tool.scm().apply_patch(patch)
176
177
178 def bug_comment_from_commit_text(commit_text):
179     comment_lines = []
180     commit_lines = commit_text.splitlines()
181     for line in commit_lines:
182         comment_lines.append(line)
183         match = re.match("^Committed r(\d+)$", line)
184         if match:
185             revision = match.group(1)
186             comment_lines.append("http://trac.webkit.org/changeset/" + revision)
187             break
188     return "\n".join(comment_lines)
189
190
191 class LandAndUpdateBug(Command):
192     def __init__(self):
193         Command.__init__(self, 'Lands the current working directory diff and updates the bug.', 'BUGID')
194
195     def execute(self, options, args, tool):
196         bug_id = args[0]
197         os.chdir(tool.scm().checkout_root)
198         commit_message = commit_message_for_this_commit(tool.scm())
199         commit_log = tool.scm().commit_with_message(commit_message)
200         comment_text = bug_comment_from_commit_text(commit_log)
201         tool.bugs.close_bug_as_fixed(bug_id, comment_text)
202
203
204 class LandPatchesFromBug(Command):
205     def __init__(self):
206         options = [
207             make_option("--no-update", action="store_false", dest="update", default=True, help="Don't update the working directory before applying patches"),
208             make_option("--force-clean", action="store_true", dest="force_clean", default=False, help="Clean working directory before applying patches (removes local changes and commits)"),
209             make_option("--no-clean", action="store_false", dest="clean", default=True, help="Don't check if the working directory is clean before applying patches"),
210             make_option("--no-build", action="store_false", dest="build", default=True, help="Commit without building first, implies --no-test."),
211             make_option("--no-test", action="store_false", dest="test", default=True, help="Commit without runnning run-webkit-tests."),
212         ]
213         Command.__init__(self, 'Lands all patches on a bug optionally testing them first', 'BUGID', options=options)
214
215     @staticmethod
216     def run_and_throw_if_fail(script_name):
217         build_webkit_process = subprocess.Popen(script_name, shell=True)
218         return_code = build_webkit_process.wait()
219         if return_code:
220             raise ScriptError(script_name + " failed with code " + return_code)
221
222     def build_webkit(self):
223         self.run_and_throw_if_fail("build-webkit")
224
225     def run_webkit_tests(self):
226         self.run_and_throw_if_fail("run-webkit-tests")
227
228     def execute(self, options, args, tool):
229         bug_id = args[0]
230
231         try:
232             patches = tool.bugs.fetch_reviewed_patches_from_bug(bug_id)
233             commit_text = ""
234
235             os.chdir(tool.scm().checkout_root)
236             tool.scm().ensure_no_local_commits(options.force_clean)
237             if options.clean:
238                 tool.scm().ensure_clean_working_directory(options.force_clean)
239             if options.update:
240                 tool.scm().update_webkit()
241             
242             for patch in patches:
243                 tool.scm().apply_patch(patch)
244                 if options.build:
245                     self.build_webkit()
246                     if options.test:
247                         self.run_webkit_tests()
248                 commit_message = commit_message_for_this_commit(tool.scm())
249                 commit_log = tool.scm().commit_with_message(commit_message)
250                 comment_text = bug_comment_from_commit_text(commit_log)
251                 # If we're commiting more than one patch, update the bug as we go.
252                 if len(patches) > 1:
253                     tool.bugs.obsolete_attachment(patch['id'], comment_text)
254
255             if len(patches) > 1:
256                 commit_text = "All reviewed patches landed, closing."
257
258             tool.bugs.close_bug_as_fixed(bug_id, commit_text)
259         except ScriptError, error:
260             log(error)
261             # We could add a comment to the bug about the failure.
262
263
264 class CommitMessageForCurrentDiff(Command):
265     def __init__(self):
266         Command.__init__(self, 'Prints a commit message suitable for the uncommitted changes.')
267
268     def execute(self, options, args, tool):
269         os.chdir(tool.scm().checkout_root)
270         print "%s" % commit_message_for_this_commit(tool.scm())
271
272
273 class ObsoleteAttachmentsOnBug(Command):
274     def __init__(self):
275         Command.__init__(self, 'Marks all attachments on a bug as obsolete.', 'BUGID')
276
277     def execute(self, options, args, tool):
278         bug_id = args[0]
279         attachments = tool.bugs.fetch_attachments_from_bug(bug_id)
280         for attachment in attachments:
281             if not attachment['obsolete']:
282                 tool.bugs.obsolete_attachment(attachment['id'])
283
284
285 class PostDiffAsPatchToBug(Command):
286     def __init__(self):
287         options = [
288             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
289             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: 'patch')"),
290         ]
291         Command.__init__(self, 'Attaches the current working directory diff to a bug as a patch file.', 'BUGID', options=options)
292
293     def execute(self, options, args, tool):
294         bug_id = args[0]
295         
296         diff_process = subprocess.Popen(tool.scm().create_patch_command(), stdout=subprocess.PIPE, shell=True)
297         diff_process.wait() # Make sure svn-create-patch is done before we continue.
298         
299         description = options.description or "patch"
300         tool.bugs.add_patch_to_bug(bug_id, diff_process.stdout, description, mark_for_review=options.review)
301
302
303 class PostCommitsAsPatchesToBug(Command):
304     def __init__(self):
305         options = [
306             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
307         ]
308         Command.__init__(self, 'Attaches a range of local commits to a bug as patch files.', 'BUGID COMMITISH', options=options)
309
310     def execute(self, options, args, tool):
311         bug_id = args[0]
312         
313         if not tool.scm().supports_local_commits():
314             log(tool.scm().display_name() + " does not support local commits.")
315             exit(1)
316         
317         commit_ids = tool.scm().commit_ids_from_range_arguments(args[1:])
318         
319         if len(commit_ids) > 10:
320             log("Are you sure you want to attach %d patches to bug %s?" % (len(commit_ids), bug_id))
321             # Could add a --patches-limit option.
322             exit(1)
323         
324         log("Attaching %d commits as patches to bug %s" % (len(commit_ids), bug_id))
325         for commit_id in commit_ids:
326             commit_message = tool.scm().commit_message_for_commit(commit_id)
327             commit_lines = commit_message.splitlines()
328             
329             description = commit_lines[0]
330             comment_text = "\n".join(commit_lines[1:])
331             comment_text += "---\n"
332             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
333         
334             # This is a little bit of a hack, that we pass stdout as the patch file.
335             # We could alternatively make an in-memory file-like object with the patch contents.
336             diff_process = subprocess.Popen(tool.scm().show_diff_command_for_commit(commit_id), stdout=subprocess.PIPE, shell=True)
337             tool.bugs.add_patch_to_bug(bug_id, diff_process.stdout, description, comment_text, mark_for_review=options.review)
338
339
340 class NonWrappingEpilogIndentedHelpFormatter(IndentedHelpFormatter):
341     def __init__(self):
342         IndentedHelpFormatter.__init__(self)
343
344     # The standard IndentedHelpFormatter paragraph-wraps the epilog, killing our custom formatting.
345     def format_epilog(self, epilog):
346         if epilog:
347             return "\n" + epilog + "\n"
348         return ""
349
350 class BugzillaTool:
351     def __init__(self):
352         self.cached_scm = None
353         self.bugs = Bugzilla()
354         self.commands = [
355             { 'name' : 'bugs-to-commit', 'object' : BugsInCommitQueue() },
356             { 'name' : 'patches-to-commit', 'object' : PatchesInCommitQueue() },
357             { 'name' : 'reviewed-patches', 'object' : ReviewedPatchesOnBug() },
358             { 'name' : 'apply-patches', 'object' : ApplyPatchesFromBug() },
359             { 'name' : 'land-and-update', 'object' : LandAndUpdateBug() },
360             { 'name' : 'land-patches', 'object' : LandPatchesFromBug() },
361             { 'name' : 'commit-message', 'object' : CommitMessageForCurrentDiff() },
362             { 'name' : 'obsolete-attachments', 'object' : ObsoleteAttachmentsOnBug() },
363             { 'name' : 'post-diff', 'object' : PostDiffAsPatchToBug() },
364             { 'name' : 'post-commits', 'object' : PostCommitsAsPatchesToBug() },
365         ]
366         
367         self.global_option_parser = OptionParser(usage=self.usage_line(), formatter=NonWrappingEpilogIndentedHelpFormatter(), epilog=self.commands_usage())
368         self.global_option_parser.add_option("--dry-run", action="store_true", dest="dryrun", help="do not touch remote servers", default=False)
369     
370     def scm(self):
371         # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands).
372         original_cwd = os.path.abspath('.')
373         if not self.cached_scm:
374             self.cached_scm = detect_scm_system(original_cwd)
375         
376         if not self.cached_scm:
377             script_directory = os.path.abspath(sys.path[0])
378             webkit_directory = os.path.abspath(os.path.join(script_directory, "../.."))
379             self.cached_scm = detect_scm_system(webkit_directory)
380             if self.cached_scm:
381                 log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory))
382             else:
383                 error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory))
384         
385         return self.cached_scm
386     
387     @staticmethod
388     def usage_line():
389         return "Usage: %prog [options] command [command-options] [command-arguments]"
390     
391     def commands_usage(self):
392         commands_text = "Commands:\n"
393         longest_name_length = 0
394         command_rows = []
395         for command in self.commands:
396             command_object = command['object']
397             command_name_and_args = command_object.name_with_arguments(command['name'])
398             command_rows.append({ 'name-and-args': command_name_and_args, 'object': command_object })
399             longest_name_length = max([longest_name_length, len(command_name_and_args)])
400         
401         # Use our own help formatter so as to indent enough.
402         formatter = IndentedHelpFormatter()
403         formatter.indent()
404         formatter.indent()
405         
406         for row in command_rows:
407             command_object = row['object']
408             commands_text += "  " + row['name-and-args'].ljust(longest_name_length + 3) + command_object.help_text + "\n"
409             commands_text += command_object.option_parser.format_option_help(formatter)
410         return commands_text
411
412     def handle_global_args(self, args):
413         (options, args) = self.global_option_parser.parse_args(args)
414         if len(args):
415             # We'll never hit this because split_args splits at the first arg without a leading '-'
416             self.global_option_parser.error("Extra arguments before command: " + args)
417         
418         if options.dryrun:
419             self.scm().dryrun = True
420             self.bugs.dryrun = True
421     
422     @staticmethod
423     def split_args(args):
424         # Assume the first argument which doesn't start with '-' is the command name.
425         command_index = 0
426         for arg in args:
427             if arg[0] != '-':
428                 break
429             command_index += 1
430         else:
431             return (args[:], None, [])
432
433         global_args = args[:command_index]
434         command = args[command_index]
435         command_args = args[command_index + 1:]
436         return (global_args, command, command_args)
437     
438     def command_by_name(self, command_name):
439         for command in self.commands:
440             if command_name == command['name']:
441                 return command
442         return None
443     
444     def main(self):
445         (global_args, command_name, args_after_command_name) = self.split_args(sys.argv[1:])
446         
447         # Handle --help, etc:
448         self.handle_global_args(global_args)
449         
450         if not command_name:
451             self.global_option_parser.error("No command specified")
452         
453         command = self.command_by_name(command_name)
454         if not command:
455             self.global_option_parser.error(command_name + " is not a recognized command")
456         
457         command_object = command['object']
458         (command_options, command_args) = command_object.parse_args(args_after_command_name)
459         return command_object.execute(command_options, command_args, self)
460
461
462 def main():
463     tool = BugzillaTool()
464     return tool.main()
465
466 if __name__ == "__main__":
467     main()