common_includes.py 28.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
#!/usr/bin/env python
# Copyright 2013 the V8 project authors. All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
#     * Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above
#       copyright notice, this list of conditions and the following
#       disclaimer in the documentation and/or other materials provided
#       with the distribution.
#     * Neither the name of Google Inc. nor the names of its
#       contributors may be used to endorse or promote products derived
#       from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

29
import argparse
30
import datetime
31
import httplib
32
import glob
33
import imp
34
import json
35 36
import os
import re
37
import shutil
38 39
import subprocess
import sys
40
import textwrap
41
import time
42
import urllib
43
import urllib2
44

45
from git_recipes import GitRecipesMixin
46
from git_recipes import GitFailedException
47

48
CHANGELOG_FILE = "ChangeLog"
49
DAY_IN_SECONDS = 24 * 60 * 60
50
PUSH_MSG_GIT_RE = re.compile(r".* \(based on (?P<git_rev>[a-fA-F0-9]+)\)$")
51
PUSH_MSG_NEW_RE = re.compile(r"^Version \d+\.\d+\.\d+$")
machenbach's avatar
machenbach committed
52
VERSION_FILE = os.path.join("include", "v8-version.h")
53
WATCHLISTS_FILE = "WATCHLISTS"
54

55
# V8 base directory.
56
V8_BASE = os.path.dirname(
57 58
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84

def TextToFile(text, file_name):
  with open(file_name, "w") as f:
    f.write(text)


def AppendToFile(text, file_name):
  with open(file_name, "a") as f:
    f.write(text)


def LinesInFile(file_name):
  with open(file_name) as f:
    for line in f:
      yield line


def FileToText(file_name):
  with open(file_name) as f:
    return f.read()


def MSub(rexp, replacement, text):
  return re.sub(rexp, replacement, text, flags=re.MULTILINE)


85
def Fill80(line):
86 87 88 89
  # Replace tabs and remove surrounding space.
  line = re.sub(r"\t", r"        ", line.strip())

  # Format with 8 characters indentation and line width 80.
90 91 92 93
  return textwrap.fill(line, width=80, initial_indent="        ",
                       subsequent_indent="        ")


94 95 96 97 98 99 100 101 102 103
def MakeComment(text):
  return MSub(r"^( ?)", "#", text)


def StripComments(text):
  # Use split not splitlines to keep terminal newlines.
  return "\n".join(filter(lambda x: not x.startswith("#"), text.split("\n")))


def MakeChangeLogBody(commit_messages, auto_format=False):
104
  result = ""
105 106
  added_titles = set()
  for (title, body, author) in commit_messages:
107 108
    # TODO(machenbach): Better check for reverts. A revert should remove the
    # original CL from the actual log entry.
109
    title = title.strip()
110 111
    if auto_format:
      # Only add commits that set the LOG flag correctly.
112
      log_exp = r"^[ \t]*LOG[ \t]*=[ \t]*(?:(?:Y(?:ES)?)|TRUE)"
113 114 115 116 117 118 119 120 121
      if not re.search(log_exp, body, flags=re.I | re.M):
        continue
      # Never include reverts.
      if title.startswith("Revert "):
        continue
      # Don't include duplicates.
      if title in added_titles:
        continue

122
    # Add and format the commit's title and bug reference. Move dot to the end.
123
    added_titles.add(title)
124 125 126 127
    raw_title = re.sub(r"(\.|\?|!)$", "", title)
    bug_reference = MakeChangeLogBugReference(body)
    space = " " if bug_reference else ""
    result += "%s\n" % Fill80("%s%s%s." % (raw_title, space, bug_reference))
128

129 130
    # Append the commit's author for reference if not in auto-format mode.
    if not auto_format:
131
      result += "%s\n" % Fill80("(%s)" % author.strip())
132 133

    result += "\n"
134 135 136
  return result


137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
def MakeChangeLogBugReference(body):
  """Grep for "BUG=xxxx" lines in the commit message and convert them to
  "(issue xxxx)".
  """
  crbugs = []
  v8bugs = []

  def AddIssues(text):
    ref = re.match(r"^BUG[ \t]*=[ \t]*(.+)$", text.strip())
    if not ref:
      return
    for bug in ref.group(1).split(","):
      bug = bug.strip()
      match = re.match(r"^v8:(\d+)$", bug)
      if match: v8bugs.append(int(match.group(1)))
      else:
        match = re.match(r"^(?:chromium:)?(\d+)$", bug)
        if match: crbugs.append(int(match.group(1)))

  # Add issues to crbugs and v8bugs.
  map(AddIssues, body.splitlines())

  # Filter duplicates, sort, stringify.
  crbugs = map(str, sorted(set(crbugs)))
  v8bugs = map(str, sorted(set(v8bugs)))

  bug_groups = []
  def FormatIssues(prefix, bugs):
    if len(bugs) > 0:
      plural = "s" if len(bugs) > 1 else ""
      bug_groups.append("%sissue%s %s" % (prefix, plural, ", ".join(bugs)))

  FormatIssues("", v8bugs)
  FormatIssues("Chromium ", crbugs)

  if len(bug_groups) > 0:
173
    return "(%s)" % ", ".join(bug_groups)
174 175 176 177
  else:
    return ""


178 179 180 181 182 183 184 185 186 187
def SortingKey(version):
  """Key for sorting version number strings: '3.11' > '3.2.1.1'"""
  version_keys = map(int, version.split("."))
  # Fill up to full version numbers to normalize comparison.
  while len(version_keys) < 4:  # pragma: no cover
    version_keys.append(0)
  # Fill digits.
  return ".".join(map("{0:04d}".format, version_keys))


188 189
# Some commands don't like the pipe, e.g. calling vi from within the script or
# from subscripts like git cl upload.
190 191
def Command(cmd, args="", prefix="", pipe=True, cwd=None):
  cwd = cwd or os.getcwd()
192
  # TODO(machenbach): Use timeout.
193 194
  cmd_line = "%s %s %s" % (prefix, cmd, args)
  print "Command: %s" % cmd_line
195
  print "in %s" % cwd
196
  sys.stdout.flush()
197 198
  try:
    if pipe:
199
      return subprocess.check_output(cmd_line, shell=True, cwd=cwd)
200
    else:
201
      return subprocess.check_call(cmd_line, shell=True, cwd=cwd)
202 203
  except subprocess.CalledProcessError:
    return None
204 205 206
  finally:
    sys.stdout.flush()
    sys.stderr.flush()
207 208


209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
def SanitizeVersionTag(tag):
    version_without_prefix = re.compile(r"^\d+\.\d+\.\d+(?:\.\d+)?$")
    version_with_prefix = re.compile(r"^tags\/\d+\.\d+\.\d+(?:\.\d+)?$")

    if version_without_prefix.match(tag):
      return tag
    elif version_with_prefix.match(tag):
        return tag[len("tags/"):]
    else:
      return None


def NormalizeVersionTags(version_tags):
  normalized_version_tags = []

  # Remove tags/ prefix because of packed refs.
  for current_tag in version_tags:
    version_tag = SanitizeVersionTag(current_tag)
    if version_tag != None:
      normalized_version_tags.append(version_tag)

  return normalized_version_tags


233
# Wrapper for side effects.
234
class SideEffectHandler(object):  # pragma: no cover
235 236 237
  def Call(self, fun, *args, **kwargs):
    return fun(*args, **kwargs)

238 239
  def Command(self, cmd, args="", prefix="", pipe=True, cwd=None):
    return Command(cmd, args, prefix, pipe, cwd=cwd)
240 241 242 243

  def ReadLine(self):
    return sys.stdin.readline().strip()

244
  def ReadURL(self, url, params=None):
245
    # pylint: disable=E1121
246
    url_fh = urllib2.urlopen(url, params, 60)
247 248 249 250 251
    try:
      return url_fh.read()
    finally:
      url_fh.close()

252
  def ReadClusterFuzzAPI(self, api_key, **params):
253
    params["api_key"] = api_key.strip()
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
    params = urllib.urlencode(params)

    headers = {"Content-type": "application/x-www-form-urlencoded"}

    conn = httplib.HTTPSConnection("backend-dot-cluster-fuzz.appspot.com")
    conn.request("POST", "/_api/", params, headers)

    response = conn.getresponse()
    data = response.read()

    try:
      return json.loads(data)
    except:
      print data
      print "ERROR: Could not read response. Is your key valid?"
      raise

271
  def Sleep(self, seconds):
272 273
    time.sleep(seconds)

274 275 276
  def GetDate(self):
    return datetime.date.today().strftime("%Y-%m-%d")

277
  def GetUTCStamp(self):
278
    return time.mktime(datetime.datetime.utcnow().timetuple())
279

280 281 282
DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()


283 284 285
class NoRetryException(Exception):
  pass

286

287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
class VCInterface(object):
  def InjectStep(self, step):
    self.step=step

  def Pull(self):
    raise NotImplementedError()

  def Fetch(self):
    raise NotImplementedError()

  def GetTags(self):
    raise NotImplementedError()

  def GetBranches(self):
    raise NotImplementedError()

303 304 305 306 307 308
  def MasterBranch(self):
    raise NotImplementedError()

  def CandidateBranch(self):
    raise NotImplementedError()

309 310 311 312 313 314 315 316 317 318 319 320
  def RemoteMasterBranch(self):
    raise NotImplementedError()

  def RemoteCandidateBranch(self):
    raise NotImplementedError()

  def RemoteBranch(self, name):
    raise NotImplementedError()

  def CLLand(self):
    raise NotImplementedError()

321 322 323 324 325
  def Tag(self, tag, remote, message):
    """Sets a tag for the current commit.

    Assumptions: The commit already landed and the commit message is unique.
    """
326 327 328
    raise NotImplementedError()


329
class GitInterface(VCInterface):
330 331 332 333 334 335 336 337 338 339
  def Pull(self):
    self.step.GitPull()

  def Fetch(self):
    self.step.Git("fetch")

  def GetTags(self):
     return self.step.Git("tag").strip().splitlines()

  def GetBranches(self):
340
    # Get relevant remote branches, e.g. "branch-heads/3.25".
341
    branches = filter(
342
        lambda s: re.match(r"^branch\-heads/\d+\.\d+$", s),
343
        self.step.GitRemotes())
344 345
    # Remove 'branch-heads/' prefix.
    return map(lambda s: s[13:], branches)
346

347 348 349 350 351 352
  def MasterBranch(self):
    return "master"

  def CandidateBranch(self):
    return "candidates"

353 354 355 356 357 358 359
  def RemoteMasterBranch(self):
    return "origin/master"

  def RemoteCandidateBranch(self):
    return "origin/candidates"

  def RemoteBranch(self, name):
360 361 362 363
    # Assume that if someone "fully qualified" the ref, they know what they
    # want.
    if name.startswith('refs/'):
      return name
364
    if name in ["candidates", "master"]:
365
      return "refs/remotes/origin/%s" % name
366 367 368 369 370 371 372 373 374 375 376 377
    try:
      # Check if branch is in heads.
      if self.step.Git("show-ref refs/remotes/origin/%s" % name).strip():
        return "refs/remotes/origin/%s" % name
    except GitFailedException:
      pass
    try:
      # Check if branch is in branch-heads.
      if self.step.Git("show-ref refs/remotes/branch-heads/%s" % name).strip():
        return "refs/remotes/branch-heads/%s" % name
    except GitFailedException:
      pass
378
    self.Die("Can't find remote of %s" % name)
379

380 381 382 383 384
  def Tag(self, tag, remote, message):
    # Wait for the commit to appear. Assumes unique commit message titles (this
    # is the case for all automated merge and push commits - also no title is
    # the prefix of another title).
    commit = None
385
    for wait_interval in [10, 30, 60, 60, 60, 60, 60]:
386 387 388 389 390 391 392 393 394 395 396 397
      self.step.Git("fetch")
      commit = self.step.GitLog(n=1, format="%H", grep=message, branch=remote)
      if commit:
        break
      print("The commit has not replicated to git. Waiting for %s seconds." %
            wait_interval)
      self.step._side_effect_handler.Sleep(wait_interval)
    else:
      self.step.Die("Couldn't determine commit for setting the tag. Maybe the "
                    "git updater is lagging behind?")

    self.step.Git("tag %s %s" % (tag, commit))
398
    self.step.Git("push origin %s" % tag)
399 400 401 402

  def CLLand(self):
    self.step.GitCLLand()

403

404
class Step(GitRecipesMixin):
405
  def __init__(self, text, number, config, state, options, handler):
406 407 408 409 410 411
    self._text = text
    self._number = number
    self._config = config
    self._state = state
    self._options = options
    self._side_effect_handler = handler
412
    self.vc = GitInterface()
413
    self.vc.InjectStep(self)
414 415

    # The testing configuration might set a different default cwd.
416 417
    self.default_cwd = (self._config.get("DEFAULT_CWD") or
                        os.path.join(self._options.work_dir, "v8"))
418

419 420 421 422
    assert self._number >= 0
    assert self._config is not None
    assert self._state is not None
    assert self._side_effect_handler is not None
423

424 425 426
  def __getitem__(self, key):
    # Convenience method to allow direct [] access on step classes for
    # manipulating the backed state dict.
427
    return self._state.get(key)
428 429 430 431 432 433

  def __setitem__(self, key, value):
    # Convenience method to allow direct [] access on step classes for
    # manipulating the backed state dict.
    self._state[key] = value

434 435 436 437
  def Config(self, key):
    return self._config[key]

  def Run(self):
438
    # Restore state.
439
    state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
440 441 442
    if not self._state and os.path.exists(state_file):
      self._state.update(json.loads(FileToText(state_file)))

443
    print ">>> Step %d: %s" % (self._number, self._text)
444 445 446 447 448
    try:
      return self.RunStep()
    finally:
      # Persist state.
      TextToFile(json.dumps(self._state), state_file)
449

450
  def RunStep(self):  # pragma: no cover
451 452
    raise NotImplementedError

453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469
  def Retry(self, cb, retry_on=None, wait_plan=None):
    """ Retry a function.
    Params:
      cb: The function to retry.
      retry_on: A callback that takes the result of the function and returns
                True if the function should be retried. A function throwing an
                exception is always retried.
      wait_plan: A list of waiting delays between retries in seconds. The
                 maximum number of retries is len(wait_plan).
    """
    retry_on = retry_on or (lambda x: False)
    wait_plan = list(wait_plan or [])
    wait_plan.reverse()
    while True:
      got_exception = False
      try:
        result = cb()
470
      except NoRetryException as e:
471
        raise e
472 473
      except Exception as e:
        got_exception = e
474
      if got_exception or retry_on(result):
475
        if not wait_plan:  # pragma: no cover
476 477
          raise Exception("Retried too often. Giving up. Reason: %s" %
                          str(got_exception))
478 479 480 481 482 483 484
        wait_time = wait_plan.pop()
        print "Waiting for %f seconds." % wait_time
        self._side_effect_handler.Sleep(wait_time)
        print "Retrying..."
      else:
        return result

485 486
  def ReadLine(self, default=None):
    # Don't prompt in forced mode.
487
    if self._options.force_readline_defaults and default is not None:
488 489 490 491
      print "%s (forced)" % default
      return default
    else:
      return self._side_effect_handler.ReadLine()
492

493 494 495 496 497 498 499 500
  def Command(self, name, args, cwd=None):
    cmd = lambda: self._side_effect_handler.Command(
        name, args, "", True, cwd=cwd or self.default_cwd)
    return self.Retry(cmd, None, [5])

  def Git(self, args="", prefix="", pipe=True, retry_on=None, cwd=None):
    cmd = lambda: self._side_effect_handler.Command(
        "git", args, prefix, pipe, cwd=cwd or self.default_cwd)
501 502 503 504
    result = self.Retry(cmd, retry_on, [5, 30])
    if result is None:
      raise GitFailedException("'git %s' failed." % args)
    return result
505 506

  def Editor(self, args):
507
    if self._options.requires_editor:
508 509 510 511 512
      return self._side_effect_handler.Command(
          os.environ["EDITOR"],
          args,
          pipe=False,
          cwd=self.default_cwd)
513

514
  def ReadURL(self, url, params=None, retry_on=None, wait_plan=None):
515
    wait_plan = wait_plan or [3, 60, 600]
516
    cmd = lambda: self._side_effect_handler.ReadURL(url, params)
517
    return self.Retry(cmd, retry_on, wait_plan)
518

519 520 521
  def GetDate(self):
    return self._side_effect_handler.GetDate()

522 523 524 525 526 527
  def Die(self, msg=""):
    if msg != "":
      print "Error: %s" % msg
    print "Exiting"
    raise Exception(msg)

528
  def DieNoManualMode(self, msg=""):
529
    if not self._options.manual:  # pragma: no cover
530
      msg = msg or "Only available in manual mode."
531 532
      self.Die(msg)

533 534
  def Confirm(self, msg):
    print "%s [Y/n] " % msg,
535
    answer = self.ReadLine(default="Y")
536 537
    return answer == "" or answer == "Y" or answer == "y"

538 539
  def DeleteBranch(self, name, cwd=None):
    for line in self.GitBranch(cwd=cwd).splitlines():
540
      if re.match(r"\*?\s*%s$" % re.escape(name), line):
541 542
        msg = "Branch %s exists, do you want to delete it?" % name
        if self.Confirm(msg):
543
          self.GitDeleteBranch(name, cwd=cwd)
544 545 546 547 548
          print "Branch %s deleted." % name
        else:
          msg = "Can't continue. Please delete branch %s and try again." % name
          self.Die(msg)

549
  def InitialEnvironmentChecks(self, cwd):
550
    # Cancel if this is not a git checkout.
551
    if not os.path.exists(os.path.join(cwd, ".git")):  # pragma: no cover
552 553 554
      self.Die("This is not a git checkout, this script won't work for you.")

    # Cancel if EDITOR is unset or not executable.
555
    if (self._options.requires_editor and (not os.environ.get("EDITOR") or
556 557
        self.Command(
            "which", os.environ["EDITOR"]) is None)):  # pragma: no cover
558 559 560 561
      self.Die("Please set your EDITOR environment variable, you'll need it.")

  def CommonPrepare(self):
    # Check for a clean workdir.
562
    if not self.GitIsWorkdirClean():  # pragma: no cover
563 564
      self.Die("Workspace is not clean. Please commit or undo your changes.")

565 566
    # Checkout master in case the script was left on a work branch.
    self.GitCheckout('origin/master')
567 568

    # Fetch unfetched revisions.
569
    self.vc.Fetch()
570

571
  def PrepareBranch(self):
572
    # Delete the branch that will be created later if it exists already.
573
    self.DeleteBranch(self._config["BRANCHNAME"])
574 575

  def CommonCleanup(self):
576 577
    self.GitCheckout('origin/master')
    self.GitDeleteBranch(self._config["BRANCHNAME"])
578 579

    # Clean up all temporary files.
580
    for f in glob.iglob("%s*" % self._config["PERSISTFILE_BASENAME"]):
581 582 583 584
      if os.path.isfile(f):
        os.remove(f)
      if os.path.isdir(f):
        shutil.rmtree(f)
585 586 587 588 589 590

  def ReadAndPersistVersion(self, prefix=""):
    def ReadAndPersist(var_name, def_name):
      match = re.match(r"^#define %s\s+(\d*)" % def_name, line)
      if match:
        value = match.group(1)
591
        self["%s%s" % (prefix, var_name)] = value
592
    for line in LinesInFile(os.path.join(self.default_cwd, VERSION_FILE)):
machenbach's avatar
machenbach committed
593 594 595 596
      for (var_name, def_name) in [("major", "V8_MAJOR_VERSION"),
                                   ("minor", "V8_MINOR_VERSION"),
                                   ("build", "V8_BUILD_NUMBER"),
                                   ("patch", "V8_PATCH_LEVEL")]:
597 598 599 600 601 602 603 604 605 606
        ReadAndPersist(var_name, def_name)

  def WaitForLGTM(self):
    print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit "
           "your change. (If you need to iterate on the patch or double check "
           "that it's sane, do so in another shell, but remember to not "
           "change the headline of the uploaded CL.")
    answer = ""
    while answer != "LGTM":
      print "> ",
