• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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