Home | History | Annotate | Download | only in autoroller
      1 #!/usr/bin/env python
      2 # Copyright (c) 2015 The WebRTC project authors. All Rights Reserved.
      3 #
      4 # Use of this source code is governed by a BSD-style license
      5 # that can be found in the LICENSE file in the root of the source
      6 # tree. An additional intellectual property rights grant can be found
      7 # in the file PATENTS.  All contributing project authors may
      8 # be found in the AUTHORS file in the root of the source tree.
      9 
     10 """Script to roll chromium_revision in the WebRTC DEPS file."""
     11 
     12 import argparse
     13 import base64
     14 import collections
     15 import logging
     16 import os
     17 import re
     18 import subprocess
     19 import sys
     20 import urllib
     21 
     22 
     23 CHROMIUM_SRC_URL = 'https://chromium.googlesource.com/chromium/src'
     24 CHROMIUM_COMMIT_TEMPLATE = CHROMIUM_SRC_URL + '/+/%s'
     25 CHROMIUM_LOG_TEMPLATE = CHROMIUM_SRC_URL + '/+log/%s'
     26 CHROMIUM_FILE_TEMPLATE = CHROMIUM_SRC_URL + '/+/%s/%s'
     27 
     28 COMMIT_POSITION_RE = re.compile('^Cr-Commit-Position: .*#([0-9]+).*$')
     29 CLANG_REVISION_RE = re.compile(r'^CLANG_REVISION=(\d+)$')
     30 ROLL_BRANCH_NAME = 'roll_chromium_revision'
     31 
     32 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
     33 CHECKOUT_ROOT_DIR = os.path.realpath(os.path.join(SCRIPT_DIR, os.pardir,
     34                                                   os.pardir))
     35 sys.path.append(CHECKOUT_ROOT_DIR)
     36 import setup_links
     37 
     38 sys.path.append(os.path.join(CHECKOUT_ROOT_DIR, 'build'))
     39 import find_depot_tools
     40 find_depot_tools.add_depot_tools_to_path()
     41 from gclient import GClientKeywords
     42 
     43 CLANG_UPDATE_SCRIPT_URL_PATH = 'tools/clang/scripts/update.py'
     44 CLANG_UPDATE_SCRIPT_LOCAL_PATH = os.path.join('tools', 'clang', 'scripts',
     45                                               'update.py')
     46 
     47 DepsEntry = collections.namedtuple('DepsEntry', 'path url revision')
     48 ChangedDep = collections.namedtuple('ChangedDep',
     49                                     'path url current_rev new_rev')
     50 
     51 
     52 def ParseDepsDict(deps_content):
     53   local_scope = {}
     54   var = GClientKeywords.VarImpl({}, local_scope)
     55   global_scope = {
     56     'File': GClientKeywords.FileImpl,
     57     'From': GClientKeywords.FromImpl,
     58     'Var': var.Lookup,
     59     'deps_os': {},
     60   }
     61   exec(deps_content, global_scope, local_scope)
     62   return local_scope
     63 
     64 
     65 def ParseLocalDepsFile(filename):
     66   with open(filename, 'rb') as f:
     67     deps_content = f.read()
     68   return ParseDepsDict(deps_content)
     69 
     70 
     71 def ParseRemoteCrDepsFile(revision):
     72   deps_content = ReadRemoteCrFile('DEPS', revision)
     73   return ParseDepsDict(deps_content)
     74 
     75 
     76 def ParseCommitPosition(commit_message):
     77   for line in reversed(commit_message.splitlines()):
     78     m = COMMIT_POSITION_RE.match(line.strip())
     79     if m:
     80       return m.group(1)
     81   logging.error('Failed to parse commit position id from:\n%s\n',
     82                 commit_message)
     83   sys.exit(-1)
     84 
     85 
     86 def _RunCommand(command, working_dir=None, ignore_exit_code=False,
     87                 extra_env=None):
     88   """Runs a command and returns the output from that command.
     89 
     90   If the command fails (exit code != 0), the function will exit the process.
     91 
     92   Returns:
     93     A tuple containing the stdout and stderr outputs as strings.
     94   """
     95   working_dir = working_dir or CHECKOUT_ROOT_DIR
     96   logging.debug('CMD: %s CWD: %s', ' '.join(command), working_dir)
     97   env = os.environ.copy()
     98   if extra_env:
     99     assert all(type(value) == str for value in extra_env.values())
    100     logging.debug('extra env: %s', extra_env)
    101     env.update(extra_env)
    102   p = subprocess.Popen(command, stdout=subprocess.PIPE,
    103                        stderr=subprocess.PIPE, env=env,
    104                        cwd=working_dir, universal_newlines=True)
    105   std_output = p.stdout.read()
    106   err_output = p.stderr.read()
    107   p.wait()
    108   p.stdout.close()
    109   p.stderr.close()
    110   if not ignore_exit_code and p.returncode != 0:
    111     logging.error('Command failed: %s\n'
    112                   'stdout:\n%s\n'
    113                   'stderr:\n%s\n', ' '.join(command), std_output, err_output)
    114     sys.exit(p.returncode)
    115   return std_output, err_output
    116 
    117 
    118 def _GetBranches():
    119   """Returns a tuple of active,branches.
    120 
    121   The 'active' is the name of the currently active branch and 'branches' is a
    122   list of all branches.
    123   """
    124   lines = _RunCommand(['git', 'branch'])[0].split('\n')
    125   branches = []
    126   active = ''
    127   for line in lines:
    128     if '*' in line:
    129       # The assumption is that the first char will always be the '*'.
    130       active = line[1:].strip()
    131       branches.append(active)
    132     else:
    133       branch = line.strip()
    134       if branch:
    135         branches.append(branch)
    136   return active, branches
    137 
    138 
    139 def _ReadGitilesContent(url):
    140   # Download and decode BASE64 content until
    141   # https://code.google.com/p/gitiles/issues/detail?id=7 is fixed.
    142   base64_content = ReadUrlContent(url + '?format=TEXT')
    143   return base64.b64decode(base64_content[0])
    144 
    145 
    146 def ReadRemoteCrFile(path_below_src, revision):
    147   """Reads a remote Chromium file of a specific revision. Returns a string."""
    148   return _ReadGitilesContent(CHROMIUM_FILE_TEMPLATE % (revision,
    149                                                        path_below_src))
    150 
    151 
    152 def ReadRemoteCrCommit(revision):
    153   """Reads a remote Chromium commit message. Returns a string."""
    154   return _ReadGitilesContent(CHROMIUM_COMMIT_TEMPLATE % revision)
    155 
    156 
    157 def ReadUrlContent(url):
    158   """Connect to a remote host and read the contents. Returns a list of lines."""
    159   conn = urllib.urlopen(url)
    160   try:
    161     return conn.readlines()
    162   except IOError as e:
    163     logging.exception('Error connecting to %s. Error: %s', url, e)
    164     raise
    165   finally:
    166     conn.close()
    167 
    168 
    169 def GetMatchingDepsEntries(depsentry_dict, dir_path):
    170   """Gets all deps entries matching the provided path.
    171 
    172   This list may contain more than one DepsEntry object.
    173   Example: dir_path='src/testing' would give results containing both
    174   'src/testing/gtest' and 'src/testing/gmock' deps entries for Chromium's DEPS.
    175   Example 2: dir_path='src/build' should return 'src/build' but not
    176   'src/buildtools'.
    177 
    178   Returns:
    179     A list of DepsEntry objects.
    180   """
    181   result = []
    182   for path, depsentry in depsentry_dict.iteritems():
    183     if path == dir_path:
    184       result.append(depsentry)
    185     else:
    186       parts = path.split('/')
    187       if all(part == parts[i]
    188              for i, part in enumerate(dir_path.split('/'))):
    189         result.append(depsentry)
    190   return result
    191 
    192 
    193 def BuildDepsentryDict(deps_dict):
    194   """Builds a dict of DepsEntry object from a raw parsed deps dict."""
    195   result = {}
    196   def AddDepsEntries(deps_subdict):
    197     for path, deps_url in deps_subdict.iteritems():
    198       if not result.has_key(path):
    199         url, revision = deps_url.split('@') if deps_url else (None, None)
    200         result[path] = DepsEntry(path, url, revision)
    201 
    202   AddDepsEntries(deps_dict['deps'])
    203   for deps_os in ['win', 'mac', 'unix', 'android', 'ios', 'unix']:
    204     AddDepsEntries(deps_dict['deps_os'].get(deps_os, {}))
    205   return result
    206 
    207 
    208 def CalculateChangedDeps(current_deps, new_deps):
    209   result = []
    210   current_entries = BuildDepsentryDict(current_deps)
    211   new_entries = BuildDepsentryDict(new_deps)
    212 
    213   all_deps_dirs = setup_links.DIRECTORIES
    214   for deps_dir in all_deps_dirs:
    215     # All deps have 'src' prepended to the path in the Chromium DEPS file.
    216     dir_path = 'src/%s' % deps_dir
    217 
    218     for entry in GetMatchingDepsEntries(current_entries, dir_path):
    219       new_matching_entries = GetMatchingDepsEntries(new_entries, entry.path)
    220       assert len(new_matching_entries) <= 1, (
    221           'Should never find more than one entry matching %s in %s, found %d' %
    222           (entry.path, new_entries, len(new_matching_entries)))
    223       if not new_matching_entries:
    224         result.append(ChangedDep(entry.path, entry.url, entry.revision, 'None'))
    225       elif entry != new_matching_entries[0]:
    226         result.append(ChangedDep(entry.path, entry.url, entry.revision,
    227                                  new_matching_entries[0].revision))
    228   return result
    229 
    230 
    231 def CalculateChangedClang(new_cr_rev):
    232   def GetClangRev(lines):
    233     for line in lines:
    234       match = CLANG_REVISION_RE.match(line)
    235       if match:
    236         return match.group(1)
    237     return None
    238 
    239   chromium_src_path = os.path.join(CHECKOUT_ROOT_DIR, 'chromium', 'src',
    240                                    CLANG_UPDATE_SCRIPT_LOCAL_PATH)
    241   with open(chromium_src_path, 'rb') as f:
    242     current_lines = f.readlines()
    243   current_rev = GetClangRev(current_lines)
    244 
    245   new_clang_update_sh = ReadRemoteCrFile(CLANG_UPDATE_SCRIPT_URL_PATH,
    246                                          new_cr_rev).splitlines()
    247   new_rev = GetClangRev(new_clang_update_sh)
    248   return ChangedDep(CLANG_UPDATE_SCRIPT_LOCAL_PATH, None, current_rev, new_rev)
    249 
    250 
    251 def GenerateCommitMessage(current_cr_rev, new_cr_rev, current_commit_pos,
    252                           new_commit_pos, changed_deps_list, clang_change):
    253   current_cr_rev = current_cr_rev[0:7]
    254   new_cr_rev = new_cr_rev[0:7]
    255   rev_interval = '%s..%s' % (current_cr_rev, new_cr_rev)
    256   git_number_interval = '%s:%s' % (current_commit_pos, new_commit_pos)
    257 
    258   commit_msg = ['Roll chromium_revision %s (%s)\n' % (rev_interval,
    259                                                     git_number_interval)]
    260   commit_msg.append('Change log: %s' % (CHROMIUM_LOG_TEMPLATE % rev_interval))
    261   commit_msg.append('Full diff: %s\n' % (CHROMIUM_COMMIT_TEMPLATE %
    262                                          rev_interval))
    263   # TBR field will be empty unless in some custom cases, where some engineers
    264   # are added.
    265   tbr_authors = ''
    266   if changed_deps_list:
    267     commit_msg.append('Changed dependencies:')
    268 
    269     for c in changed_deps_list:
    270       commit_msg.append('* %s: %s/+log/%s..%s' % (c.path, c.url,
    271                                                   c.current_rev[0:7],
    272                                                   c.new_rev[0:7]))
    273       if 'libvpx' in c.path:
    274         tbr_authors += 'marpan (at] webrtc.org, stefan (at] webrtc.org, '
    275 
    276     change_url = CHROMIUM_FILE_TEMPLATE % (rev_interval, 'DEPS')
    277     commit_msg.append('DEPS diff: %s\n' % change_url)
    278   else:
    279     commit_msg.append('No dependencies changed.')
    280 
    281   if clang_change.current_rev != clang_change.new_rev:
    282     commit_msg.append('Clang version changed %s:%s' %
    283                       (clang_change.current_rev, clang_change.new_rev))
    284     change_url = CHROMIUM_FILE_TEMPLATE % (rev_interval,
    285                                            CLANG_UPDATE_SCRIPT_URL_PATH)
    286     commit_msg.append('Details: %s\n' % change_url)
    287     tbr_authors += 'pbos (at] webrtc.org'
    288   else:
    289     commit_msg.append('No update to Clang.\n')
    290 
    291   commit_msg.append('TBR=%s' % tbr_authors)
    292   return '\n'.join(commit_msg)
    293 
    294 
    295 def UpdateDeps(deps_filename, old_cr_revision, new_cr_revision):
    296   """Update the DEPS file with the new revision."""
    297   with open(deps_filename, 'rb') as deps_file:
    298     deps_content = deps_file.read()
    299   deps_content = deps_content.replace(old_cr_revision, new_cr_revision)
    300   with open(deps_filename, 'wb') as deps_file:
    301     deps_file.write(deps_content)
    302 
    303 def _IsTreeClean():
    304   stdout, _ = _RunCommand(['git', 'status', '--porcelain'])
    305   if len(stdout) == 0:
    306     return True
    307 
    308   logging.error('Dirty/unversioned files:\n%s', stdout)
    309   return False
    310 
    311 
    312 def _EnsureUpdatedMasterBranch(dry_run):
    313   current_branch = _RunCommand(
    314       ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])[0].splitlines()[0]
    315   if current_branch != 'master':
    316     logging.error('Please checkout the master branch and re-run this script.')
    317     if not dry_run:
    318       sys.exit(-1)
    319 
    320   logging.info('Updating master branch...')
    321   _RunCommand(['git', 'pull'])
    322 
    323 
    324 def _CreateRollBranch(dry_run):
    325   logging.info('Creating roll branch: %s', ROLL_BRANCH_NAME)
    326   if not dry_run:
    327     _RunCommand(['git', 'checkout', '-b', ROLL_BRANCH_NAME])
    328 
    329 
    330 def _RemovePreviousRollBranch(dry_run):
    331   active_branch, branches = _GetBranches()
    332   if active_branch == ROLL_BRANCH_NAME:
    333     active_branch = 'master'
    334   if ROLL_BRANCH_NAME in branches:
    335     logging.info('Removing previous roll branch (%s)', ROLL_BRANCH_NAME)
    336     if not dry_run:
    337       _RunCommand(['git', 'checkout', active_branch])
    338       _RunCommand(['git', 'branch', '-D', ROLL_BRANCH_NAME])
    339 
    340 
    341 def _LocalCommit(commit_msg, dry_run):
    342   logging.info('Committing changes locally.')
    343   if not dry_run:
    344     _RunCommand(['git', 'add', '--update', '.'])
    345     _RunCommand(['git', 'commit', '-m', commit_msg])
    346 
    347 
    348 def _UploadCL(dry_run, rietveld_email=None):
    349   logging.info('Uploading CL...')
    350   if not dry_run:
    351     cmd = ['git', 'cl', 'upload', '-f']
    352     if rietveld_email:
    353       cmd.append('--email=%s' % rietveld_email)
    354     _RunCommand(cmd, extra_env={'EDITOR': 'true'})
    355 
    356 
    357 def _LaunchTrybots(dry_run, skip_try):
    358   logging.info('Sending tryjobs...')
    359   if not dry_run and not skip_try:
    360     _RunCommand(['git', 'cl', 'try'])
    361 
    362 
    363 def _SendToCQ(dry_run, skip_cq):
    364   logging.info('Sending the CL to the CQ...')
    365   if not dry_run and not skip_cq:
    366     _RunCommand(['git', 'cl', 'set_commit'])
    367     logging.info('Sent the CL to the CQ.')
    368 
    369 
    370 def main():
    371   p = argparse.ArgumentParser()
    372   p.add_argument('--clean', action='store_true', default=False,
    373                  help='Removes any previous local roll branch.')
    374   p.add_argument('-r', '--revision',
    375                  help=('Chromium Git revision to roll to. Defaults to the '
    376                        'Chromium HEAD revision if omitted.'))
    377   p.add_argument('-u', '--rietveld-email',
    378                  help=('E-mail address to use for creating the CL at Rietveld'
    379                        'If omitted a previously cached one will be used or an '
    380                        'error will be thrown during upload.'))
    381   p.add_argument('--dry-run', action='store_true', default=False,
    382                  help=('Calculate changes and modify DEPS, but don\'t create '
    383                        'any local branch, commit, upload CL or send any '
    384                        'tryjobs.'))
    385   p.add_argument('--allow-reverse', action='store_true', default=False,
    386                  help=('Allow rolling back in time (disabled by default but '
    387                        'may be useful to be able do to manually).'))
    388   p.add_argument('-s', '--skip-try', action='store_true', default=False,
    389                  help='Skip sending tryjobs (default: %(default)s)')
    390   p.add_argument('--skip-cq', action='store_true', default=False,
    391                  help='Skip sending the CL to the CQ (default: %(default)s)')
    392   p.add_argument('-v', '--verbose', action='store_true', default=False,
    393                  help='Be extra verbose in printing of log messages.')
    394   opts = p.parse_args()
    395 
    396   if opts.verbose:
    397     logging.basicConfig(level=logging.DEBUG)
    398   else:
    399     logging.basicConfig(level=logging.INFO)
    400 
    401   if not _IsTreeClean():
    402     logging.error('Please clean your local checkout first.')
    403     return 1
    404 
    405   if opts.clean:
    406     _RemovePreviousRollBranch(opts.dry_run)
    407 
    408   _EnsureUpdatedMasterBranch(opts.dry_run)
    409 
    410   new_cr_rev = opts.revision
    411   if not new_cr_rev:
    412     stdout, _ = _RunCommand(['git', 'ls-remote', CHROMIUM_SRC_URL, 'HEAD'])
    413     head_rev = stdout.strip().split('\t')[0]
    414     logging.info('No revision specified. Using HEAD: %s', head_rev)
    415     new_cr_rev = head_rev
    416 
    417   deps_filename = os.path.join(CHECKOUT_ROOT_DIR, 'DEPS')
    418   local_deps = ParseLocalDepsFile(deps_filename)
    419   current_cr_rev = local_deps['vars']['chromium_revision']
    420 
    421   current_commit_pos = ParseCommitPosition(ReadRemoteCrCommit(current_cr_rev))
    422   new_commit_pos = ParseCommitPosition(ReadRemoteCrCommit(new_cr_rev))
    423 
    424   current_cr_deps = ParseRemoteCrDepsFile(current_cr_rev)
    425   new_cr_deps = ParseRemoteCrDepsFile(new_cr_rev)
    426 
    427   if new_commit_pos > current_commit_pos or opts.allow_reverse:
    428     changed_deps = sorted(CalculateChangedDeps(current_cr_deps, new_cr_deps))
    429     clang_change = CalculateChangedClang(new_cr_rev)
    430     commit_msg = GenerateCommitMessage(current_cr_rev, new_cr_rev,
    431                                        current_commit_pos, new_commit_pos,
    432                                        changed_deps, clang_change)
    433     logging.debug('Commit message:\n%s', commit_msg)
    434   else:
    435     logging.info('Currently pinned chromium_revision: %s (#%s) is newer than '
    436                  '%s (#%s). To roll to older revisions, you must pass the '
    437                  '--allow-reverse flag.\n'
    438                  'Aborting without action.', current_cr_rev, current_commit_pos,
    439                  new_cr_rev, new_commit_pos)
    440     return 0
    441 
    442   _CreateRollBranch(opts.dry_run)
    443   UpdateDeps(deps_filename, current_cr_rev, new_cr_rev)
    444   _LocalCommit(commit_msg, opts.dry_run)
    445   _UploadCL(opts.dry_run, opts.rietveld_email)
    446   _LaunchTrybots(opts.dry_run, opts.skip_try)
    447   _SendToCQ(opts.dry_run, opts.skip_cq)
    448   return 0
    449 
    450 
    451 if __name__ == '__main__':
    452   sys.exit(main())
    453