2009-06-18 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         
332             comment_text += "\n---\n"
333             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
334         
335             # This is a little bit of a hack, that we pass stdout as the patch file.
336             # We could alternatively make an in-memory file-like object with the patch contents.
337             diff_process = subprocess.Popen(tool.scm().show_diff_command_for_commit(commit_id), stdout=subprocess.PIPE, shell=True)
338             tool.bugs.add_patch_to_bug(bug_id, diff_process.stdout, description, comment_text, mark_for_review=options.review)
339
340
341 class NonWrappingEpilogIndentedHelpFormatter(IndentedHelpFormatter):
342     def __init__(self):
343         IndentedHelpFormatter.__init__(self)
344
345     # The standard IndentedHelpFormatter paragraph-wraps the epilog, killing our custom formatting.
346     def format_epilog(self, epilog):
347         if epilog:
348             return "\n" + epilog + "\n"
349         return ""
350
351 class BugzillaTool:
352     def __init__(self):
353         self.cached_scm = None
354         self.bugs = Bugzilla()
355         self.commands = [
356             { 'name' : 'bugs-to-commit', 'object' : BugsInCommitQueue() },
357             { 'name' : 'patches-to-commit', 'object' : PatchesInCommitQueue() },
358             { 'name' : 'reviewed-patches', 'object' : ReviewedPatchesOnBug() },
359             { 'name' : 'apply-patches', 'object' : ApplyPatchesFromBug() },
360             { 'name' : 'land-and-update', 'object' : LandAndUpdateBug() },
361             { 'name' : 'land-patches', 'object' : LandPatchesFromBug() },
362             { 'name' : 'commit-message', 'object' : CommitMessageForCurrentDiff() },
363             { 'name' : 'obsolete-attachments', 'object' : ObsoleteAttachmentsOnBug() },
364             { 'name' : 'post-diff', 'object' : PostDiffAsPatchToBug() },
365             { 'name' : 'post-commits', 'object' : PostCommitsAsPatchesToBug() },
366         ]
367         
368         self.global_option_parser = OptionParser(usage=self.usage_line(), formatter=NonWrappingEpilogIndentedHelpFormatter(), epilog=self.commands_usage())
369         self.global_option_parser.add_option("--dry-run", action="store_true", dest="dryrun", help="do not touch remote servers", default=False)
370     
371     def scm(self):
372         # Lazily initialize SCM to not error-out before command line parsing (or when running non-scm commands).
373         original_cwd = os.path.abspath('.')
374         if not self.cached_scm:
375             self.cached_scm = detect_scm_system(original_cwd)
376         
377         if not self.cached_scm:
378             script_directory = os.path.abspath(sys.path[0])
379             webkit_directory = os.path.abspath(os.path.join(script_directory, "../.."))
380             self.cached_scm = detect_scm_system(webkit_directory)
381             if self.cached_scm:
382                 log("The current directory (%s) is not a WebKit checkout, using %s" % (original_cwd, webkit_directory))
383             else:
384                 error("FATAL: Failed to determine the SCM system for either %s or %s" % (original_cwd, webkit_directory))
385         
386         return self.cached_scm
387     
388     @staticmethod
389     def usage_line():
390         return "Usage: %prog [options] command [command-options] [command-arguments]"
391     
392     def commands_usage(self):
393         commands_text = "Commands:\n"
394         longest_name_length = 0
395         command_rows = []
396         for command in self.commands:
397             command_object = command['object']
398             command_name_and_args = command_object.name_with_arguments(command['name'])
399             command_rows.append({ 'name-and-args': command_name_and_args, 'object': command_object })
400             longest_name_length = max([longest_name_length, len(command_name_and_args)])
401         
402         # Use our own help formatter so as to indent enough.
403         formatter = IndentedHelpFormatter()
404         formatter.indent()
405         formatter.indent()
406         
407         for row in command_rows:
408             command_object = row['object']
409             commands_text += "  " + row['name-and-args'].ljust(longest_name_length + 3) + command_object.help_text + "\n"
410             commands_text += command_object.option_parser.format_option_help(formatter)
411         return commands_text
412
413     def handle_global_args(self, args):
414         (options, args) = self.global_option_parser.parse_args(args)
415         if len(args):
416             # We'll never hit this because split_args splits at the first arg without a leading '-'
417             self.global_option_parser.error("Extra arguments before command: " + args)
418         
419         if options.dryrun:
420             self.scm().dryrun = True
421             self.bugs.dryrun = True
422     
423     @staticmethod
424     def split_args(args):
425         # Assume the first argument which doesn't start with '-' is the command name.
426         command_index = 0
427         for arg in args:
428             if arg[0] != '-':
429                 break
430             command_index += 1
431         else:
432             return (args[:], None, [])
433
434         global_args = args[:command_index]
435         command = args[command_index]
436         command_args = args[command_index + 1:]
437         return (global_args, command, command_args)
438     
439     def command_by_name(self, command_name):
440         for command in self.commands:
441             if command_name == command['name']:
442                 return command
443         return None
444     
445     def main(self):
446         (global_args, command_name, args_after_command_name) = self.split_args(sys.argv[1:])
447         
448         # Handle --help, etc:
449         self.handle_global_args(global_args)
450         
451         if not command_name:
452             self.global_option_parser.error("No command specified")
453         
454         command = self.command_by_name(command_name)
455         if not command:
456             self.global_option_parser.error(command_name + " is not a recognized command")
457         
458         command_object = command['object']
459         (command_options, command_args) = command_object.parse_args(args_after_command_name)
460         return command_object.execute(command_options, command_args, self)
461
462
463 def main():
464     tool = BugzillaTool()
465     return tool.main()
466
467 if __name__ == "__main__":
468     main()