607
      answer = self.ReadLine(None if self._options.wait_for_lgtm else "LGTM")
608 609 610 611 612 613
      if answer != "LGTM":
        print "That was not 'LGTM'."

  def WaitForResolvingConflicts(self, patch_file):
    print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", "
          "or resolve the conflicts, stage *all* touched files with "
614
          "'git add', and type \"RESOLVED<Return>\"" % (patch_file))
615
    self.DieNoManualMode()
616 617 618 619 620 621 622 623 624 625
    answer = ""
    while answer != "RESOLVED":
      if answer == "ABORT":
        self.Die("Applying the patch failed.")
      if answer != "":
        print "That was not 'RESOLVED' or 'ABORT'."
      print "> ",
      answer = self.ReadLine()

  # Takes a file containing the patch to apply as first argument.
626
  def ApplyPatch(self, patch_file, revert=False):
627
    try:
628
      self.GitApplyPatch(patch_file, revert)
629
    except GitFailedException:
630 631
      self.WaitForResolvingConflicts(patch_file)

632 633
  def GetVersionTag(self, revision):
    tag = self.Git("describe --tags %s" % revision).strip()
634
    return SanitizeVersionTag(tag)
635

636 637 638 639 640 641 642 643 644 645 646 647
  def GetRecentReleases(self, max_age):
    # Make sure tags are fetched.
    self.Git("fetch origin +refs/tags/*:refs/tags/*")

    # Current timestamp.
    time_now = int(self._side_effect_handler.GetUTCStamp())

    # List every tag from a given period.
    revisions = self.Git("rev-list --max-age=%d --tags" %
                         int(time_now - max_age)).strip()

    # Filter out revisions who's tag is off by one or more commits.
