Fix formatting issues reported by check-webkit-style
[WebKit-https.git] / Tools / Scripts / webkitpy / tool / commands / upload.py
1 # Copyright (c) 2009, 2010 Google Inc. All rights reserved.
2 # Copyright (c) 2009 Apple 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 import logging
31 import os
32 import re
33 import sys
34
35 from optparse import make_option
36
37 from webkitpy.tool import steps
38
39 from webkitpy.common.checkout.changelog import parse_bug_id_from_changelog
40 from webkitpy.common.config.committers import CommitterList
41 from webkitpy.common.system.user import User
42 from webkitpy.thirdparty.mock import Mock
43 from webkitpy.tool.commands.abstractsequencedcommand import AbstractSequencedCommand
44 from webkitpy.tool.comments import bug_comment_from_svn_revision
45 from webkitpy.tool.grammar import pluralize, join_with_separators
46 from webkitpy.tool.multicommandtool import Command
47
48 _log = logging.getLogger(__name__)
49
50
51 class CommitMessageForCurrentDiff(Command):
52     name = "commit-message"
53     help_text = "Print a commit message suitable for the uncommitted changes"
54
55     def __init__(self):
56         options = [
57             steps.Options.git_commit,
58         ]
59         Command.__init__(self, options=options)
60
61     def execute(self, options, args, tool):
62         # This command is a useful test to make sure commit_message_for_this_commit
63         # always returns the right value regardless of the current working directory.
64         print "%s" % tool.checkout().commit_message_for_this_commit(options.git_commit).message()
65
66
67 class CleanPendingCommit(Command):
68     name = "clean-pending-commit"
69     help_text = "Clear r+ on obsolete patches so they do not appear in the pending-commit list."
70
71     # NOTE: This was designed to be generic, but right now we're only processing patches from the pending-commit list, so only r+ matters.
72     def _flags_to_clear_on_patch(self, patch):
73         if not patch.is_obsolete():
74             return None
75         what_was_cleared = []
76         if patch.review() == "+":
77             if patch.reviewer():
78                 what_was_cleared.append(u"%s's review+" % patch.reviewer().full_name)
79             else:
80                 what_was_cleared.append("review+")
81         return join_with_separators(what_was_cleared)
82
83     def execute(self, options, args, tool):
84         committers = CommitterList()
85         for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list():
86             bug = self._tool.bugs.fetch_bug(bug_id)
87             patches = bug.patches(include_obsolete=True)
88             for patch in patches:
89                 flags_to_clear = self._flags_to_clear_on_patch(patch)
90                 if not flags_to_clear:
91                     continue
92                 message = u"Cleared %s from obsolete attachment %s so that this bug does not appear in http://webkit.org/pending-commit." % (flags_to_clear, patch.id())
93                 self._tool.bugs.obsolete_attachment(patch.id(), message)
94
95
96 # FIXME: This should be share more logic with AssignToCommitter and CleanPendingCommit
97 class CleanReviewQueue(Command):
98     name = "clean-review-queue"
99     help_text = "Clear r? on obsolete patches so they do not appear in the pending-review list."
100
101     def execute(self, options, args, tool):
102         queue_url = "http://webkit.org/pending-review"
103         # We do this inefficient dance to be more like webkit.org/pending-review
104         # bugs.queries.fetch_bug_ids_from_review_queue() doesn't return
105         # closed bugs, but folks using /pending-review will see them. :(
106         for patch_id in tool.bugs.queries.fetch_attachment_ids_from_review_queue():
107             patch = self._tool.bugs.fetch_attachment(patch_id)
108             if not patch.review() == "?":
109                 continue
110             attachment_obsolete_modifier = ""
111             if patch.is_obsolete():
112                 attachment_obsolete_modifier = "obsolete "
113             elif patch.bug().is_closed():
114                 bug_closed_explanation = "  If you would like this patch reviewed, please attach it to a new bug (or re-open this bug before marking it for review again)."
115             else:
116                 # Neither the patch was obsolete or the bug was closed, next patch...
117                 continue
118             message = "Cleared review? from %sattachment %s so that this bug does not appear in %s.%s" % (attachment_obsolete_modifier, patch.id(), queue_url, bug_closed_explanation)
119             self._tool.bugs.obsolete_attachment(patch.id(), message)
120
121
122 class AssignToCommitter(Command):
123     name = "assign-to-committer"
124     help_text = "Assign bug to whoever attached the most recent r+'d patch"
125
126     def _patches_have_commiters(self, reviewed_patches):
127         for patch in reviewed_patches:
128             if not patch.committer():
129                 return False
130         return True
131
132     def _assign_bug_to_last_patch_attacher(self, bug_id):
133         committers = CommitterList()
134         bug = self._tool.bugs.fetch_bug(bug_id)
135         if not bug.is_unassigned():
136             assigned_to_email = bug.assigned_to_email()
137             _log.info(u"Bug %s is already assigned to %s (%s)." % (bug_id, assigned_to_email, committers.committer_by_email(assigned_to_email)))
138             return
139
140         reviewed_patches = bug.reviewed_patches()
141         if not reviewed_patches:
142             _log.info("Bug %s has no non-obsolete patches, ignoring." % bug_id)
143             return
144
145         # We only need to do anything with this bug if one of the r+'d patches does not have a valid committer (cq+ set).
146         if self._patches_have_commiters(reviewed_patches):
147             _log.info("All reviewed patches on bug %s already have commit-queue+, ignoring." % bug_id)
148             return
149
150         latest_patch = reviewed_patches[-1]
151         attacher_email = latest_patch.attacher_email()
152         committer = committers.committer_by_email(attacher_email)
153         if not committer:
154             _log.info("Attacher %s is not a committer.  Bug %s likely needs commit-queue+." % (attacher_email, bug_id))
155             return
156
157         reassign_message = u"Attachment %s was posted by a committer and has review+, assigning to %s for commit." % (latest_patch.id(), committer.full_name)
158         self._tool.bugs.reassign_bug(bug_id, committer.bugzilla_email(), reassign_message)
159
160     def execute(self, options, args, tool):
161         for bug_id in tool.bugs.queries.fetch_bug_ids_from_pending_commit_list():
162             self._assign_bug_to_last_patch_attacher(bug_id)
163
164
165 class ObsoleteAttachments(AbstractSequencedCommand):
166     name = "obsolete-attachments"
167     help_text = "Mark all attachments on a bug as obsolete"
168     argument_names = "BUGID"
169     steps = [
170         steps.ObsoletePatches,
171     ]
172
173     def _prepare_state(self, options, args, tool):
174         return { "bug_id" : args[0] }
175
176
177 class AttachToBug(AbstractSequencedCommand):
178     name = "attach-to-bug"
179     help_text = "Attach the file to the bug"
180     argument_names = "BUGID FILEPATH"
181     steps = [
182         steps.AttachToBug,
183     ]
184
185     def _prepare_state(self, options, args, tool):
186         state = {}
187         state["bug_id"] = args[0]
188         state["filepath"] = args[1]
189         return state
190
191
192 class AbstractPatchUploadingCommand(AbstractSequencedCommand):
193     def _bug_id(self, options, args, tool, state):
194         # Perfer a bug id passed as an argument over a bug url in the diff (i.e. ChangeLogs).
195         bug_id = args and args[0]
196         if not bug_id:
197             changed_files = self._tool.scm().changed_files(options.git_commit)
198             state["changed_files"] = changed_files
199             bug_id = tool.checkout().bug_id_for_this_commit(options.git_commit, changed_files)
200         return bug_id
201
202     def _prepare_state(self, options, args, tool):
203         state = {}
204         state["bug_id"] = self._bug_id(options, args, tool, state)
205         if not state["bug_id"]:
206             _log.error("No bug id passed and no bug url found in ChangeLogs.")
207             sys.exit(1)
208         return state
209
210
211 class Post(AbstractPatchUploadingCommand):
212     name = "post"
213     help_text = "Attach the current working directory diff to a bug as a patch file"
214     argument_names = "[BUGID]"
215     steps = [
216         steps.ValidateChangeLogs,
217         steps.CheckStyle,
218         steps.ConfirmDiff,
219         steps.ObsoletePatches,
220         steps.SuggestReviewers,
221         steps.EnsureBugIsOpenAndAssigned,
222         steps.PostDiff,
223     ]
224
225
226 class LandSafely(AbstractPatchUploadingCommand):
227     name = "land-safely"
228     help_text = "Land the current diff via the commit-queue"
229     argument_names = "[BUGID]"
230     long_help = """land-safely updates the ChangeLog with the reviewer listed
231     in bugs.webkit.org for BUGID (or the bug ID detected from the ChangeLog).
232     The command then uploads the current diff to the bug and marks it for
233     commit by the commit-queue."""
234     show_in_main_help = True
235     steps = [
236         steps.UpdateChangeLogsWithReviewer,
237         steps.ValidateChangeLogs,
238         steps.ObsoletePatches,
239         steps.EnsureBugIsOpenAndAssigned,
240         steps.PostDiffForCommit,
241     ]
242
243
244 class HasLanded(AbstractPatchUploadingCommand):
245     name = "has-landed"
246     help_text = "Check that the current code was successfully landed and no changes remain."
247     argument_names = "[BUGID]"
248     steps = [
249         steps.HasLanded,
250     ]
251
252
253 class Prepare(AbstractSequencedCommand):
254     name = "prepare"
255     help_text = "Creates a bug (or prompts for an existing bug) and prepares the ChangeLogs"
256     argument_names = "[BUGID]"
257     steps = [
258         steps.PromptForBugOrTitle,
259         steps.CreateBug,
260         steps.PrepareChangeLog,
261     ]
262
263     def _prepare_state(self, options, args, tool):
264         bug_id = args and args[0]
265         return { "bug_id" : bug_id }
266
267
268 class Upload(AbstractPatchUploadingCommand):
269     name = "upload"
270     help_text = "Automates the process of uploading a patch for review"
271     argument_names = "[BUGID]"
272     show_in_main_help = True
273     steps = [
274         steps.ValidateChangeLogs,
275         steps.CheckStyle,
276         steps.PromptForBugOrTitle,
277         steps.CreateBug,
278         steps.PrepareChangeLog,
279         steps.EditChangeLog,
280         steps.ConfirmDiff,
281         steps.ObsoletePatches,
282         steps.SuggestReviewers,
283         steps.EnsureBugIsOpenAndAssigned,
284         steps.PostDiff,
285     ]
286     long_help = """upload uploads the current diff to bugs.webkit.org.
287     If no bug id is provided, upload will create a bug.
288     If the current diff does not have a ChangeLog, upload
289     will prepare a ChangeLog.  Once a patch is read, upload
290     will open the ChangeLogs for editing using the command in the
291     EDITOR environment variable and will display the diff using the
292     command in the PAGER environment variable."""
293
294     def _prepare_state(self, options, args, tool):
295         state = {}
296         state["bug_id"] = self._bug_id(options, args, tool, state)
297         return state
298
299
300 class EditChangeLogs(AbstractSequencedCommand):
301     name = "edit-changelogs"
302     help_text = "Opens modified ChangeLogs in $EDITOR"
303     show_in_main_help = True
304     steps = [
305         steps.EditChangeLog,
306     ]
307
308
309 class PostCommits(Command):
310     name = "post-commits"
311     help_text = "Attach a range of local commits to bugs as patch files"
312     argument_names = "COMMITISH"
313
314     def __init__(self):
315         options = [
316             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."),
317             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."),
318             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
319             steps.Options.obsolete_patches,
320             steps.Options.review,
321             steps.Options.request_commit,
322         ]
323         Command.__init__(self, options=options, requires_local_commits=True)
324
325     def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
326         comment_text = None
327         if (options.add_log_as_comment):
328             comment_text = commit_message.body(lstrip=True)
329             comment_text += "---\n"
330             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
331         return comment_text
332
333     def execute(self, options, args, tool):
334         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
335         if len(commit_ids) > 10:  # We could lower this limit, 10 is too many for one bug as-is.
336             _log.error("webkit-patch does not support attaching %s at once.  Are you sure you passed the right commit range?" % (pluralize(len(commit_ids), "patch")))
337             sys.exit(1)
338
339         have_obsoleted_patches = set()
340         for commit_id in commit_ids:
341             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
342
343             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
344             bug_id = options.bug_id or parse_bug_id_from_changelog(commit_message.message()) or parse_bug_id_from_changelog(tool.scm().create_patch(git_commit=commit_id))
345             if not bug_id:
346                 _log.info("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
347                 continue
348
349             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
350                 state = { "bug_id": bug_id }
351                 steps.ObsoletePatches(tool, options).run(state)
352                 have_obsoleted_patches.add(bug_id)
353
354             diff = tool.scm().create_patch(git_commit=commit_id)
355             description = options.description or commit_message.description(lstrip=True, strip_url=True)
356             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
357             tool.bugs.add_patch_to_bug(bug_id, diff, description, comment_text, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
358
359
360 # FIXME: This command needs to be brought into the modern age with steps and CommitInfo.
361 class MarkBugFixed(Command):
362     name = "mark-bug-fixed"
363     help_text = "Mark the specified bug as fixed"
364     argument_names = "[SVN_REVISION]"
365
366     def __init__(self):
367         options = [
368             make_option("--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."),
369             make_option("--comment", action="store", type="string", dest="comment", help="Text to include in bug comment."),
370             make_option("--open", action="store_true", default=False, dest="open_bug", help="Open bug in default web browser (Mac only)."),
371             make_option("--update-only", action="store_true", default=False, dest="update_only", help="Add comment to the bug, but do not close it."),
372         ]
373         Command.__init__(self, options=options)
374
375     # FIXME: We should be using checkout().changelog_entries_for_revision(...) instead here.
376     def _fetch_commit_log(self, tool, svn_revision):
377         if not svn_revision:
378             return tool.scm().last_svn_commit_log()
379         return tool.scm().svn_commit_log(svn_revision)
380
381     def _determine_bug_id_and_svn_revision(self, tool, bug_id, svn_revision):
382         commit_log = self._fetch_commit_log(tool, svn_revision)
383
384         if not bug_id:
385             bug_id = parse_bug_id_from_changelog(commit_log)
386
387         if not svn_revision:
388             match = re.search("^r(?P<svn_revision>\d+) \|", commit_log, re.MULTILINE)
389             if match:
390                 svn_revision = match.group('svn_revision')
391
392         if not bug_id or not svn_revision:
393             not_found = []
394             if not bug_id:
395                 not_found.append("bug id")
396             if not svn_revision:
397                 not_found.append("svn revision")
398             _log.error("Could not find %s on command-line or in %s."
399                   % (" or ".join(not_found), "r%s" % svn_revision if svn_revision else "last commit"))
400             sys.exit(1)
401
402         return (bug_id, svn_revision)
403
404     def execute(self, options, args, tool):
405         bug_id = options.bug_id
406
407         svn_revision = args and args[0]
408         if svn_revision:
409             if re.match("^r[0-9]+$", svn_revision, re.IGNORECASE):
410                 svn_revision = svn_revision[1:]
411             if not re.match("^[0-9]+$", svn_revision):
412                 _log.error("Invalid svn revision: '%s'" % svn_revision)
413                 sys.exit(1)
414
415         needs_prompt = False
416         if not bug_id or not svn_revision:
417             needs_prompt = True
418             (bug_id, svn_revision) = self._determine_bug_id_and_svn_revision(tool, bug_id, svn_revision)
419
420         _log.info("Bug: <%s> %s" % (tool.bugs.bug_url_for_bug_id(bug_id), tool.bugs.fetch_bug_dictionary(bug_id)["title"]))
421         _log.info("Revision: %s" % svn_revision)
422
423         if options.open_bug:
424             tool.user.open_url(tool.bugs.bug_url_for_bug_id(bug_id))
425
426         if needs_prompt:
427             if not tool.user.confirm("Is this correct?"):
428                 self._exit(1)
429
430         bug_comment = bug_comment_from_svn_revision(svn_revision)
431         if options.comment:
432             bug_comment = "%s\n\n%s" % (options.comment, bug_comment)
433
434         if options.update_only:
435             _log.info("Adding comment to Bug %s." % bug_id)
436             tool.bugs.post_comment_to_bug(bug_id, bug_comment)
437         else:
438             _log.info("Adding comment to Bug %s and marking as Resolved/Fixed." % bug_id)
439             tool.bugs.close_bug_as_fixed(bug_id, bug_comment)
440
441
442 # FIXME: Requires unit test.  Blocking issue: too complex for now.
443 class CreateBug(Command):
444     name = "create-bug"
445     help_text = "Create a bug from local changes or local commits"
446     argument_names = "[COMMITISH]"
447
448     def __init__(self):
449         options = [
450             steps.Options.cc,
451             steps.Options.component,
452             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
453             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
454             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
455         ]
456         Command.__init__(self, options=options)
457
458     def create_bug_from_commit(self, options, args, tool):
459         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
460         if len(commit_ids) > 3:
461             _log.error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
462             sys.exit(1)
463
464         commit_id = commit_ids[0]
465
466         bug_title = ""
467         comment_text = ""
468         if options.prompt:
469             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
470         else:
471             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
472             bug_title = commit_message.description(lstrip=True, strip_url=True)
473             comment_text = commit_message.body(lstrip=True)
474             comment_text += "---\n"
475             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
476
477         diff = tool.scm().create_patch(git_commit=commit_id)
478         bug_id = tool.bugs.create_bug(bug_title, comment_text, options.component, diff, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
479
480         if bug_id and len(commit_ids) > 1:
481             options.bug_id = bug_id
482             options.obsolete_patches = False
483             # FIXME: We should pass through --no-comment switch as well.
484             PostCommits.execute(self, options, commit_ids[1:], tool)
485
486     def create_bug_from_patch(self, options, args, tool):
487         bug_title = ""
488         comment_text = ""
489         if options.prompt:
490             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
491         else:
492             commit_message = tool.checkout().commit_message_for_this_commit(options.git_commit)
493             bug_title = commit_message.description(lstrip=True, strip_url=True)
494             comment_text = commit_message.body(lstrip=True)
495
496         diff = tool.scm().create_patch(options.git_commit)
497         bug_id = tool.bugs.create_bug(bug_title, comment_text, options.component, diff, "Patch", cc=options.cc, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
498
499     def prompt_for_bug_title_and_comment(self):
500         bug_title = User.prompt("Bug title: ")
501         # FIXME: User should provide a function for doing this multi-line prompt.
502         print "Bug comment (hit ^D on blank line to end):"
503         lines = sys.stdin.readlines()
504         try:
505             sys.stdin.seek(0, os.SEEK_END)
506         except IOError:
507             # Cygwin raises an Illegal Seek (errno 29) exception when the above
508             # seek() call is made. Ignoring it seems to cause no harm.
509             # FIXME: Figure out a way to get avoid the exception in the first
510             # place.
511             pass
512         comment_text = "".join(lines)
513         return (bug_title, comment_text)
514
515     def execute(self, options, args, tool):
516         if len(args):
517             if (not tool.scm().supports_local_commits()):
518                 _log.error("Extra arguments not supported; patch is taken from working directory.")
519                 sys.exit(1)
520             self.create_bug_from_commit(options, args, tool)
521         else:
522             self.create_bug_from_patch(options, args, tool)