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