648
    return filter(lambda r: self.GetVersionTag(r), revisions.splitlines())
649

650 651 652 653 654 655 656
  def GetLatestVersion(self):
    # Use cached version if available.
    if self["latest_version"]:
      return self["latest_version"]

    # Make sure tags are fetched.
    self.Git("fetch origin +refs/tags/*:refs/tags/*")
657 658 659 660 661

    all_tags = self.vc.GetTags()
    only_version_tags = NormalizeVersionTags(all_tags)

    version = sorted(only_version_tags,
662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677
                     key=SortingKey, reverse=True)[0]
    self["latest_version"] = version
    return version

  def GetLatestRelease(self):
    """The latest release is the git hash of the latest tagged version.

    This revision should be rolled into chromium.
    """
    latest_version = self.GetLatestVersion()

    # The latest release.
    latest_hash = self.GitLog(n=1, format="%H", branch=latest_version)
    assert latest_hash
    return latest_hash

678
  def GetLatestReleaseBase(self, version=None):
679 680 681
    """The latest release base is the latest revision that is covered in the
    last change log file. It doesn't include cherry-picked patches.
    """
682
    latest_version = version or self.GetLatestVersion()
683 684 685 686 687 688 689 690

    # Strip patch level if it exists.
    latest_version = ".".join(latest_version.split(".")[:3])

    # The latest release base.
    latest_hash = self.GitLog(n=1, format="%H", branch=latest_version)
    assert latest_hash

