1#!/usr/bin/env python3 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 29import argparse 30import datetime 31from distutils.version import LooseVersion 32import glob 33import imp 34import json 35import os 36import re 37import shutil 38import subprocess 39import sys 40import textwrap 41import time 42import urllib 43 44from git_recipes import GitRecipesMixin 45from git_recipes import GitFailedException 46 47import http.client as httplib 48import urllib.request as urllib2 49 50 51DAY_IN_SECONDS = 24 * 60 * 60 52PUSH_MSG_GIT_RE = re.compile(r".* \(based on (?P<git_rev>[a-fA-F0-9]+)\)$") 53PUSH_MSG_NEW_RE = re.compile(r"^Version \d+\.\d+\.\d+$") 54VERSION_FILE = os.path.join("include", "v8-version.h") 55WATCHLISTS_FILE = "WATCHLISTS" 56RELEASE_WORKDIR = "/tmp/v8-release-scripts-work-dir/" 57 58# V8 base directory. 59V8_BASE = os.path.dirname( 60 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 61 62# Add our copy of depot_tools to the PATH as many scripts use tools from there, 63# e.g. git-cl, fetch, git-new-branch etc, and we can not depend on depot_tools 64# being in the PATH on the LUCI bots. 65path_to_depot_tools = os.path.join(V8_BASE, 'third_party', 'depot_tools') 66new_path = path_to_depot_tools + os.pathsep + os.environ.get('PATH') 67os.environ['PATH'] = new_path 68 69 70def TextToFile(text, file_name): 71 with open(file_name, "w") as f: 72 f.write(text) 73 74 75def AppendToFile(text, file_name): 76 with open(file_name, "a") as f: 77 f.write(text) 78 79 80def LinesInFile(file_name): 81 with open(file_name) as f: 82 for line in f: 83 yield line 84 85 86def FileToText(file_name): 87 with open(file_name) as f: 88 return f.read() 89 90 91def MSub(rexp, replacement, text): 92 return re.sub(rexp, replacement, text, flags=re.MULTILINE) 93 94 95# Some commands don't like the pipe, e.g. calling vi from within the script or 96# from subscripts like git cl upload. 97def Command(cmd, args="", prefix="", pipe=True, cwd=None): 98 cwd = cwd or os.getcwd() 99 # TODO(machenbach): Use timeout. 100 cmd_line = "%s %s %s" % (prefix, cmd, args) 101 print("Command: %s" % cmd_line) 102 print("in %s" % cwd) 103 sys.stdout.flush() 104 try: 105 if pipe: 106 return subprocess.check_output(cmd_line, shell=True, cwd=cwd).decode('utf-8') 107 else: 108 return subprocess.check_call(cmd_line, shell=True, cwd=cwd) 109 except subprocess.CalledProcessError: 110 return None 111 finally: 112 sys.stdout.flush() 113 sys.stderr.flush() 114 115 116def SanitizeVersionTag(tag): 117 version_without_prefix = re.compile(r"^\d+\.\d+\.\d+(?:\.\d+)?$") 118 version_with_prefix = re.compile(r"^tags\/\d+\.\d+\.\d+(?:\.\d+)?$") 119 120 if version_without_prefix.match(tag): 121 return tag 122 elif version_with_prefix.match(tag): 123 return tag[len("tags/"):] 124 else: 125 return None 126 127 128def NormalizeVersionTags(version_tags): 129 normalized_version_tags = [] 130 131 # Remove tags/ prefix because of packed refs. 132 for current_tag in version_tags: 133 version_tag = SanitizeVersionTag(current_tag) 134 if version_tag != None: 135 normalized_version_tags.append(version_tag) 136 137 return normalized_version_tags 138 139 140# Wrapper for side effects. 141class SideEffectHandler(object): # pragma: no cover 142 def Call(self, fun, *args, **kwargs): 143 return fun(*args, **kwargs) 144 145 def Command(self, cmd, args="", prefix="", pipe=True, cwd=None): 146 return Command(cmd, args, prefix, pipe, cwd=cwd) 147 148 def ReadLine(self): 149 return sys.stdin.readline().strip() 150 151 def ReadURL(self, url, params=None): 152 # pylint: disable=E1121 153 url_fh = urllib2.urlopen(url, params, 60) 154 try: 155 return url_fh.read() 156 finally: 157 url_fh.close() 158 159 def ReadClusterFuzzAPI(self, api_key, **params): 160 params["api_key"] = api_key.strip() 161 params = urllib.urlencode(params) 162 163 headers = {"Content-type": "application/x-www-form-urlencoded"} 164 165 conn = httplib.HTTPSConnection("backend-dot-cluster-fuzz.appspot.com") 166 conn.request("POST", "/_api/", params, headers) 167 168 response = conn.getresponse() 169 data = response.read() 170 171 try: 172 return json.loads(data) 173 except: 174 print(data) 175 print("ERROR: Could not read response. Is your key valid?") 176 raise 177 178 def Sleep(self, seconds): 179 time.sleep(seconds) 180 181 def GetUTCStamp(self): 182 return time.mktime(datetime.datetime.utcnow().timetuple()) 183 184DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler() 185 186 187class NoRetryException(Exception): 188 pass 189 190 191class VCInterface(object): 192 def InjectStep(self, step): 193 self.step=step 194 195 def Pull(self): 196 raise NotImplementedError() 197 198 def Fetch(self): 199 raise NotImplementedError() 200 201 def GetTags(self): 202 raise NotImplementedError() 203 204 def GetBranches(self): 205 raise NotImplementedError() 206 207 def MainBranch(self): 208 raise NotImplementedError() 209 210 def CandidateBranch(self): 211 raise NotImplementedError() 212 213 def RemoteMainBranch(self): 214 raise NotImplementedError() 215 216 def RemoteCandidateBranch(self): 217 raise NotImplementedError() 218 219 def RemoteBranch(self, name): 220 raise NotImplementedError() 221 222 def CLLand(self): 223 raise NotImplementedError() 224 225 def Tag(self, tag, remote, message): 226 """Sets a tag for the current commit. 227 228 Assumptions: The commit already landed and the commit message is unique. 229 """ 230 raise NotImplementedError() 231 232 233class GitInterface(VCInterface): 234 def Pull(self): 235 self.step.GitPull() 236 237 def Fetch(self): 238 self.step.Git("fetch") 239 240 def GetTags(self): 241 return self.step.Git("tag").strip().splitlines() 242 243 def GetBranches(self): 244 # Get relevant remote branches, e.g. "branch-heads/3.25". 245 branches = filter( 246 lambda s: re.match(r"^branch\-heads/\d+\.\d+$", s), 247 self.step.GitRemotes()) 248 # Remove 'branch-heads/' prefix. 249 return [b[13:] for b in branches] 250 251 def MainBranch(self): 252 return "main" 253 254 def CandidateBranch(self): 255 return "candidates" 256 257 def RemoteMainBranch(self): 258 return "origin/main" 259 260 def RemoteCandidateBranch(self): 261 return "origin/candidates" 262 263 def RemoteBranch(self, name): 264 # Assume that if someone "fully qualified" the ref, they know what they 265 # want. 266 if name.startswith('refs/'): 267 return name 268 if name in ["candidates", "main"]: 269 return "refs/remotes/origin/%s" % name 270 try: 271 # Check if branch is in heads. 272 if self.step.Git("show-ref refs/remotes/origin/%s" % name).strip(): 273 return "refs/remotes/origin/%s" % name 274 except GitFailedException: 275 pass 276 try: 277 # Check if branch is in branch-heads. 278 if self.step.Git("show-ref refs/remotes/branch-heads/%s" % name).strip(): 279 return "refs/remotes/branch-heads/%s" % name 280 except GitFailedException: 281 pass 282 self.Die("Can't find remote of %s" % name) 283 284 def Tag(self, tag, remote, message): 285 # Wait for the commit to appear. Assumes unique commit message titles (this 286 # is the case for all automated merge and push commits - also no title is 287 # the prefix of another title). 288 commit = None 289 for wait_interval in [10, 30, 60, 60, 60, 60, 60]: 290 self.step.Git("fetch") 291 commit = self.step.GitLog(n=1, format="%H", grep=message, branch=remote) 292 if commit: 293 break 294 print("The commit has not replicated to git. Waiting for %s seconds." % 295 wait_interval) 296 self.step._side_effect_handler.Sleep(wait_interval) 297 else: 298 self.step.Die("Couldn't determine commit for setting the tag. Maybe the " 299 "git updater is lagging behind?") 300 301 self.step.Git("tag %s %s" % (tag, commit)) 302 self.step.Git("push origin refs/tags/%s:refs/tags/%s" % (tag, tag)) 303 304 def CLLand(self): 305 self.step.GitCLLand() 306 307 308class Step(GitRecipesMixin): 309 def __init__(self, text, number, config, state, options, handler): 310 self._text = text 311 self._number = number 312 self._config = config 313 self._state = state 314 self._options = options 315 self._side_effect_handler = handler 316 self.vc = GitInterface() 317 self.vc.InjectStep(self) 318 319 # The testing configuration might set a different default cwd. 320 self.default_cwd = (self._config.get("DEFAULT_CWD") or 321 os.path.join(self._options.work_dir, "v8")) 322 323 assert self._number >= 0 324 assert self._config is not None 325 assert self._state is not None 326 assert self._side_effect_handler is not None 327 328 def __getitem__(self, key): 329 # Convenience method to allow direct [] access on step classes for 330 # manipulating the backed state dict. 331 return self._state.get(key) 332 333 def __setitem__(self, key, value): 334 # Convenience method to allow direct [] access on step classes for 335 # manipulating the backed state dict. 336 self._state[key] = value 337 338 def Config(self, key): 339 return self._config[key] 340 341 def Run(self): 342 # Restore state. 343 state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"] 344 if not self._state and os.path.exists(state_file): 345 self._state.update(json.loads(FileToText(state_file))) 346 347 print(">>> Step %d: %s" % (self._number, self._text)) 348 try: 349 return self.RunStep() 350 finally: 351 # Persist state. 352 TextToFile(json.dumps(self._state), state_file) 353 354 def RunStep(self): # pragma: no cover 355 raise NotImplementedError 356 357 def Retry(self, cb, retry_on=None, wait_plan=None): 358 """ Retry a function. 359 Params: 360 cb: The function to retry. 361 retry_on: A callback that takes the result of the function and returns 362 True if the function should be retried. A function throwing an 363 exception is always retried. 364 wait_plan: A list of waiting delays between retries in seconds. The 365 maximum number of retries is len(wait_plan). 366 """ 367 retry_on = retry_on or (lambda x: False) 368 wait_plan = list(wait_plan or []) 369 wait_plan.reverse() 370 while True: 371 got_exception = False 372 try: 373 result = cb() 374 except NoRetryException as e: 375 raise e 376 except Exception as e: 377 got_exception = e 378 if got_exception or retry_on(result): 379 if not wait_plan: # pragma: no cover 380 raise Exception("Retried too often. Giving up. Reason: %s" % 381 str(got_exception)) 382 wait_time = wait_plan.pop() 383 print("Waiting for %f seconds." % wait_time) 384 self._side_effect_handler.Sleep(wait_time) 385 print("Retrying...") 386 else: 387 return result 388 389 def ReadLine(self, default=None): 390 # Don't prompt in forced mode. 391 if self._options.force_readline_defaults and default is not None: 392 print("%s (forced)" % default) 393 return default 394 else: 395 return self._side_effect_handler.ReadLine() 396 397 def Command(self, name, args, cwd=None): 398 cmd = lambda: self._side_effect_handler.Command( 399 name, args, "", True, cwd=cwd or self.default_cwd) 400 return self.Retry(cmd, None, [5]) 401 402 def Git(self, args="", prefix="", pipe=True, retry_on=None, cwd=None): 403 cmd = lambda: self._side_effect_handler.Command( 404 "git", args, prefix, pipe, cwd=cwd or self.default_cwd) 405 result = self.Retry(cmd, retry_on, [5, 30]) 406 if result is None: 407 raise GitFailedException("'git %s' failed." % args) 408 return result 409 410 def Editor(self, args): 411 if self._options.requires_editor: 412 return self._side_effect_handler.Command( 413 os.environ["EDITOR"], 414 args, 415 pipe=False, 416 cwd=self.default_cwd) 417 418 def ReadURL(self, url, params=None, retry_on=None, wait_plan=None): 419 wait_plan = wait_plan or [3, 60, 600] 420 cmd = lambda: self._side_effect_handler.ReadURL(url, params) 421 return self.Retry(cmd, retry_on, wait_plan) 422 423 def Die(self, msg=""): 424 if msg != "": 425 print("Error: %s" % msg) 426 print("Exiting") 427 raise Exception(msg) 428 429 def DieNoManualMode(self, msg=""): 430 if not self._options.manual: # pragma: no cover 431 msg = msg or "Only available in manual mode." 432 self.Die(msg) 433 434 def Confirm(self, msg): 435 print("%s [Y/n] " % msg, end=' ') 436 answer = self.ReadLine(default="Y") 437 return answer == "" or answer == "Y" or answer == "y" 438 439 def DeleteBranch(self, name, cwd=None): 440 for line in self.GitBranch(cwd=cwd).splitlines(): 441 if re.match(r"\*?\s*%s$" % re.escape(name), line): 442 msg = "Branch %s exists, do you want to delete it?" % name 443 if self.Confirm(msg): 444 self.GitDeleteBranch(name, cwd=cwd) 445 print("Branch %s deleted." % name) 446 else: 447 msg = "Can't continue. Please delete branch %s and try again." % name 448 self.Die(msg) 449 450 def InitialEnvironmentChecks(self, cwd): 451 # Cancel if this is not a git checkout. 452 if not os.path.exists(os.path.join(cwd, ".git")): # pragma: no cover 453 self.Die("%s is not a git checkout. If you know what you're doing, try " 454 "deleting it and rerunning this script." % cwd) 455 456 # Cancel if EDITOR is unset or not executable. 457 if (self._options.requires_editor and (not os.environ.get("EDITOR") or 458 self.Command( 459 "which", os.environ["EDITOR"]) is None)): # pragma: no cover 460 self.Die("Please set your EDITOR environment variable, you'll need it.") 461 462 def CommonPrepare(self): 463 # Check for a clean workdir. 464 if not self.GitIsWorkdirClean(): # pragma: no cover 465 self.Die("Workspace is not clean. Please commit or undo your changes.") 466 467 # Checkout main in case the script was left on a work branch. 468 self.GitCheckout('origin/main') 469 470 # Fetch unfetched revisions. 471 self.vc.Fetch() 472 473 def PrepareBranch(self): 474 # Delete the branch that will be created later if it exists already. 475 self.DeleteBranch(self._config["BRANCHNAME"]) 476 477 def CommonCleanup(self): 478 self.GitCheckout('origin/main') 479 self.GitDeleteBranch(self._config["BRANCHNAME"]) 480 481 # Clean up all temporary files. 482 for f in glob.iglob("%s*" % self._config["PERSISTFILE_BASENAME"]): 483 if os.path.isfile(f): 484 os.remove(f) 485 if os.path.isdir(f): 486 shutil.rmtree(f) 487 488 def ReadAndPersistVersion(self, prefix=""): 489 def ReadAndPersist(var_name, def_name): 490 match = re.match(r"^#define %s\s+(\d*)" % def_name, line) 491 if match: 492 value = match.group(1) 493 self["%s%s" % (prefix, var_name)] = value 494 for line in LinesInFile(os.path.join(self.default_cwd, VERSION_FILE)): 495 for (var_name, def_name) in [("major", "V8_MAJOR_VERSION"), 496 ("minor", "V8_MINOR_VERSION"), 497 ("build", "V8_BUILD_NUMBER"), 498 ("patch", "V8_PATCH_LEVEL")]: 499 ReadAndPersist(var_name, def_name) 500 501 def WaitForLGTM(self): 502 print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit " 503 "your change. (If you need to iterate on the patch or double check " 504 "that it's sensible, do so in another shell, but remember to not " 505 "change the headline of the uploaded CL.") 506 answer = "" 507 while answer != "LGTM": 508 print("> ", end=' ') 509 answer = self.ReadLine(None if self._options.wait_for_lgtm else "LGTM") 510 if answer != "LGTM": 511 print("That was not 'LGTM'.") 512 513 def WaitForResolvingConflicts(self, patch_file): 514 print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", " 515 "or resolve the conflicts, stage *all* touched files with " 516 "'git add', and type \"RESOLVED<Return>\"" % (patch_file)) 517 self.DieNoManualMode() 518 answer = "" 519 while answer != "RESOLVED": 520 if answer == "ABORT": 521 self.Die("Applying the patch failed.") 522 if answer != "": 523 print("That was not 'RESOLVED' or 'ABORT'.") 524 print("> ", end=' ') 525 answer = self.ReadLine() 526 527 # Takes a file containing the patch to apply as first argument. 528 def ApplyPatch(self, patch_file, revert=False): 529 try: 530 self.GitApplyPatch(patch_file, revert) 531 except GitFailedException: 532 self.WaitForResolvingConflicts(patch_file) 533 534 def GetVersionTag(self, revision): 535 tag = self.Git("describe --tags %s" % revision).strip() 536 return SanitizeVersionTag(tag) 537 538 def GetRecentReleases(self, max_age): 539 # Make sure tags are fetched. 540 self.Git("fetch origin +refs/tags/*:refs/tags/*") 541 542 # Current timestamp. 543 time_now = int(self._side_effect_handler.GetUTCStamp()) 544 545 # List every tag from a given period. 546 revisions = self.Git("rev-list --max-age=%d --tags" % 547 int(time_now - max_age)).strip() 548 549 # Filter out revisions who's tag is off by one or more commits. 550 return list(filter(self.GetVersionTag, revisions.splitlines())) 551 552 def GetLatestVersion(self): 553 # Use cached version if available. 554 if self["latest_version"]: 555 return self["latest_version"] 556 557 # Make sure tags are fetched. 558 self.Git("fetch origin +refs/tags/*:refs/tags/*") 559 560 all_tags = self.vc.GetTags() 561 only_version_tags = NormalizeVersionTags(all_tags) 562 563 version = sorted(only_version_tags, 564 key=LooseVersion, reverse=True)[0] 565 self["latest_version"] = version 566 return version 567 568 def GetLatestRelease(self): 569 """The latest release is the git hash of the latest tagged version. 570 571 This revision should be rolled into chromium. 572 """ 573 latest_version = self.GetLatestVersion() 574 575 # The latest release. 576 latest_hash = self.GitLog(n=1, format="%H", branch=latest_version) 577 assert latest_hash 578 return latest_hash 579 580 def GetLatestReleaseBase(self, version=None): 581 """The latest release base is the latest revision that is covered in the 582 last change log file. It doesn't include cherry-picked patches. 583 """ 584 latest_version = version or self.GetLatestVersion() 585 586 # Strip patch level if it exists. 587 latest_version = ".".join(latest_version.split(".")[:3]) 588 589 # The latest release base. 590 latest_hash = self.GitLog(n=1, format="%H", branch=latest_version) 591 assert latest_hash 592 593 title = self.GitLog(n=1, format="%s", git_hash=latest_hash) 594 match = PUSH_MSG_GIT_RE.match(title) 595 if match: 596 # Legacy: In the old process there's one level of indirection. The 597 # version is on the candidates branch and points to the real release 598 # base on main through the commit message. 599 return match.group("git_rev") 600 match = PUSH_MSG_NEW_RE.match(title) 601 if match: 602 # This is a new-style v8 version branched from main. The commit 603 # "latest_hash" is the version-file change. Its parent is the release 604 # base on main. 605 return self.GitLog(n=1, format="%H", git_hash="%s^" % latest_hash) 606 607 self.Die("Unknown latest release: %s" % latest_hash) 608 609 def ArrayToVersion(self, prefix): 610 return ".".join([self[prefix + "major"], 611 self[prefix + "minor"], 612 self[prefix + "build"], 613 self[prefix + "patch"]]) 614 615 def StoreVersion(self, version, prefix): 616 version_parts = version.split(".") 617 if len(version_parts) == 3: 618 version_parts.append("0") 619 major, minor, build, patch = version_parts 620 self[prefix + "major"] = major 621 self[prefix + "minor"] = minor 622 self[prefix + "build"] = build 623 self[prefix + "patch"] = patch 624 625 def SetVersion(self, version_file, prefix): 626 output = "" 627 for line in FileToText(version_file).splitlines(): 628 if line.startswith("#define V8_MAJOR_VERSION"): 629 line = re.sub("\d+$", self[prefix + "major"], line) 630 elif line.startswith("#define V8_MINOR_VERSION"): 631 line = re.sub("\d+$", self[prefix + "minor"], line) 632 elif line.startswith("#define V8_BUILD_NUMBER"): 633 line = re.sub("\d+$", self[prefix + "build"], line) 634 elif line.startswith("#define V8_PATCH_LEVEL"): 635 line = re.sub("\d+$", self[prefix + "patch"], line) 636 elif (self[prefix + "candidate"] and 637 line.startswith("#define V8_IS_CANDIDATE_VERSION")): 638 line = re.sub("\d+$", self[prefix + "candidate"], line) 639 output += "%s\n" % line 640 TextToFile(output, version_file) 641 642 643class BootstrapStep(Step): 644 MESSAGE = "Bootstrapping checkout and state." 645 646 def RunStep(self): 647 # Reserve state entry for json output. 648 self['json_output'] = {} 649 650 if os.path.realpath(self.default_cwd) == os.path.realpath(V8_BASE): 651 self.Die("Can't use v8 checkout with calling script as work checkout.") 652 # Directory containing the working v8 checkout. 653 if not os.path.exists(self._options.work_dir): 654 os.makedirs(self._options.work_dir) 655 if not os.path.exists(self.default_cwd): 656 self.Command("fetch", "v8", cwd=self._options.work_dir) 657 658 659class UploadStep(Step): 660 MESSAGE = "Upload for code review." 661 662 def RunStep(self): 663 reviewer = None 664 if self._options.reviewer: 665 print("Using account %s for review." % self._options.reviewer) 666 reviewer = self._options.reviewer 667 668 tbr_reviewer = None 669 if self._options.tbr_reviewer: 670 print("Using account %s for TBR review." % self._options.tbr_reviewer) 671 tbr_reviewer = self._options.tbr_reviewer 672 673 if not reviewer and not tbr_reviewer: 674 print( 675 "Please enter the email address of a V8 reviewer for your patch: ", 676 end=' ') 677 self.DieNoManualMode("A reviewer must be specified in forced mode.") 678 reviewer = self.ReadLine() 679 680 self.GitUpload(reviewer, self._options.force_upload, 681 bypass_hooks=self._options.bypass_upload_hooks, 682 tbr_reviewer=tbr_reviewer) 683 684 685def MakeStep(step_class=Step, number=0, state=None, config=None, 686 options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER): 687 # Allow to pass in empty dictionaries. 688 state = state if state is not None else {} 689 config = config if config is not None else {} 690 691 try: 692 message = step_class.MESSAGE 693 except AttributeError: 694 message = step_class.__name__ 695 696 return step_class(message, number=number, config=config, 697 state=state, options=options, 698 handler=side_effect_handler) 699 700 701class ScriptsBase(object): 702 def __init__(self, 703 config=None, 704 side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER, 705 state=None): 706 self._config = config or self._Config() 707 self._side_effect_handler = side_effect_handler 708 self._state = state if state is not None else {} 709 710 def _Description(self): 711 return None 712 713 def _PrepareOptions(self, parser): 714 pass 715 716 def _ProcessOptions(self, options): 717 return True 718 719 def _Steps(self): # pragma: no cover 720 raise Exception("Not implemented.") 721 722 def _Config(self): 723 return {} 724 725 def MakeOptions(self, args=None): 726 parser = argparse.ArgumentParser(description=self._Description()) 727 parser.add_argument("-a", "--author", default="", 728 help="The author email used for code review.") 729 parser.add_argument("--dry-run", default=False, action="store_true", 730 help="Perform only read-only actions.") 731 parser.add_argument("--json-output", 732 help="File to write results summary to.") 733 parser.add_argument("-r", "--reviewer", default="", 734 help="The account name to be used for reviews.") 735 parser.add_argument("--tbr-reviewer", "--tbr", default="", 736 help="The account name to be used for TBR reviews.") 737 parser.add_argument("-s", "--step", 738 help="Specify the step where to start work. Default: 0.", 739 default=0, type=int) 740 parser.add_argument("--work-dir", 741 help=("Location where to bootstrap a working v8 " 742 "checkout.")) 743 self._PrepareOptions(parser) 744 745 if args is None: # pragma: no cover 746 options = parser.parse_args() 747 else: 748 options = parser.parse_args(args) 749 750 # Process common options. 751 if options.step < 0: # pragma: no cover 752 print("Bad step number %d" % options.step) 753 parser.print_help() 754 return None 755 756 # Defaults for options, common to all scripts. 757 options.manual = getattr(options, "manual", True) 758 options.force = getattr(options, "force", False) 759 options.bypass_upload_hooks = False 760 761 # Derived options. 762 options.requires_editor = not options.force 763 options.wait_for_lgtm = not options.force 764 options.force_readline_defaults = not options.manual 765 options.force_upload = not options.manual 766 767 # Process script specific options. 768 if not self._ProcessOptions(options): 769 parser.print_help() 770 return None 771 772 if not options.work_dir: 773 options.work_dir = "/tmp/v8-release-scripts-work-dir" 774 return options 775 776 def RunSteps(self, step_classes, args=None): 777 options = self.MakeOptions(args) 778 if not options: 779 return 1 780 781 # Ensure temp dir exists for state files. 782 state_dir = os.path.dirname(self._config["PERSISTFILE_BASENAME"]) 783 if not os.path.exists(state_dir): 784 os.makedirs(state_dir) 785 786 state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"] 787 if options.step == 0 and os.path.exists(state_file): 788 os.remove(state_file) 789 790 steps = [] 791 for (number, step_class) in enumerate([BootstrapStep] + step_classes): 792 steps.append(MakeStep(step_class, number, self._state, self._config, 793 options, self._side_effect_handler)) 794 795 try: 796 for step in steps[options.step:]: 797 if step.Run(): 798 return 0 799 finally: 800 if options.json_output: 801 with open(options.json_output, "w") as f: 802 json.dump(self._state['json_output'], f) 803 804 return 0 805 806 def Run(self, args=None): 807 return self.RunSteps(self._Steps(), args) 808