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
48VERSION_FILE = os.path.join("src", "version.cc")
49
50# V8 base directory.
51DEFAULT_CWD = os.path.dirname(
52    os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
53
54
55def TextToFile(text, file_name):
56  with open(file_name, "w") as f:
57    f.write(text)
58
59
60def AppendToFile(text, file_name):
61  with open(file_name, "a") as f:
62    f.write(text)
63
64
65def LinesInFile(file_name):
66  with open(file_name) as f:
67    for line in f:
68      yield line
69
70
71def FileToText(file_name):
72  with open(file_name) as f:
73    return f.read()
74
75
76def MSub(rexp, replacement, text):
77  return re.sub(rexp, replacement, text, flags=re.MULTILINE)
78
79
80def 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
89def MakeComment(text):
90  return MSub(r"^( ?)", "#", text)
91
92
93def 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
98def 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
132def 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
173def 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.
185def 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.
205class 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
251DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()
252
253
254class NoRetryException(Exception):
255  pass
256
257
258class 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
537class 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
552class 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
586def 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
602class 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