691 692
    title = self.GitLog(n=1, format="%s", git_hash=latest_hash)
    match = PUSH_MSG_GIT_RE.match(title)
693 694 695 696
    if match:
      # Legacy: In the old process there's one level of indirection. The
      # version is on the candidates branch and points to the real release
      # base on master through the commit message.
697 698 699 700 701 702 703 704 705
      return match.group("git_rev")
    match = PUSH_MSG_NEW_RE.match(title)
    if match:
      # This is a new-style v8 version branched from master. The commit
      # "latest_hash" is the version-file change. Its parent is the release
      # base on master.
      return self.GitLog(n=1, format="%H", git_hash="%s^" % latest_hash)

    self.Die("Unknown latest release: %s" % latest_hash)
706

707 708 709 710 711 712
  def ArrayToVersion(self, prefix):
    return ".".join([self[prefix + "major"],
                     self[prefix + "minor"],
                     self[prefix + "build"],
                     self[prefix + "patch"]])

713 714 715 716 717 718 719 720 721 722
  def StoreVersion(self, version, prefix):
    version_parts = version.split(".")
    if len(version_parts) == 3:
      version_parts.append("0")
    major, minor, build, patch = version_parts
    self[prefix + "major"] = major
    self[prefix + "minor"] = minor
    self[prefix + "build"] = build
    self[prefix + "patch"] = patch

