1 #!/usr/bin/env python 2 # Copyright 2013 the V8 project authors. All rights reserved. 3 # Redistribution and use in source and binary forms, with or without 4 # modification, are permitted provided that the following conditions are 5 # met: 6 # 7 # * Redistributions of source code must retain the above copyright 8 # notice, this list of conditions and the following disclaimer. 9 # * Redistributions in binary form must reproduce the above 10 # copyright notice, this list of conditions and the following 11 # disclaimer in the documentation and/or other materials provided 12 # with the distribution. 13 # * Neither the name of Google Inc. nor the names of its 14 # contributors may be used to endorse or promote products derived 15 # from this software without specific prior written permission. 16 # 17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 29 import datetime 30 import os 31 import re 32 import subprocess 33 import sys 34 import textwrap 35 import time 36 import urllib2 37 38 PERSISTFILE_BASENAME = "PERSISTFILE_BASENAME" 39 TEMP_BRANCH = "TEMP_BRANCH" 40 BRANCHNAME = "BRANCHNAME" 41 DOT_GIT_LOCATION = "DOT_GIT_LOCATION" 42 VERSION_FILE = "VERSION_FILE" 43 CHANGELOG_FILE = "CHANGELOG_FILE" 44 CHANGELOG_ENTRY_FILE = "CHANGELOG_ENTRY_FILE" 45 COMMITMSG_FILE = "COMMITMSG_FILE" 46 PATCH_FILE = "PATCH_FILE" 47 48 49 def TextToFile(text, file_name): 50 with open(file_name, "w") as f: 51 f.write(text) 52 53 54 def AppendToFile(text, file_name): 55 with open(file_name, "a") as f: 56 f.write(text) 57 58 59 def LinesInFile(file_name): 60 with open(file_name) as f: 61 for line in f: 62 yield line 63 64 65 def FileToText(file_name): 66 with open(file_name) as f: 67 return f.read() 68 69 70 def MSub(rexp, replacement, text): 71 return re.sub(rexp, replacement, text, flags=re.MULTILINE) 72 73 74 def Fill80(line): 75 # Replace tabs and remove surrounding space. 76 line = re.sub(r"\t", r" ", line.strip()) 77 78 # Format with 8 characters indentation and line width 80. 79 return textwrap.fill(line, width=80, initial_indent=" ", 80 subsequent_indent=" ") 81 82 83 def GetLastChangeLogEntries(change_log_file): 84 result = [] 85 for line in LinesInFile(change_log_file): 86 if re.search(r"^\d{4}-\d{2}-\d{2}:", line) and result: break 87 result.append(line) 88 return "".join(result) 89 90 91 def MakeComment(text): 92 return MSub(r"^( ?)", "#", text) 93 94 95 def StripComments(text): 96 # Use split not splitlines to keep terminal newlines. 97 return "\n".join(filter(lambda x: not x.startswith("#"), text.split("\n"))) 98 99 100 def MakeChangeLogBody(commit_messages, auto_format=False): 101 result = "" 102 added_titles = set() 103 for (title, body, author) in commit_messages: 104 # TODO(machenbach): Better check for reverts. A revert should remove the 105 # original CL from the actual log entry. 106 title = title.strip() 107 if auto_format: 108 # Only add commits that set the LOG flag correctly. 109 log_exp = r"^[ \t]*LOG[ \t]*=[ \t]*(?:(?:Y(?:ES)?)|TRUE)" 110 if not re.search(log_exp, body, flags=re.I | re.M): 111 continue 112 # Never include reverts. 113 if title.startswith("Revert "): 114 continue 115 # Don't include duplicates. 116 if title in added_titles: 117 continue 118 119 # Add and format the commit's title and bug reference. Move dot to the end. 120 added_titles.add(title) 121 raw_title = re.sub(r"(\.|\?|!)$", "", title) 122 bug_reference = MakeChangeLogBugReference(body) 123 space = " " if bug_reference else "" 124 result += "%s\n" % Fill80("%s%s%s." % (raw_title, space, bug_reference)) 125 126 # Append the commit's author for reference if not in auto-format mode. 127 if not auto_format: 128 result += "%s\n" % Fill80("(%s)" % author.strip()) 129 130 result += "\n" 131 return result 132 133 134 def MakeChangeLogBugReference(body): 135 """Grep for "BUG=xxxx" lines in the commit message and convert them to 136 "(issue xxxx)". 137 """ 138 crbugs = [] 139 v8bugs = [] 140 141 def AddIssues(text): 142 ref = re.match(r"^BUG[ \t]*=[ \t]*(.+)$", text.strip()) 143 if not ref: 144 return 145 for bug in ref.group(1).split(","): 146 bug = bug.strip() 147 match = re.match(r"^v8:(\d+)$", bug) 148 if match: v8bugs.append(int(match.group(1))) 149 else: 150 match = re.match(r"^(?:chromium:)?(\d+)$", bug) 151 if match: crbugs.append(int(match.group(1))) 152 153 # Add issues to crbugs and v8bugs. 154 map(AddIssues, body.splitlines()) 155 156 # Filter duplicates, sort, stringify. 157 crbugs = map(str, sorted(set(crbugs))) 158 v8bugs = map(str, sorted(set(v8bugs))) 159 160 bug_groups = [] 161 def FormatIssues(prefix, bugs): 162 if len(bugs) > 0: 163 plural = "s" if len(bugs) > 1 else "" 164 bug_groups.append("%sissue%s %s" % (prefix, plural, ", ".join(bugs))) 165 166 FormatIssues("", v8bugs) 167 FormatIssues("Chromium ", crbugs) 168 169 if len(bug_groups) > 0: 170 return "(%s)" % ", ".join(bug_groups) 171 else: 172 return "" 173 174 175 # Some commands don't like the pipe, e.g. calling vi from within the script or 176 # from subscripts like git cl upload. 177 def Command(cmd, args="", prefix="", pipe=True): 178 # TODO(machenbach): Use timeout. 179 cmd_line = "%s %s %s" % (prefix, cmd, args) 180 print "Command: %s" % cmd_line 181 try: 182 if pipe: 183 return subprocess.check_output(cmd_line, shell=True) 184 else: 185 return subprocess.check_call(cmd_line, shell=True) 186 except subprocess.CalledProcessError: 187 return None 188 189 190 # Wrapper for side effects. 191 class SideEffectHandler(object): 192 def Command(self, cmd, args="", prefix="", pipe=True): 193 return Command(cmd, args, prefix, pipe) 194 195 def ReadLine(self): 196 return sys.stdin.readline().strip() 197 198 def ReadURL(self, url): 199 # pylint: disable=E1121 200 url_fh = urllib2.urlopen(url, None, 60) 201 try: 202 return url_fh.read() 203 finally: 204 url_fh.close() 205 206 def Sleep(self, seconds): 207 time.sleep(seconds) 208 209 def GetDate(self): 210 return datetime.date.today().strftime("%Y-%m-%d") 211 212 DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler() 213 214 215 class Step(object): 216 def __init__(self, text, requires, number, config, state, options, handler): 217 self._text = text 218 self._requires = requires 219 self._number = number 220 self._config = config 221 self._state = state 222 self._options = options 223 self._side_effect_handler = handler 224 assert self._number >= 0 225 assert self._config is not None 226 assert self._state is not None 227 assert self._side_effect_handler is not None 228 229 def Config(self, key): 230 return self._config[key] 231 232 def IsForced(self): 233 return self._options and self._options.f 234 235 def IsManual(self): 236 return self._options and self._options.m 237 238 def Run(self): 239 if self._requires: 240 self.RestoreIfUnset(self._requires) 241 if not self._state[self._requires]: 242 return 243 print ">>> Step %d: %s" % (self._number, self._text) 244 self.RunStep() 245 246 def RunStep(self): 247 raise NotImplementedError 248 249 def Retry(self, cb, retry_on=None, wait_plan=None): 250 """ Retry a function. 251 Params: 252 cb: The function to retry. 253 retry_on: A callback that takes the result of the function and returns 254 True if the function should be retried. A function throwing an 255 exception is always retried. 256 wait_plan: A list of waiting delays between retries in seconds. The 257 maximum number of retries is len(wait_plan). 258 """ 259 retry_on = retry_on or (lambda x: False) 260 wait_plan = list(wait_plan or []) 261 wait_plan.reverse() 262 while True: 263 got_exception = False 264 try: 265 result = cb() 266 except Exception: 267 got_exception = True 268 if got_exception or retry_on(result): 269 if not wait_plan: 270 raise Exception("Retried too often. Giving up.") 271 wait_time = wait_plan.pop() 272 print "Waiting for %f seconds." % wait_time 273 self._side_effect_handler.Sleep(wait_time) 274 print "Retrying..." 275 else: 276 return result 277 278 def ReadLine(self, default=None): 279 # Don't prompt in forced mode. 280 if not self.IsManual() and default is not None: 281 print "%s (forced)" % default 282 return default 283 else: 284 return self._side_effect_handler.ReadLine() 285 286 def Git(self, args="", prefix="", pipe=True, retry_on=None): 287 cmd = lambda: self._side_effect_handler.Command("git", args, prefix, pipe) 288 return self.Retry(cmd, retry_on, [5, 30]) 289 290 def Editor(self, args): 291 if not self.IsForced(): 292 return self._side_effect_handler.Command(os.environ["EDITOR"], args, 293 pipe=False) 294 295 def ReadURL(self, url, retry_on=None, wait_plan=None): 296 wait_plan = wait_plan or [3, 60, 600] 297 cmd = lambda: self._side_effect_handler.ReadURL(url) 298 return self.Retry(cmd, retry_on, wait_plan) 299 300 def GetDate(self): 301 return self._side_effect_handler.GetDate() 302 303 def Die(self, msg=""): 304 if msg != "": 305 print "Error: %s" % msg 306 print "Exiting" 307 raise Exception(msg) 308 309 def DieNoManualMode(self, msg=""): 310 if not self.IsManual(): 311 msg = msg or "Only available in manual mode." 312 self.Die(msg) 313 314 def Confirm(self, msg): 315 print "%s [Y/n] " % msg, 316 answer = self.ReadLine(default="Y") 317 return answer == "" or answer == "Y" or answer == "y" 318 319 def DeleteBranch(self, name): 320 git_result = self.Git("branch").strip() 321 for line in git_result.splitlines(): 322 if re.match(r".*\s+%s$" % name, line): 323 msg = "Branch %s exists, do you want to delete it?" % name 324 if self.Confirm(msg): 325 if self.Git("branch -D %s" % name) is None: 326 self.Die("Deleting branch '%s' failed." % name) 327 print "Branch %s deleted." % name 328 else: 329 msg = "Can't continue. Please delete branch %s and try again." % name 330 self.Die(msg) 331 332 def Persist(self, var, value): 333 value = value or "__EMPTY__" 334 TextToFile(value, "%s-%s" % (self._config[PERSISTFILE_BASENAME], var)) 335 336 def Restore(self, var): 337 value = FileToText("%s-%s" % (self._config[PERSISTFILE_BASENAME], var)) 338 value = value or self.Die("Variable '%s' could not be restored." % var) 339 return "" if value == "__EMPTY__" else value 340 341 def RestoreIfUnset(self, var_name): 342 if self._state.get(var_name) is None: 343 self._state[var_name] = self.Restore(var_name) 344 345 def InitialEnvironmentChecks(self): 346 # Cancel if this is not a git checkout. 347 if not os.path.exists(self._config[DOT_GIT_LOCATION]): 348 self.Die("This is not a git checkout, this script won't work for you.") 349 350 # Cancel if EDITOR is unset or not executable. 351 if (not self.IsForced() and (not os.environ.get("EDITOR") or 352 Command("which", os.environ["EDITOR"]) is None)): 353 self.Die("Please set your EDITOR environment variable, you'll need it.") 354 355 def CommonPrepare(self): 356 # Check for a clean workdir. 357 if self.Git("status -s -uno").strip() != "": 358 self.Die("Workspace is not clean. Please commit or undo your changes.") 359 360 # Persist current branch. 361 current_branch = "" 362 git_result = self.Git("status -s -b -uno").strip() 363 for line in git_result.splitlines(): 364 match = re.match(r"^## (.+)", line) 365 if match: 366 current_branch = match.group(1) 367 break 368 self.Persist("current_branch", current_branch) 369 370 # Fetch unfetched revisions. 371 if self.Git("svn fetch") is None: 372 self.Die("'git svn fetch' failed.") 373 374 def PrepareBranch(self): 375 # Get ahold of a safe temporary branch and check it out. 376 self.RestoreIfUnset("current_branch") 377 if self._state["current_branch"] != self._config[TEMP_BRANCH]: 378 self.DeleteBranch(self._config[TEMP_BRANCH]) 379 self.Git("checkout -b %s" % self._config[TEMP_BRANCH]) 380 381 # Delete the branch that will be created later if it exists already. 382 self.DeleteBranch(self._config[BRANCHNAME]) 383 384 def CommonCleanup(self): 385 self.RestoreIfUnset("current_branch") 386 self.Git("checkout -f %s" % self._state["current_branch"]) 387 if self._config[TEMP_BRANCH] != self._state["current_branch"]: 388 self.Git("branch -D %s" % self._config[TEMP_BRANCH]) 389 if self._config[BRANCHNAME] != self._state["current_branch"]: 390 self.Git("branch -D %s" % self._config[BRANCHNAME]) 391 392 # Clean up all temporary files. 393 Command("rm", "-f %s*" % self._config[PERSISTFILE_BASENAME]) 394 395 def ReadAndPersistVersion(self, prefix=""): 396 def ReadAndPersist(var_name, def_name): 397 match = re.match(r"^#define %s\s+(\d*)" % def_name, line) 398 if match: 399 value = match.group(1) 400 self.Persist("%s%s" % (prefix, var_name), value) 401 self._state["%s%s" % (prefix, var_name)] = value 402 for line in LinesInFile(self._config[VERSION_FILE]): 403 for (var_name, def_name) in [("major", "MAJOR_VERSION"), 404 ("minor", "MINOR_VERSION"), 405 ("build", "BUILD_NUMBER"), 406 ("patch", "PATCH_LEVEL")]: 407 ReadAndPersist(var_name, def_name) 408 409 def RestoreVersionIfUnset(self, prefix=""): 410 for v in ["major", "minor", "build", "patch"]: 411 self.RestoreIfUnset("%s%s" % (prefix, v)) 412 413 def WaitForLGTM(self): 414 print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit " 415 "your change. (If you need to iterate on the patch or double check " 416 "that it's sane, do so in another shell, but remember to not " 417 "change the headline of the uploaded CL.") 418 answer = "" 419 while answer != "LGTM": 420 print "> ", 421 answer = self.ReadLine("LGTM" if self.IsForced() else None) 422 if answer != "LGTM": 423 print "That was not 'LGTM'." 424 425 def WaitForResolvingConflicts(self, patch_file): 426 print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", " 427 "or resolve the conflicts, stage *all* touched files with " 428 "'git add', and type \"RESOLVED<Return>\"") 429 self.DieNoManualMode() 430 answer = "" 431 while answer != "RESOLVED": 432 if answer == "ABORT": 433 self.Die("Applying the patch failed.") 434 if answer != "": 435 print "That was not 'RESOLVED' or 'ABORT'." 436 print "> ", 437 answer = self.ReadLine() 438 439 # Takes a file containing the patch to apply as first argument. 440 def ApplyPatch(self, patch_file, reverse_patch=""): 441 args = "apply --index --reject %s \"%s\"" % (reverse_patch, patch_file) 442 if self.Git(args) is None: 443 self.WaitForResolvingConflicts(patch_file) 444 445 446 class UploadStep(Step): 447 MESSAGE = "Upload for code review." 448 449 def RunStep(self): 450 if self._options.r: 451 print "Using account %s for review." % self._options.r 452 reviewer = self._options.r 453 else: 454 print "Please enter the email address of a V8 reviewer for your patch: ", 455 self.DieNoManualMode("A reviewer must be specified in forced mode.") 456 reviewer = self.ReadLine() 457 force_flag = " -f" if not self.IsManual() else "" 458 args = "cl upload -r \"%s\" --send-mail%s" % (reviewer, force_flag) 459 # TODO(machenbach): Check output in forced mode. Verify that all required 460 # base files were uploaded, if not retry. 461 if self.Git(args, pipe=False) is None: 462 self.Die("'git cl upload' failed, please try again.") 463 464 465 def MakeStep(step_class=Step, number=0, state=None, config=None, 466 options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER): 467 # Allow to pass in empty dictionaries. 468 state = state if state is not None else {} 469 config = config if config is not None else {} 470 471 try: 472 message = step_class.MESSAGE 473 except AttributeError: 474 message = step_class.__name__ 475 try: 476 requires = step_class.REQUIRES 477 except AttributeError: 478 requires = None 479 480 return step_class(message, requires, number=number, config=config, 481 state=state, options=options, 482 handler=side_effect_handler) 483 484 485 def RunScript(step_classes, 486 config, 487 options, 488 side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER): 489 state = {} 490 steps = [] 491 for (number, step_class) in enumerate(step_classes): 492 steps.append(MakeStep(step_class, number, state, config, 493 options, side_effect_handler)) 494 495 for step in steps[options.s:]: 496 step.Run() 497