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