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