[XCode] webkit-patch should run sort-Xcode-project-file
[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.SortXcodeProjectFiles,
261         steps.PrepareChangeLog,
262     ]
263
264     def _prepare_state(self, options, args, tool):
265         bug_id = args and args[0]
266         return { "bug_id" : bug_id }
267
268
269 class Upload(AbstractPatchUploadingCommand):
270     name = "upload"
271     help_text = "Automates the process of uploading a patch for review"
272     argument_names = "[BUGID]"
273     show_in_main_help = True
274     steps = [
275         steps.ValidateChangeLogs,
276         steps.CheckStyle,
277         steps.PromptForBugOrTitle,
278         steps.CreateBug,
279         steps.SortXcodeProjectFiles,
280         steps.PrepareChangeLog,
281         steps.EditChangeLog,
282         steps.ConfirmDiff,
283         steps.ObsoletePatches,
284         steps.SuggestReviewers,
285         steps.EnsureBugIsOpenAndAssigned,
286         steps.PostDiff,
287     ]
288     long_help = """upload uploads the current diff to bugs.webkit.org.
289     If no bug id is provided, upload will create a bug.
290     If the current diff does not have a ChangeLog, upload
291     will prepare a ChangeLog.  Once a patch is read, upload
292     will open the ChangeLogs for editing using the command in the
293     EDITOR environment variable and will display the diff using the
294     command in the PAGER environment variable."""
295
296     def _prepare_state(self, options, args, tool):
297         state = {}
298         state["bug_id"] = self._bug_id(options, args, tool, state)
299         return state
300
301
302 class EditChangeLogs(AbstractSequencedCommand):
303     name = "edit-changelogs"
304     help_text = "Opens modified ChangeLogs in $EDITOR"
305     show_in_main_help = True
306     steps = [
307         steps.EditChangeLog,
308     ]
309
310
311 class PostCommits(Command):
312     name = "post-commits"
313     help_text = "Attach a range of local commits to bugs as patch files"
314     argument_names = "COMMITISH"
315
316     def __init__(self):
317         options = [
318             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."),
319             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."),
320             make_option("-m", "--description", action="store", type="string", dest="description", help="Description string for the attachment (default: description from commit message)"),
321             steps.Options.obsolete_patches,
322             steps.Options.review,
323             steps.Options.request_commit,
324         ]
325         Command.__init__(self, options=options, requires_local_commits=True)
326
327     def _comment_text_for_commit(self, options, commit_message, tool, commit_id):
328         comment_text = None
329         if (options.add_log_as_comment):
330             comment_text = commit_message.body(lstrip=True)
331             comment_text += "---\n"
332             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
333         return comment_text
334
335     def execute(self, options, args, tool):
336         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
337         if len(commit_ids) > 10:  # We could lower this limit, 10 is too many for one bug as-is.
338             _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")))
339             sys.exit(1)
340
341         have_obsoleted_patches = set()
342         for commit_id in commit_ids:
343             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
344
345             # Prefer --bug-id=, then a bug url in the commit message, then a bug url in the entire commit diff (i.e. ChangeLogs).
346             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))
347             if not bug_id:
348                 _log.info("Skipping %s: No bug id found in commit or specified with --bug-id." % commit_id)
349                 continue
350
351             if options.obsolete_patches and bug_id not in have_obsoleted_patches:
352                 state = { "bug_id": bug_id }
353                 steps.ObsoletePatches(tool, options).run(state)
354                 have_obsoleted_patches.add(bug_id)
355
356             diff = tool.scm().create_patch(git_commit=commit_id)
357             description = options.description or commit_message.description(lstrip=True, strip_url=True)
358             comment_text = self._comment_text_for_commit(options, commit_message, tool, commit_id)
359             tool.bugs.add_patch_to_bug(bug_id, diff, description, comment_text, mark_for_review=options.review, mark_for_commit_queue=options.request_commit)
360
361
362 # FIXME: This command needs to be brought into the modern age with steps and CommitInfo.
363 class MarkBugFixed(Command):
364     name = "mark-bug-fixed"
365     help_text = "Mark the specified bug as fixed"
366     argument_names = "[SVN_REVISION]"
367
368     def __init__(self):
369         options = [
370             make_option("--bug-id", action="store", type="string", dest="bug_id", help="Specify bug id if no URL is provided in the commit log."),
371             make_option("--comment", action="store", type="string", dest="comment", help="Text to include in bug comment."),
372             make_option("--open", action="store_true", default=False, dest="open_bug", help="Open bug in default web browser (Mac only)."),
373             make_option("--update-only", action="store_true", default=False, dest="update_only", help="Add comment to the bug, but do not close it."),
374         ]
375         Command.__init__(self, options=options)
376
377     # FIXME: We should be using checkout().changelog_entries_for_revision(...) instead here.
378     def _fetch_commit_log(self, tool, svn_revision):
379         if not svn_revision:
380             return tool.scm().last_svn_commit_log()
381         return tool.scm().svn_commit_log(svn_revision)
382
383     def _determine_bug_id_and_svn_revision(self, tool, bug_id, svn_revision):
384         commit_log = self._fetch_commit_log(tool, svn_revision)
385
386         if not bug_id:
387             bug_id = parse_bug_id_from_changelog(commit_log)
388
389         if not svn_revision:
390             match = re.search("^r(?P<svn_revision>\d+) \|", commit_log, re.MULTILINE)
391             if match:
392                 svn_revision = match.group('svn_revision')
393
394         if not bug_id or not svn_revision:
395             not_found = []
396             if not bug_id:
397                 not_found.append("bug id")
398             if not svn_revision:
399                 not_found.append("svn revision")
400             _log.error("Could not find %s on command-line or in %s."
401                   % (" or ".join(not_found), "r%s" % svn_revision if svn_revision else "last commit"))
402             sys.exit(1)
403
404         return (bug_id, svn_revision)
405
406     def execute(self, options, args, tool):
407         bug_id = options.bug_id
408
409         svn_revision = args and args[0]
410         if svn_revision:
411             if re.match("^r[0-9]+$", svn_revision, re.IGNORECASE):
412                 svn_revision = svn_revision[1:]
413             if not re.match("^[0-9]+$", svn_revision):
414                 _log.error("Invalid svn revision: '%s'" % svn_revision)
415                 sys.exit(1)
416
417         needs_prompt = False
418         if not bug_id or not svn_revision:
419             needs_prompt = True
420             (bug_id, svn_revision) = self._determine_bug_id_and_svn_revision(tool, bug_id, svn_revision)
421
422         _log.info("Bug: <%s> %s" % (tool.bugs.bug_url_for_bug_id(bug_id), tool.bugs.fetch_bug_dictionary(bug_id)["title"]))
423         _log.info("Revision: %s" % svn_revision)
424
425         if options.open_bug:
426             tool.user.open_url(tool.bugs.bug_url_for_bug_id(bug_id))
427
428         if needs_prompt:
429             if not tool.user.confirm("Is this correct?"):
430                 self._exit(1)
431
432         bug_comment = bug_comment_from_svn_revision(svn_revision)
433         if options.comment:
434             bug_comment = "%s\n\n%s" % (options.comment, bug_comment)
435
436         if options.update_only:
437             _log.info("Adding comment to Bug %s." % bug_id)
438             tool.bugs.post_comment_to_bug(bug_id, bug_comment)
439         else:
440             _log.info("Adding comment to Bug %s and marking as Resolved/Fixed." % bug_id)
441             tool.bugs.close_bug_as_fixed(bug_id, bug_comment)
442
443
444 # FIXME: Requires unit test.  Blocking issue: too complex for now.
445 class CreateBug(Command):
446     name = "create-bug"
447     help_text = "Create a bug from local changes or local commits"
448     argument_names = "[COMMITISH]"
449
450     def __init__(self):
451         options = [
452             steps.Options.cc,
453             steps.Options.component,
454             make_option("--no-prompt", action="store_false", dest="prompt", default=True, help="Do not prompt for bug title and comment; use commit log instead."),
455             make_option("--no-review", action="store_false", dest="review", default=True, help="Do not mark the patch for review."),
456             make_option("--request-commit", action="store_true", dest="request_commit", default=False, help="Mark the patch as needing auto-commit after review."),
457         ]
458         Command.__init__(self, options=options)
459
460     def create_bug_from_commit(self, options, args, tool):
461         commit_ids = tool.scm().commit_ids_from_commitish_arguments(args)
462         if len(commit_ids) > 3:
463             _log.error("Are you sure you want to create one bug with %s patches?" % len(commit_ids))
464             sys.exit(1)
465
466         commit_id = commit_ids[0]
467
468         bug_title = ""
469         comment_text = ""
470         if options.prompt:
471             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
472         else:
473             commit_message = tool.scm().commit_message_for_local_commit(commit_id)
474             bug_title = commit_message.description(lstrip=True, strip_url=True)
475             comment_text = commit_message.body(lstrip=True)
476             comment_text += "---\n"
477             comment_text += tool.scm().files_changed_summary_for_commit(commit_id)
478
479         diff = tool.scm().create_patch(git_commit=commit_id)
480         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)
481
482         if bug_id and len(commit_ids) > 1:
483             options.bug_id = bug_id
484             options.obsolete_patches = False
485             # FIXME: We should pass through --no-comment switch as well.
486             PostCommits.execute(self, options, commit_ids[1:], tool)
487
488     def create_bug_from_patch(self, options, args, tool):
489         bug_title = ""
490         comment_text = ""
491         if options.prompt:
492             (bug_title, comment_text) = self.prompt_for_bug_title_and_comment()
493         else:
494             commit_message = tool.checkout().commit_message_for_this_commit(options.git_commit)
495             bug_title = commit_message.description(lstrip=True, strip_url=True)
496             comment_text = commit_message.body(lstrip=True)
497
498         diff = tool.scm().create_patch(options.git_commit)
499         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)
500
501     def prompt_for_bug_title_and_comment(self):
502         bug_title = User.prompt("Bug title: ")
503         # FIXME: User should provide a function for doing this multi-line prompt.
504         print "Bug comment (hit ^D on blank line to end):"
505         lines = sys.stdin.readlines()
506         try:
507             sys.stdin.seek(0, os.SEEK_END)
508         except IOError:
509             # Cygwin raises an Illegal Seek (errno 29) exception when the above
510             # seek() call is made. Ignoring it seems to cause no harm.
511             # FIXME: Figure out a way to get avoid the exception in the first
512             # place.
513             pass
514         comment_text = "".join(lines)
515         return (bug_title, comment_text)
516
517     def execute(self, options, args, tool):
518         if len(args):
519             if (not tool.scm().supports_local_commits()):
520                 _log.error("Extra arguments not supported; patch is taken from working directory.")
521                 sys.exit(1)
522             self.create_bug_from_commit(options, args, tool)
523         else:
524             self.create_bug_from_patch(options, args, tool)