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 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.config.committers import CommitterList 40 from webkitpy.common.net.bugzilla import parse_bug_id_from_changelog 41 from webkitpy.common.system.deprecated_logging import error, log 42 from webkitpy.common.system.user import User 43 from webkitpy.thirdparty.mock import Mock 44 from webkitpy.tool.commands.abstractsequencedcommand import AbstractSequencedCommand 45 from webkitpy.tool.comments import bug_comment_from_svn_revision 46 from webkitpy.tool.grammar import pluralize, join_with_separators 47 from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand 48 49 50 class 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 66 class 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 96 class 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 121 class 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 164 class 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 176 class 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 191 class 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 209 class 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 223 class 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 240 class 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 255 class 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 286 class 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 295 class 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. 346 class 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. 425 class 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