723 724 725
  def SetVersion(self, version_file, prefix):
    output = ""
    for line in FileToText(version_file).splitlines():
machenbach's avatar
machenbach committed
726
      if line.startswith("#define V8_MAJOR_VERSION"):
727
        line = re.sub("\d+$", self[prefix + "major"], line)
machenbach's avatar
machenbach committed
728
      elif line.startswith("#define V8_MINOR_VERSION"):
729
        line = re.sub("\d+$", self[prefix + "minor"], line)
machenbach's avatar
machenbach committed
730
      elif line.startswith("#define V8_BUILD_NUMBER"):
731
        line = re.sub("\d+$", self[prefix + "build"], line)
machenbach's avatar
machenbach committed
732
      elif line.startswith("#define V8_PATCH_LEVEL"):
733
        line = re.sub("\d+$", self[prefix + "patch"], line)
734
      elif (self[prefix + "candidate"] and
machenbach's avatar
machenbach committed
735
            line.startswith("#define V8_IS_CANDIDATE_VERSION")):
736
        line = re.sub("\d+$", self[prefix + "candidate"], line)
737 738
      output += "%s\n" % line
    TextToFile(output, version_file)
739

740

741
class BootstrapStep(Step):
742
  MESSAGE = "Bootstrapping checkout and state."
