Home | History | Annotate | Download | only in push-to-trunk
      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 
     29 import argparse
     30 import datetime
     31 import httplib
     32 import glob
     33 import imp
     34 import json
     35 import os
     36 import re
     37 import shutil
     38 import subprocess
     39 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 VERSION_FILE = os.path.join("src", "version.cc")
     49 
     50 # V8 base directory.
     51 DEFAULT_CWD = os.path.dirname(
     52     os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
     53 
     54 
     55 def TextToFile(text, file_name):
     56   with open(file_name, "w") as f:
     57     f.write(text)
     58 
     59 
     60 def AppendToFile(text, file_name):
     61   with open(file_name, "a") as f:
     62     f.write(text)
     63 
     64 
     65 def LinesInFile(file_name):
     66   with open(file_name) as f:
     67     for line in f:
     68       yield line
     69 
     70 
     71 def FileToText(file_name):
     72   with open(file_name) as f:
     73     return f.read()
     74 
     75 
     76 def MSub(rexp, replacement, text):
     77   return re.sub(rexp, replacement, text, flags=re.MULTILINE)
     78 
     79 
     80 def Fill80(line):
     81   # Replace tabs and remove surrounding space.
     82   line = re.sub(r"\t", r"        ", line.strip())
     83 
     84   # Format with 8 characters indentation and line width 80.
     85   return textwrap.fill(line, width=80, initial_indent="        ",
     86                        subsequent_indent="        ")
     87 
     88 
     89 def MakeComment(text):
     90   return MSub(r"^( ?)", "#", text)
     91 
     92 
     93 def StripComments(text):
     94   # Use split not splitlines to keep terminal newlines.
     95   return "\n".join(filter(lambda x: not x.startswith("#"), text.split("\n")))
     96 
     97 
     98 def MakeChangeLogBody(commit_messages, auto_format=False):
     99   result = ""
    100   added_titles = set()
    101   for (title, body, author) in commit_messages:
    102     # TODO(machenbach): Better check for reverts. A revert should remove the
    103     # original CL from the actual log entry.
    104     title = title.strip()
    105     if auto_format:
    106       # Only add commits that set the LOG flag correctly.
    107       log_exp = r"^[ \t]*LOG[ \t]*=[ \t]*(?:(?:Y(?:ES)?)|TRUE)"
    108       if not re.search(log_exp, body, flags=re.I | re.M):
    109         continue
    110       # Never include reverts.
    111       if title.startswith("Revert "):
    112         continue
    113       # Don't include duplicates.
    114       if title in added_titles:
    115         continue
    116 
    117     # Add and format the commit's title and bug reference. Move dot to the end.
    118     added_titles.add(title)
    119     raw_title = re.sub(r"(\.|\?|!)$", "", title)
    120     bug_reference = MakeChangeLogBugReference(body)
    121     space = " " if bug_reference else ""
    122     result += "%s\n" % Fill80("%s%s%s." % (raw_title, space, bug_reference))
    123 
    124     # Append the commit's author for reference if not in auto-format mode.
    125     if not auto_format:
    126       result += "%s\n" % Fill80("(%s)" % author.strip())
    127 
    128     result += "\n"
    129   return result
    130 
    131 
    132 def MakeChangeLogBugReference(body):
    133   """Grep for "BUG=xxxx" lines in the commit message and convert them to
    134   "(issue xxxx)".
    135   """
    136   crbugs = []
    137   v8bugs = []
    138 
    139   def AddIssues(text):
    140     ref = re.match(r"^BUG[ \t]*=[ \t]*(.+)$", text.strip())
    141     if not ref:
    142       return
    143     for bug in ref.group(1).split(","):
    144       bug = bug.strip()
    145       match = re.match(r"^v8:(\d+)$", bug)
    146       if match: v8bugs.append(int(match.group(1)))
    147       else:
    148         match = re.match(r"^(?:chromium:)?(\d+)$", bug)
    149         if match: crbugs.append(int(match.group(1)))
    150 
    151   # Add issues to crbugs and v8bugs.
    152   map(AddIssues, body.splitlines())
    153 
    154   # Filter duplicates, sort, stringify.
    155   crbugs = map(str, sorted(set(crbugs)))
    156   v8bugs = map(str, sorted(set(v8bugs)))
    157 
    158   bug_groups = []
    159   def FormatIssues(prefix, bugs):
    160     if len(bugs) > 0:
    161       plural = "s" if len(bugs) > 1 else ""
    162       bug_groups.append("%sissue%s %s" % (prefix, plural, ", ".join(bugs)))
    163 
    164   FormatIssues("", v8bugs)
    165   FormatIssues("Chromium ", crbugs)
    166 
    167   if len(bug_groups) > 0:
    168     return "(%s)" % ", ".join(bug_groups)
    169   else:
    170     return ""
    171 
    172 
    173 def SortingKey(version):
    174   """Key for sorting version number strings: '3.11' > '3.2.1.1'"""
    175   version_keys = map(int, version.split("."))
    176   # Fill up to full version numbers to normalize comparison.
    177   while len(version_keys) < 4:  # pragma: no cover
    178     version_keys.append(0)
    179   # Fill digits.
    180   return ".".join(map("{0:04d}".format, version_keys))
    181 
    182 
    183 # Some commands don't like the pipe, e.g. calling vi from within the script or
    184 # from subscripts like git cl upload.
    185 def Command(cmd, args="", prefix="", pipe=True, cwd=None):
    186   cwd = cwd or os.getcwd()
    187   # TODO(machenbach): Use timeout.
    188   cmd_line = "%s %s %s" % (prefix, cmd, args)
    189   print "Command: %s" % cmd_line
    190   print "in %s" % cwd
    191   sys.stdout.flush()
    192   try:
    193     if pipe:
    194       return subprocess.check_output(cmd_line, shell=True, cwd=cwd)
    195     else:
    196       return subprocess.check_call(cmd_line, shell=True, cwd=cwd)
    197   except subprocess.CalledProcessError:
    198     return None
    199   finally:
    200     sys.stdout.flush()
    201     sys.stderr.flush()
    202 
    203 
    204 # Wrapper for side effects.
    205 class SideEffectHandler(object):  # pragma: no cover
    206   def Call(self, fun, *args, **kwargs):
    207     return fun(*args, **kwargs)
    208 
    209   def Command(self, cmd, args="", prefix="", pipe=True, cwd=None):
    210     return Command(cmd, args, prefix, pipe, cwd=cwd)
    211 
    212   def ReadLine(self):
    213     return sys.stdin.readline().strip()
    214 
    215   def ReadURL(self, url, params=None):
    216     # pylint: disable=E1121
    217     url_fh = urllib2.urlopen(url, params, 60)
    218     try:
    219       return url_fh.read()
    220     finally:
    221       url_fh.close()
    222 
    223   def ReadClusterFuzzAPI(self, api_key, **params):
    224     params["api_key"] = api_key.strip()
    225     params = urllib.urlencode(params)
    226 
    227     headers = {"Content-type": "application/x-www-form-urlencoded"}
    228 
    229     conn = httplib.HTTPSConnection("backend-dot-cluster-fuzz.appspot.com")
    230     conn.request("POST", "/_api/", params, headers)
    231 
    232     response = conn.getresponse()
    233     data = response.read()
    234 
    235     try:
    236       return json.loads(data)
    237     except:
    238       print data
    239       print "ERROR: Could not read response. Is your key valid?"
    240       raise
    241 
    242   def Sleep(self, seconds):
    243     time.sleep(seconds)
    244 
    245   def GetDate(self):
    246     return datetime.date.today().strftime("%Y-%m-%d")
    247 
    248   def GetUTCStamp(self):
    249     return time.mktime(datetime.datetime.utcnow().timetuple())
    250 
    251 DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()
    252 
    253 
    254 class NoRetryException(Exception):
    255   pass
    256 
    257 
    258 class Step(GitRecipesMixin):
    259   def __init__(self, text, number, config, state, options, handler):
    260     self._text = text
    261     self._number = number
    262     self._config = config
    263     self._state = state
    264     self._options = options
    265     self._side_effect_handler = handler
    266 
    267     # The testing configuration might set a different default cwd.
    268     self.default_cwd = self._config.get("DEFAULT_CWD") or DEFAULT_CWD
    269 
    270     assert self._number >= 0
    271     assert self._config is not None
    272     assert self._state is not None
    273     assert self._side_effect_handler is not None
    274 
    275   def __getitem__(self, key):
    276     # Convenience method to allow direct [] access on step classes for
    277     # manipulating the backed state dict.
    278     return self._state[key]
    279 
    280   def __setitem__(self, key, value):
    281     # Convenience method to allow direct [] access on step classes for
    282     # manipulating the backed state dict.
    283     self._state[key] = value
    284 
    285   def Config(self, key):
    286     return self._config[key]
    287 
    288   def Run(self):
    289     # Restore state.
    290     state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
    291     if not self._state and os.path.exists(state_file):
    292       self._state.update(json.loads(FileToText(state_file)))
    293 
    294     print ">>> Step %d: %s" % (self._number, self._text)
    295     try:
    296       return self.RunStep()
    297     finally:
    298       # Persist state.
    299       TextToFile(json.dumps(self._state), state_file)
    300 
    301   def RunStep(self):  # pragma: no cover
    302     raise NotImplementedError
    303 
    304   def Retry(self, cb, retry_on=None, wait_plan=None):
    305     """ Retry a function.
    306     Params:
    307       cb: The function to retry.
    308       retry_on: A callback that takes the result of the function and returns
    309                 True if the function should be retried. A function throwing an
    310                 exception is always retried.
    311       wait_plan: A list of waiting delays between retries in seconds. The
    312                  maximum number of retries is len(wait_plan).
    313     """
    314     retry_on = retry_on or (lambda x: False)
    315     wait_plan = list(wait_plan or [])
    316     wait_plan.reverse()
    317     while True:
    318       got_exception = False
    319       try:
    320         result = cb()
    321       except NoRetryException as e:
    322         raise e
    323       except Exception as e:
    324         got_exception = e
    325       if got_exception or retry_on(result):
    326         if not wait_plan:  # pragma: no cover
    327           raise Exception("Retried too often. Giving up. Reason: %s" %
    328                           str(got_exception))
    329         wait_time = wait_plan.pop()
    330         print "Waiting for %f seconds." % wait_time
    331         self._side_effect_handler.Sleep(wait_time)
    332         print "Retrying..."
    333       else:
    334         return result
    335 
    336   def ReadLine(self, default=None):
    337     # Don't prompt in forced mode.
    338     if self._options.force_readline_defaults and default is not None:
    339       print "%s (forced)" % default
    340       return default
    341     else:
    342       return self._side_effect_handler.ReadLine()
    343 
    344   def Command(self, name, args, cwd=None):
    345     cmd = lambda: self._side_effect_handler.Command(
    346         name, args, "", True, cwd=cwd or self.default_cwd)
    347     return self.Retry(cmd, None, [5])
    348 
    349   def Git(self, args="", prefix="", pipe=True, retry_on=None, cwd=None):
    350     cmd = lambda: self._side_effect_handler.Command(
    351         "git", args, prefix, pipe, cwd=cwd or self.default_cwd)
    352     result = self.Retry(cmd, retry_on, [5, 30])
    353     if result is None:
    354       raise GitFailedException("'git %s' failed." % args)
    355     return result
    356 
    357   def SVN(self, args="", prefix="", pipe=True, retry_on=None, cwd=None):
    358     cmd = lambda: self._side_effect_handler.Command(
    359         "svn", args, prefix, pipe, cwd=cwd or self.default_cwd)
    360     return self.Retry(cmd, retry_on, [5, 30])
    361 
    362   def Editor(self, args):
    363     if self._options.requires_editor:
    364       return self._side_effect_handler.Command(
    365           os.environ["EDITOR"],
    366           args,
    367           pipe=False,
    368           cwd=self.default_cwd)
    369 
    370   def ReadURL(self, url, params=None, retry_on=None, wait_plan=None):
    371     wait_plan = wait_plan or [3, 60, 600]
    372     cmd = lambda: self._side_effect_handler.ReadURL(url, params)
    373     return self.Retry(cmd, retry_on, wait_plan)
    374 
    375   def GetDate(self):
    376     return self._side_effect_handler.GetDate()
    377 
    378   def Die(self, msg=""):
    379     if msg != "":
    380       print "Error: %s" % msg
    381     print "Exiting"
    382     raise Exception(msg)
    383 
    384   def DieNoManualMode(self, msg=""):
    385     if not self._options.manual:  # pragma: no cover
    386       msg = msg or "Only available in manual mode."
    387       self.Die(msg)
    388 
    389   def Confirm(self, msg):
    390     print "%s [Y/n] " % msg,
    391     answer = self.ReadLine(default="Y")
    392     return answer == "" or answer == "Y" or answer == "y"
    393 
    394   def DeleteBranch(self, name):
    395     for line in self.GitBranch().splitlines():
    396       if re.match(r"\*?\s*%s$" % re.escape(name), line):
    397         msg = "Branch %s exists, do you want to delete it?" % name
    398         if self.Confirm(msg):
    399           self.GitDeleteBranch(name)
    400           print "Branch %s deleted." % name
    401         else:
    402           msg = "Can't continue. Please delete branch %s and try again." % name
    403           self.Die(msg)
    404 
    405   def InitialEnvironmentChecks(self, cwd):
    406     # Cancel if this is not a git checkout.
    407     if not os.path.exists(os.path.join(cwd, ".git")):  # pragma: no cover
    408       self.Die("This is not a git checkout, this script won't work for you.")
    409 
    410     # Cancel if EDITOR is unset or not executable.
    411     if (self._options.requires_editor and (not os.environ.get("EDITOR") or
    412         self.Command(
    413             "which", os.environ["EDITOR"]) is None)):  # pragma: no cover
    414       self.Die("Please set your EDITOR environment variable, you'll need it.")
    415 
    416   def CommonPrepare(self):
    417     # Check for a clean workdir.
    418     if not self.GitIsWorkdirClean():  # pragma: no cover
    419       self.Die("Workspace is not clean. Please commit or undo your changes.")
    420 
    421     # Persist current branch.
    422     self["current_branch"] = self.GitCurrentBranch()
    423 
    424     # Fetch unfetched revisions.
    425     self.GitSVNFetch()
    426 
    427   def PrepareBranch(self):
    428     # Delete the branch that will be created later if it exists already.
    429     self.DeleteBranch(self._config["BRANCHNAME"])
    430 
    431   def CommonCleanup(self):
    432     self.GitCheckout(self["current_branch"])
    433     if self._config["BRANCHNAME"] != self["current_branch"]:
    434       self.GitDeleteBranch(self._config["BRANCHNAME"])
    435 
    436     # Clean up all temporary files.
    437     for f in glob.iglob("%s*" % self._config["PERSISTFILE_BASENAME"]):
    438       if os.path.isfile(f):
    439         os.remove(f)
    440       if os.path.isdir(f):
    441         shutil.rmtree(f)
    442 
    443   def ReadAndPersistVersion(self, prefix=""):
    444     def ReadAndPersist(var_name, def_name):
    445       match = re.match(r"^#define %s\s+(\d*)" % def_name, line)
    446       if match:
    447         value = match.group(1)
    448         self["%s%s" % (prefix, var_name)] = value
    449     for line in LinesInFile(os.path.join(self.default_cwd, VERSION_FILE)):
    450       for (var_name, def_name) in [("major", "MAJOR_VERSION"),
    451                                    ("minor", "MINOR_VERSION"),
    452                                    ("build", "BUILD_NUMBER"),
    453                                    ("patch", "PATCH_LEVEL")]:
    454         ReadAndPersist(var_name, def_name)
    455 
    456   def WaitForLGTM(self):
    457     print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit "
    458            "your change. (If you need to iterate on the patch or double check "
    459            "that it's sane, do so in another shell, but remember to not "
    460            "change the headline of the uploaded CL.")
    461     answer = ""
    462     while answer != "LGTM":
    463       print "> ",
    464       answer = self.ReadLine(None if self._options.wait_for_lgtm else "LGTM")
    465       if answer != "LGTM":
    466         print "That was not 'LGTM'."
    467 
    468   def WaitForResolvingConflicts(self, patch_file):
    469     print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", "
    470           "or resolve the conflicts, stage *all* touched files with "
    471           "'git add', and type \"RESOLVED<Return>\"")
    472     self.DieNoManualMode()
    473     answer = ""
    474     while answer != "RESOLVED":
    475       if answer == "ABORT":
    476         self.Die("Applying the patch failed.")
    477       if answer != "":
    478         print "That was not 'RESOLVED' or 'ABORT'."
    479       print "> ",
    480       answer = self.ReadLine()
    481 
    482   # Takes a file containing the patch to apply as first argument.
    483   def ApplyPatch(self, patch_file, revert=False):
    484     try:
    485       self.GitApplyPatch(patch_file, revert)
    486     except GitFailedException:
    487       self.WaitForResolvingConflicts(patch_file)
    488 
    489   def FindLastTrunkPush(
    490       self, parent_hash="", branch="", include_patches=False):
    491     push_pattern = "^Version [[:digit:]]*\.[[:digit:]]*\.[[:digit:]]*"
    492     if not include_patches:
    493       # Non-patched versions only have three numbers followed by the "(based
    494       # on...) comment."
    495       push_pattern += " (based"
    496     branch = "" if parent_hash else branch or "svn/trunk"
    497     return self.GitLog(n=1, format="%H", grep=push_pattern,
    498                        parent_hash=parent_hash, branch=branch)
    499 
    500   def ArrayToVersion(self, prefix):
    501     return ".".join([self[prefix + "major"],
    502                      self[prefix + "minor"],
    503                      self[prefix + "build"],
    504                      self[prefix + "patch"]])
    505 
    506   def SetVersion(self, version_file, prefix):
    507     output = ""
    508     for line in FileToText(version_file).splitlines():
    509       if line.startswith("#define MAJOR_VERSION"):
    510         line = re.sub("\d+$", self[prefix + "major"], line)
    511       elif line.startswith("#define MINOR_VERSION"):
    512         line = re.sub("\d+$", self[prefix + "minor"], line)
    513       elif line.startswith("#define BUILD_NUMBER"):
    514         line = re.sub("\d+$", self[prefix + "build"], line)
    515       elif line.startswith("#define PATCH_LEVEL"):
    516         line = re.sub("\d+$", self[prefix + "patch"], line)
    517       output += "%s\n" % line
    518     TextToFile(output, version_file)
    519 
    520   def SVNCommit(self, root, commit_message):
    521     patch = self.GitDiff("HEAD^", "HEAD")
    522     TextToFile(patch, self._config["PATCH_FILE"])
    523     self.Command("svn", "update", cwd=self._options.svn)
    524     if self.Command("svn", "status", cwd=self._options.svn) != "":
    525       self.Die("SVN checkout not clean.")
    526     if not self.Command("patch", "-d %s -p1 -i %s" %
    527                         (root, self._config["PATCH_FILE"]),
    528                         cwd=self._options.svn):
    529       self.Die("Could not apply patch.")
    530     self.Command(
    531         "svn",
    532         "commit --non-interactive --username=%s --config-dir=%s -m \"%s\"" %
    533             (self._options.author, self._options.svn_config, commit_message),
    534         cwd=self._options.svn)
    535 
    536 
    537 class UploadStep(Step):
    538   MESSAGE = "Upload for code review."
    539 
    540   def RunStep(self):
    541     if self._options.reviewer:
    542       print "Using account %s for review." % self._options.reviewer
    543       reviewer = self._options.reviewer
    544     else:
    545       print "Please enter the email address of a V8 reviewer for your patch: ",
    546       self.DieNoManualMode("A reviewer must be specified in forced mode.")
    547       reviewer = self.ReadLine()
    548     self.GitUpload(reviewer, self._options.author, self._options.force_upload,
    549                    bypass_hooks=self._options.bypass_upload_hooks)
    550 
    551 
    552 class DetermineV8Sheriff(Step):
    553   MESSAGE = "Determine the V8 sheriff for code review."
    554 
    555   def RunStep(self):
    556     self["sheriff"] = None
    557     if not self._options.sheriff:  # pragma: no cover
    558       return
    559 
    560     try:
    561       # The googlers mapping maps @google.com accounts to @chromium.org
    562       # accounts.
    563       googlers = imp.load_source('googlers_mapping',
    564                                  self._options.googlers_mapping)
    565       googlers = googlers.list_to_dict(googlers.get_list())
    566     except:  # pragma: no cover
    567       print "Skip determining sheriff without googler mapping."
    568       return
    569 
    570     # The sheriff determined by the rotation on the waterfall has a
    571     # @google.com account.
    572     url = "https://chromium-build.appspot.com/p/chromium/sheriff_v8.js"
    573     match = re.match(r"document\.write\('(\w+)'\)", self.ReadURL(url))
    574 
    575     # If "channel is sheriff", we can't match an account.
    576     if match:
    577       g_name = match.group(1)
    578       self["sheriff"] = googlers.get(g_name + "@google.com",
    579                                      g_name + "@chromium.org")
    580       self._options.reviewer = self["sheriff"]
    581       print "Found active sheriff: %s" % self["sheriff"]
    582     else:
    583       print "No active sheriff found."
    584 
    585 
    586 def MakeStep(step_class=Step, number=0, state=None, config=None,
    587              options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER):
    588     # Allow to pass in empty dictionaries.
    589     state = state if state is not None else {}
    590     config = config if config is not None else {}
    591 
    592     try:
    593       message = step_class.MESSAGE
    594     except AttributeError:
    595       message = step_class.__name__
    596 
    597     return step_class(message, number=number, config=config,
    598                       state=state, options=options,
    599                       handler=side_effect_handler)
    600 
    601 
    602 class ScriptsBase(object):
    603   # TODO(machenbach): Move static config here.
    604   def __init__(self,
    605                config=None,
    606                side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER,
    607                state=None):
    608     self._config = config or self._Config()
    609     self._side_effect_handler = side_effect_handler
    610     self._state = state if state is not None else {}
    611 
    612   def _Description(self):
    613     return None
    614 
    615   def _PrepareOptions(self, parser):
    616     pass
    617 
    618   def _ProcessOptions(self, options):
    619     return True
    620 
    621   def _Steps(self):  # pragma: no cover
    622     raise Exception("Not implemented.")
    623 
    624   def _Config(self):
    625     return {}
    626 
    627   def MakeOptions(self, args=None):
    628     parser = argparse.ArgumentParser(description=self._Description())
    629     parser.add_argument("-a", "--author", default="",
    630                         help="The author email used for rietveld.")
    631     parser.add_argument("--dry-run", default=False, action="store_true",
    632                         help="Perform only read-only actions.")
    633     parser.add_argument("-g", "--googlers-mapping",
    634                         help="Path to the script mapping google accounts.")
    635     parser.add_argument("-r", "--reviewer", default="",
    636                         help="The account name to be used for reviews.")
    637     parser.add_argument("--sheriff", default=False, action="store_true",
    638                         help=("Determine current sheriff to review CLs. On "
    639                               "success, this will overwrite the reviewer "
    640                               "option."))
    641     parser.add_argument("--svn",
    642                         help=("Optional full svn checkout for the commit."
    643                               "The folder needs to be the svn root."))
    644     parser.add_argument("--svn-config",
    645                         help=("Optional folder used as svn --config-dir."))
    646     parser.add_argument("-s", "--step",
    647         help="Specify the step where to start work. Default: 0.",
    648         default=0, type=int)
    649     self._PrepareOptions(parser)
    650 
    651     if args is None:  # pragma: no cover
    652       options = parser.parse_args()
    653     else:
    654       options = parser.parse_args(args)
    655 
    656     # Process common options.
    657     if options.step < 0:  # pragma: no cover
    658       print "Bad step number %d" % options.step
    659       parser.print_help()
    660       return None
    661     if options.sheriff and not options.googlers_mapping:  # pragma: no cover
    662       print "To determine the current sheriff, requires the googler mapping"
    663       parser.print_help()
    664       return None
    665     if options.svn and not options.svn_config:
    666       print "Using pure svn for committing requires also --svn-config"
    667       parser.print_help()
    668       return None
    669 
    670     # Defaults for options, common to all scripts.
    671     options.manual = getattr(options, "manual", True)
    672     options.force = getattr(options, "force", False)
    673     options.bypass_upload_hooks = False
    674 
    675     # Derived options.
    676     options.requires_editor = not options.force
    677     options.wait_for_lgtm = not options.force
    678     options.force_readline_defaults = not options.manual
    679     options.force_upload = not options.manual
    680 
    681     # Process script specific options.
    682     if not self._ProcessOptions(options):
    683       parser.print_help()
    684       return None
    685     return options
    686 
    687   def RunSteps(self, step_classes, args=None):
    688     options = self.MakeOptions(args)
    689     if not options:
    690       return 1
    691 
    692     state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
    693     if options.step == 0 and os.path.exists(state_file):
    694       os.remove(state_file)
    695 
    696     steps = []
    697     for (number, step_class) in enumerate(step_classes):
    698       steps.append(MakeStep(step_class, number, self._state, self._config,
    699                             options, self._side_effect_handler))
    700     for step in steps[options.step:]:
    701       if step.Run():
    702         return 0
    703     return 0
    704 
    705   def Run(self, args=None):
    706     return self.RunSteps(self._Steps(), args)
    707