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