743 744

  def RunStep(self):
745 746 747
    # Reserve state entry for json output.
    self['json_output'] = {}

748 749 750 751 752 753 754 755 756
    if os.path.realpath(self.default_cwd) == os.path.realpath(V8_BASE):
      self.Die("Can't use v8 checkout with calling script as work checkout.")
    # Directory containing the working v8 checkout.
    if not os.path.exists(self._options.work_dir):
      os.makedirs(self._options.work_dir)
    if not os.path.exists(self.default_cwd):
      self.Command("fetch", "v8", cwd=self._options.work_dir)


757
class UploadStep(Step):
758
  MESSAGE = "Upload for code review."
759 760

  def RunStep(self):
761 762 763
    if self._options.reviewer:
      print "Using account %s for review." % self._options.reviewer
      reviewer = self._options.reviewer
764 765
    else:
      print "Please enter the email address of a V8 reviewer for your patch: ",
766
      self.DieNoManualMode("A reviewer must be specified in forced mode.")
767
      reviewer = self.ReadLine()
768
    self.GitUpload(reviewer, self._options.author, self._options.force_upload,
769
                   bypass_hooks=self._options.bypass_upload_hooks,
770
                   cc=self._options.cc, use_gerrit=not self._options.rietveld)
771 772


773 774 775 776 777 778 779 780 781 782 783
def MakeStep(step_class=Step, number=0, state=None, config=None,
             options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER):
    # Allow to pass in empty dictionaries.
    state = state if state is not None else {}
    config = config if config is not None else {}

    try:
      message = step_class.MESSAGE
    except AttributeError:
      message = step_class.__name__

784
    return step_class(message, number=number, config=config,
785 786 787 788
                      state=state, options=options,
                      handler=side_effect_handler)


789
class ScriptsBase(object):
790 791 792
  def __init__(self,
               config=None,
               side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER,
793
               state=None):
794
    self._config = config or self._Config()
795 796 797 798 799 800 801 802 803 804 805 806
    self._side_effect_handler = side_effect_handler
    self._state = state if state is not None else {}

  def _Description(self):
    return None

  def _PrepareOptions(self, parser):
    pass

  def _ProcessOptions(self, options):
    return True

807
  def _Steps(self):  # pragma: no cover
808 809
    raise Exception("Not implemented.")

810 811 812
  def _Config(self):
    return {}

813 814 815 816
  def MakeOptions(self, args=None):
    parser = argparse.ArgumentParser(description=self._Description())
    parser.add_argument("-a", "--author", default="",
                        help="The author email used for rietveld.")
817 818
    parser.add_argument("--dry-run", default=False, action="store_true",
                        help="Perform only read-only actions.")
819 820
    parser.add_argument("--json-output",
                        help="File to write results summary to.")
821 822
    parser.add_argument("-r", "--reviewer", default="",
                        help="The account name to be used for reviews.")
823
    parser.add_argument("--rietveld", default=False, action="store_true",
824
                        help="Whether to use rietveld instead of gerrit.")
825
    parser.add_argument("-s", "--step",
826 827
        help="Specify the step where to start work. Default: 0.",
        default=0, type=int)
828 829 830
    parser.add_argument("--work-dir",
                        help=("Location where to bootstrap a working v8 "
                              "checkout."))
831 832
    self._PrepareOptions(parser)

833
    if args is None:  # pragma: no cover
834 835 836 837 838
      options = parser.parse_args()
    else:
      options = parser.parse_args(args)

    # Process common options.
839
    if options.step < 0:  # pragma: no cover
840 841 842 843 844 845 846
      print "Bad step number %d" % options.step
      parser.print_help()
      return None

    # Defaults for options, common to all scripts.
    options.manual = getattr(options, "manual", True)
    options.force = getattr(options, "force", False)
847
    options.bypass_upload_hooks = False
848 849 850 851 852 853 854 855 856 857 858

    # Derived options.
    options.requires_editor = not options.force
    options.wait_for_lgtm = not options.force
    options.force_readline_defaults = not options.manual
    options.force_upload = not options.manual

    # Process script specific options.
    if not self._ProcessOptions(options):
      parser.print_help()
      return None
859

860 861
    if not options.work_dir:
      options.work_dir = "/tmp/v8-release-scripts-work-dir"
862 863 864 865 866 867 868
    return options

  def RunSteps(self, step_classes, args=None):
    options = self.MakeOptions(args)
    if not options:
      return 1

869
    state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
870 871 872 873
    if options.step == 0 and os.path.exists(state_file):
      os.remove(state_file)

    steps = []
874
    for (number, step_class) in enumerate([BootstrapStep] + step_classes):
875 876
      steps.append(MakeStep(step_class, number, self._state, self._config,
                            options, self._side_effect_handler))
877

878 879
    try:
      for step in steps[options.step:]:
880
        if step.Run():
881 882 883 884
          return 0
    finally:
      if options.json_output:
        with open(options.json_output, "w") as f:
885
          json.dump(self._state['json_output'], f)
886

887 888 889 890
    return 0

  def Run(self, args=None):
    return self.RunSteps(self._Steps(), args)