Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Snapshot Build Bisect Tool
      7 
      8 This script bisects a snapshot archive using binary search. It starts at
      9 a bad revision (it will try to guess HEAD) and asks for a last known-good
     10 revision. It will then binary search across this revision range by downloading,
     11 unzipping, and opening Chromium for you. After testing the specific revision,
     12 it will ask you whether it is good or bad before continuing the search.
     13 """
     14 
     15 # The base URL for stored build archives.
     16 CHROMIUM_BASE_URL = ('http://commondatastorage.googleapis.com'
     17                      '/chromium-browser-snapshots')
     18 WEBKIT_BASE_URL = ('http://commondatastorage.googleapis.com'
     19                    '/chromium-webkit-snapshots')
     20 ASAN_BASE_URL = ('http://commondatastorage.googleapis.com'
     21                  '/chromium-browser-asan')
     22 
     23 # GS bucket name.
     24 GS_BUCKET_NAME = 'chrome-unsigned/desktop-W15K3Y'
     25 
     26 # Base URL for downloading official builds.
     27 GOOGLE_APIS_URL = 'commondatastorage.googleapis.com'
     28 
     29 # The base URL for official builds.
     30 OFFICIAL_BASE_URL = 'http://%s/%s' % (GOOGLE_APIS_URL, GS_BUCKET_NAME)
     31 
     32 # URL template for viewing changelogs between revisions.
     33 CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/src/+log/%s..%s')
     34 
     35 # URL to convert SVN revision to git hash.
     36 CRREV_URL = ('https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/')
     37 
     38 # URL template for viewing changelogs between official versions.
     39 OFFICIAL_CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/'
     40                           'src/+log/%s..%s?pretty=full')
     41 
     42 # DEPS file URL.
     43 DEPS_FILE_OLD = ('http://src.chromium.org/viewvc/chrome/trunk/src/'
     44                  'DEPS?revision=%d')
     45 DEPS_FILE_NEW = ('https://chromium.googlesource.com/chromium/src/+/%s/DEPS')
     46 
     47 # Blink changelogs URL.
     48 BLINK_CHANGELOG_URL = ('http://build.chromium.org'
     49                       '/f/chromium/perf/dashboard/ui/changelog_blink.html'
     50                       '?url=/trunk&range=%d%%3A%d')
     51 
     52 DONE_MESSAGE_GOOD_MIN = ('You are probably looking for a change made after %s ('
     53                          'known good), but no later than %s (first known bad).')
     54 DONE_MESSAGE_GOOD_MAX = ('You are probably looking for a change made after %s ('
     55                          'known bad), but no later than %s (first known good).')
     56 
     57 CHROMIUM_GITHASH_TO_SVN_URL = (
     58     'https://chromium.googlesource.com/chromium/src/+/%s?format=json')
     59 
     60 BLINK_GITHASH_TO_SVN_URL = (
     61     'https://chromium.googlesource.com/chromium/blink/+/%s?format=json')
     62 
     63 GITHASH_TO_SVN_URL = {
     64     'chromium': CHROMIUM_GITHASH_TO_SVN_URL,
     65     'blink': BLINK_GITHASH_TO_SVN_URL,
     66 }
     67 
     68 # Search pattern to be matched in the JSON output from
     69 # CHROMIUM_GITHASH_TO_SVN_URL to get the chromium revision (svn revision).
     70 CHROMIUM_SEARCH_PATTERN_OLD = (
     71     r'.*git-svn-id: svn://svn.chromium.org/chrome/trunk/src@(\d+) ')
     72 CHROMIUM_SEARCH_PATTERN = (
     73     r'Cr-Commit-Position: refs/heads/master@{#(\d+)}')
     74 
     75 # Search pattern to be matched in the json output from
     76 # BLINK_GITHASH_TO_SVN_URL to get the blink revision (svn revision).
     77 BLINK_SEARCH_PATTERN = (
     78     r'.*git-svn-id: svn://svn.chromium.org/blink/trunk@(\d+) ')
     79 
     80 SEARCH_PATTERN = {
     81     'chromium': CHROMIUM_SEARCH_PATTERN,
     82     'blink': BLINK_SEARCH_PATTERN,
     83 }
     84 
     85 CREDENTIAL_ERROR_MESSAGE = ('You are attempting to access protected data with '
     86                             'no configured credentials')
     87 
     88 ###############################################################################
     89 
     90 import httplib
     91 import json
     92 import optparse
     93 import os
     94 import re
     95 import shlex
     96 import shutil
     97 import subprocess
     98 import sys
     99 import tempfile
    100 import threading
    101 import urllib
    102 from distutils.version import LooseVersion
    103 from xml.etree import ElementTree
    104 import zipfile
    105 
    106 
    107 class PathContext(object):
    108   """A PathContext is used to carry the information used to construct URLs and
    109   paths when dealing with the storage server and archives."""
    110   def __init__(self, base_url, platform, good_revision, bad_revision,
    111                is_official, is_asan, use_local_repo, flash_path = None,
    112                pdf_path = None):
    113     super(PathContext, self).__init__()
    114     # Store off the input parameters.
    115     self.base_url = base_url
    116     self.platform = platform  # What's passed in to the '-a/--archive' option.
    117     self.good_revision = good_revision
    118     self.bad_revision = bad_revision
    119     self.is_official = is_official
    120     self.is_asan = is_asan
    121     self.build_type = 'release'
    122     self.flash_path = flash_path
    123     # Dictionary which stores svn revision number as key and it's
    124     # corresponding git hash as value. This data is populated in
    125     # _FetchAndParse and used later in GetDownloadURL while downloading
    126     # the build.
    127     self.githash_svn_dict = {}
    128     self.pdf_path = pdf_path
    129 
    130     # The name of the ZIP file in a revision directory on the server.
    131     self.archive_name = None
    132 
    133     # If the script is run from a local Chromium checkout,
    134     # "--use-local-repo" option can be used to make the script run faster.
    135     # It uses "git svn find-rev <SHA1>" command to convert git hash to svn
    136     # revision number.
    137     self.use_local_repo = use_local_repo
    138 
    139     # Set some internal members:
    140     #   _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
    141     #   _archive_extract_dir = Uncompressed directory in the archive_name file.
    142     #   _binary_name = The name of the executable to run.
    143     if self.platform in ('linux', 'linux64', 'linux-arm'):
    144       self._binary_name = 'chrome'
    145     elif self.platform in ('mac', 'mac64'):
    146       self.archive_name = 'chrome-mac.zip'
    147       self._archive_extract_dir = 'chrome-mac'
    148     elif self.platform in ('win', 'win64'):
    149       self.archive_name = 'chrome-win32.zip'
    150       self._archive_extract_dir = 'chrome-win32'
    151       self._binary_name = 'chrome.exe'
    152     else:
    153       raise Exception('Invalid platform: %s' % self.platform)
    154 
    155     if is_official:
    156       if self.platform == 'linux':
    157         self._listing_platform_dir = 'precise32/'
    158         self.archive_name = 'chrome-precise32.zip'
    159         self._archive_extract_dir = 'chrome-precise32'
    160       elif self.platform == 'linux64':
    161         self._listing_platform_dir = 'precise64/'
    162         self.archive_name = 'chrome-precise64.zip'
    163         self._archive_extract_dir = 'chrome-precise64'
    164       elif self.platform == 'mac':
    165         self._listing_platform_dir = 'mac/'
    166         self._binary_name = 'Google Chrome.app/Contents/MacOS/Google Chrome'
    167       elif self.platform == 'mac64':
    168         self._listing_platform_dir = 'mac64/'
    169         self._binary_name = 'Google Chrome.app/Contents/MacOS/Google Chrome'
    170       elif self.platform == 'win':
    171         self._listing_platform_dir = 'win/'
    172         self.archive_name = 'chrome-win.zip'
    173         self._archive_extract_dir = 'chrome-win'
    174       elif self.platform == 'win64':
    175         self._listing_platform_dir = 'win64/'
    176         self.archive_name = 'chrome-win64.zip'
    177         self._archive_extract_dir = 'chrome-win64'
    178     else:
    179       if self.platform in ('linux', 'linux64', 'linux-arm'):
    180         self.archive_name = 'chrome-linux.zip'
    181         self._archive_extract_dir = 'chrome-linux'
    182         if self.platform == 'linux':
    183           self._listing_platform_dir = 'Linux/'
    184         elif self.platform == 'linux64':
    185           self._listing_platform_dir = 'Linux_x64/'
    186         elif self.platform == 'linux-arm':
    187           self._listing_platform_dir = 'Linux_ARM_Cross-Compile/'
    188       elif self.platform == 'mac':
    189         self._listing_platform_dir = 'Mac/'
    190         self._binary_name = 'Chromium.app/Contents/MacOS/Chromium'
    191       elif self.platform == 'win':
    192         self._listing_platform_dir = 'Win/'
    193 
    194   def GetASANPlatformDir(self):
    195     """ASAN builds are in directories like "linux-release", or have filenames
    196     like "asan-win32-release-277079.zip". This aligns to our platform names
    197     except in the case of Windows where they use "win32" instead of "win"."""
    198     if self.platform == 'win':
    199       return 'win32'
    200     else:
    201       return self.platform
    202 
    203   def GetListingURL(self, marker=None):
    204     """Returns the URL for a directory listing, with an optional marker."""
    205     marker_param = ''
    206     if marker:
    207       marker_param = '&marker=' + str(marker)
    208     if self.is_asan:
    209       prefix = '%s-%s' % (self.GetASANPlatformDir(), self.build_type)
    210       return self.base_url + '/?delimiter=&prefix=' + prefix + marker_param
    211     else:
    212       return (self.base_url + '/?delimiter=/&prefix=' +
    213               self._listing_platform_dir + marker_param)
    214 
    215   def GetDownloadURL(self, revision):
    216     """Gets the download URL for a build archive of a specific revision."""
    217     if self.is_asan:
    218       return '%s/%s-%s/%s-%d.zip' % (
    219           ASAN_BASE_URL, self.GetASANPlatformDir(), self.build_type,
    220           self.GetASANBaseName(), revision)
    221     if self.is_official:
    222       return '%s/%s/%s%s' % (
    223           OFFICIAL_BASE_URL, revision, self._listing_platform_dir,
    224           self.archive_name)
    225     else:
    226       if str(revision) in self.githash_svn_dict:
    227         revision = self.githash_svn_dict[str(revision)]
    228       return '%s/%s%s/%s' % (self.base_url, self._listing_platform_dir,
    229                              revision, self.archive_name)
    230 
    231   def GetLastChangeURL(self):
    232     """Returns a URL to the LAST_CHANGE file."""
    233     return self.base_url + '/' + self._listing_platform_dir + 'LAST_CHANGE'
    234 
    235   def GetASANBaseName(self):
    236     """Returns the base name of the ASAN zip file."""
    237     if 'linux' in self.platform:
    238       return 'asan-symbolized-%s-%s' % (self.GetASANPlatformDir(),
    239                                         self.build_type)
    240     else:
    241       return 'asan-%s-%s' % (self.GetASANPlatformDir(), self.build_type)
    242 
    243   def GetLaunchPath(self, revision):
    244     """Returns a relative path (presumably from the archive extraction location)
    245     that is used to run the executable."""
    246     if self.is_asan:
    247       extract_dir = '%s-%d' % (self.GetASANBaseName(), revision)
    248     else:
    249       extract_dir = self._archive_extract_dir
    250     return os.path.join(extract_dir, self._binary_name)
    251 
    252   def ParseDirectoryIndex(self):
    253     """Parses the Google Storage directory listing into a list of revision
    254     numbers."""
    255 
    256     def _FetchAndParse(url):
    257       """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
    258       next-marker is not None, then the listing is a partial listing and another
    259       fetch should be performed with next-marker being the marker= GET
    260       parameter."""
    261       handle = urllib.urlopen(url)
    262       document = ElementTree.parse(handle)
    263 
    264       # All nodes in the tree are namespaced. Get the root's tag name to extract
    265       # the namespace. Etree does namespaces as |{namespace}tag|.
    266       root_tag = document.getroot().tag
    267       end_ns_pos = root_tag.find('}')
    268       if end_ns_pos == -1:
    269         raise Exception('Could not locate end namespace for directory index')
    270       namespace = root_tag[:end_ns_pos + 1]
    271 
    272       # Find the prefix (_listing_platform_dir) and whether or not the list is
    273       # truncated.
    274       prefix_len = len(document.find(namespace + 'Prefix').text)
    275       next_marker = None
    276       is_truncated = document.find(namespace + 'IsTruncated')
    277       if is_truncated is not None and is_truncated.text.lower() == 'true':
    278         next_marker = document.find(namespace + 'NextMarker').text
    279       # Get a list of all the revisions.
    280       revisions = []
    281       githash_svn_dict = {}
    282       if self.is_asan:
    283         asan_regex = re.compile(r'.*%s-(\d+)\.zip$' % (self.GetASANBaseName()))
    284         # Non ASAN builds are in a <revision> directory. The ASAN builds are
    285         # flat
    286         all_prefixes = document.findall(namespace + 'Contents/' +
    287                                         namespace + 'Key')
    288         for prefix in all_prefixes:
    289           m = asan_regex.match(prefix.text)
    290           if m:
    291             try:
    292               revisions.append(int(m.group(1)))
    293             except ValueError:
    294               pass
    295       else:
    296         all_prefixes = document.findall(namespace + 'CommonPrefixes/' +
    297                                         namespace + 'Prefix')
    298         # The <Prefix> nodes have content of the form of
    299         # |_listing_platform_dir/revision/|. Strip off the platform dir and the
    300         # trailing slash to just have a number.
    301         for prefix in all_prefixes:
    302           revnum = prefix.text[prefix_len:-1]
    303           try:
    304             if not revnum.isdigit():
    305               git_hash = revnum
    306               revnum = self.GetSVNRevisionFromGitHash(git_hash)
    307               githash_svn_dict[revnum] = git_hash
    308             if revnum is not None:
    309               revnum = int(revnum)
    310               revisions.append(revnum)
    311           except ValueError:
    312             pass
    313       return (revisions, next_marker, githash_svn_dict)
    314 
    315     # Fetch the first list of revisions.
    316     (revisions, next_marker, self.githash_svn_dict) = _FetchAndParse(
    317         self.GetListingURL())
    318     # If the result list was truncated, refetch with the next marker. Do this
    319     # until an entire directory listing is done.
    320     while next_marker:
    321       next_url = self.GetListingURL(next_marker)
    322       (new_revisions, next_marker, new_dict) = _FetchAndParse(next_url)
    323       revisions.extend(new_revisions)
    324       self.githash_svn_dict.update(new_dict)
    325     return revisions
    326 
    327   def _GetSVNRevisionFromGitHashWithoutGitCheckout(self, git_sha1, depot):
    328     json_url = GITHASH_TO_SVN_URL[depot] % git_sha1
    329     response = urllib.urlopen(json_url)
    330     if response.getcode() == 200:
    331       try:
    332         data = json.loads(response.read()[4:])
    333       except ValueError:
    334         print 'ValueError for JSON URL: %s' % json_url
    335         raise ValueError
    336     else:
    337       raise ValueError
    338     if 'message' in data:
    339       message = data['message'].split('\n')
    340       message = [line for line in message if line.strip()]
    341       search_pattern = re.compile(SEARCH_PATTERN[depot])
    342       result = search_pattern.search(message[len(message)-1])
    343       if result:
    344         return result.group(1)
    345       else:
    346         if depot == 'chromium':
    347           result = re.search(CHROMIUM_SEARCH_PATTERN_OLD,
    348                              message[len(message)-1])
    349           if result:
    350             return result.group(1)
    351     print 'Failed to get svn revision number for %s' % git_sha1
    352     raise ValueError
    353 
    354   def _GetSVNRevisionFromGitHashFromGitCheckout(self, git_sha1, depot):
    355     def _RunGit(command, path):
    356       command = ['git'] + command
    357       if path:
    358         original_path = os.getcwd()
    359         os.chdir(path)
    360       shell = sys.platform.startswith('win')
    361       proc = subprocess.Popen(command, shell=shell, stdout=subprocess.PIPE,
    362                               stderr=subprocess.PIPE)
    363       (output, _) = proc.communicate()
    364 
    365       if path:
    366         os.chdir(original_path)
    367       return (output, proc.returncode)
    368 
    369     path = None
    370     if depot == 'blink':
    371       path = os.path.join(os.getcwd(), 'third_party', 'WebKit')
    372     if os.path.basename(os.getcwd()) == 'src':
    373       command = ['svn', 'find-rev', git_sha1]
    374       (git_output, return_code) = _RunGit(command, path)
    375       if not return_code:
    376         return git_output.strip('\n')
    377       raise ValueError
    378     else:
    379       print ('Script should be run from src folder. ' +
    380              'Eg: python tools/bisect-builds.py -g 280588 -b 280590' +
    381              '--archive linux64 --use-local-repo')
    382       sys.exit(1)
    383 
    384   def GetSVNRevisionFromGitHash(self, git_sha1, depot='chromium'):
    385     if not self.use_local_repo:
    386       return self._GetSVNRevisionFromGitHashWithoutGitCheckout(git_sha1, depot)
    387     else:
    388       return self._GetSVNRevisionFromGitHashFromGitCheckout(git_sha1, depot)
    389 
    390   def GetRevList(self):
    391     """Gets the list of revision numbers between self.good_revision and
    392     self.bad_revision."""
    393     # Download the revlist and filter for just the range between good and bad.
    394     minrev = min(self.good_revision, self.bad_revision)
    395     maxrev = max(self.good_revision, self.bad_revision)
    396     revlist_all = map(int, self.ParseDirectoryIndex())
    397 
    398     revlist = [x for x in revlist_all if x >= int(minrev) and x <= int(maxrev)]
    399     revlist.sort()
    400 
    401     # Set good and bad revisions to be legit revisions.
    402     if revlist:
    403       if self.good_revision < self.bad_revision:
    404         self.good_revision = revlist[0]
    405         self.bad_revision = revlist[-1]
    406       else:
    407         self.bad_revision = revlist[0]
    408         self.good_revision = revlist[-1]
    409 
    410       # Fix chromium rev so that the deps blink revision matches REVISIONS file.
    411       if self.base_url == WEBKIT_BASE_URL:
    412         revlist_all.sort()
    413         self.good_revision = FixChromiumRevForBlink(revlist,
    414                                                     revlist_all,
    415                                                     self,
    416                                                     self.good_revision)
    417         self.bad_revision = FixChromiumRevForBlink(revlist,
    418                                                    revlist_all,
    419                                                    self,
    420                                                    self.bad_revision)
    421     return revlist
    422 
    423   def GetOfficialBuildsList(self):
    424     """Gets the list of official build numbers between self.good_revision and
    425     self.bad_revision."""
    426 
    427     def CheckDepotToolsInPath():
    428       delimiter = ';' if sys.platform.startswith('win') else ':'
    429       path_list = os.environ['PATH'].split(delimiter)
    430       for path in path_list:
    431         if path.find('depot_tools') != -1:
    432           return path
    433       return None
    434 
    435     def RunGsutilCommand(args):
    436       gsutil_path = CheckDepotToolsInPath()
    437       if gsutil_path is None:
    438         print ('Follow the instructions in this document '
    439                'http://dev.chromium.org/developers/how-tos/install-depot-tools'
    440                ' to install depot_tools and then try again.')
    441         sys.exit(1)
    442       gsutil_path = os.path.join(gsutil_path, 'third_party', 'gsutil', 'gsutil')
    443       gsutil = subprocess.Popen([sys.executable, gsutil_path] + args,
    444                                 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    445                                 env=None)
    446       stdout, stderr = gsutil.communicate()
    447       if gsutil.returncode:
    448         if (re.findall(r'status[ |=]40[1|3]', stderr) or
    449             stderr.startswith(CREDENTIAL_ERROR_MESSAGE)):
    450           print ('Follow these steps to configure your credentials and try'
    451                  ' running the bisect-builds.py again.:\n'
    452                  '  1. Run "python %s config" and follow its instructions.\n'
    453                  '  2. If you have a @google.com account, use that account.\n'
    454                  '  3. For the project-id, just enter 0.' % gsutil_path)
    455           sys.exit(1)
    456         else:
    457           raise Exception('Error running the gsutil command: %s' % stderr)
    458       return stdout
    459 
    460     def GsutilList(bucket):
    461       query = 'gs://%s/' % bucket
    462       stdout = RunGsutilCommand(['ls', query])
    463       return [url[len(query):].strip('/') for url in stdout.splitlines()]
    464 
    465     # Download the revlist and filter for just the range between good and bad.
    466     minrev = min(self.good_revision, self.bad_revision)
    467     maxrev = max(self.good_revision, self.bad_revision)
    468     build_numbers = GsutilList(GS_BUCKET_NAME)
    469     revision_re = re.compile(r'(\d\d\.\d\.\d{4}\.\d+)')
    470     build_numbers = filter(lambda b: revision_re.search(b), build_numbers)
    471     final_list = []
    472     parsed_build_numbers = [LooseVersion(x) for x in build_numbers]
    473     connection = httplib.HTTPConnection(GOOGLE_APIS_URL)
    474     for build_number in sorted(parsed_build_numbers):
    475       if build_number > maxrev:
    476         break
    477       if build_number < minrev:
    478         continue
    479       path = ('/' + GS_BUCKET_NAME + '/' + str(build_number) + '/' +
    480               self._listing_platform_dir + self.archive_name)
    481       connection.request('HEAD', path)
    482       response = connection.getresponse()
    483       if response.status == 200:
    484         final_list.append(str(build_number))
    485       response.read()
    486     connection.close()
    487     return final_list
    488 
    489 def UnzipFilenameToDir(filename, directory):
    490   """Unzip |filename| to |directory|."""
    491   cwd = os.getcwd()
    492   if not os.path.isabs(filename):
    493     filename = os.path.join(cwd, filename)
    494   zf = zipfile.ZipFile(filename)
    495   # Make base.
    496   if not os.path.isdir(directory):
    497     os.mkdir(directory)
    498   os.chdir(directory)
    499   # Extract files.
    500   for info in zf.infolist():
    501     name = info.filename
    502     if name.endswith('/'):  # dir
    503       if not os.path.isdir(name):
    504         os.makedirs(name)
    505     else:  # file
    506       directory = os.path.dirname(name)
    507       if not os.path.isdir(directory):
    508         os.makedirs(directory)
    509       out = open(name, 'wb')
    510       out.write(zf.read(name))
    511       out.close()
    512     # Set permissions. Permission info in external_attr is shifted 16 bits.
    513     os.chmod(name, info.external_attr >> 16L)
    514   os.chdir(cwd)
    515 
    516 
    517 def FetchRevision(context, rev, filename, quit_event=None, progress_event=None):
    518   """Downloads and unzips revision |rev|.
    519   @param context A PathContext instance.
    520   @param rev The Chromium revision number/tag to download.
    521   @param filename The destination for the downloaded file.
    522   @param quit_event A threading.Event which will be set by the master thread to
    523                     indicate that the download should be aborted.
    524   @param progress_event A threading.Event which will be set by the master thread
    525                     to indicate that the progress of the download should be
    526                     displayed.
    527   """
    528   def ReportHook(blocknum, blocksize, totalsize):
    529     if quit_event and quit_event.isSet():
    530       raise RuntimeError('Aborting download of revision %s' % str(rev))
    531     if progress_event and progress_event.isSet():
    532       size = blocknum * blocksize
    533       if totalsize == -1:  # Total size not known.
    534         progress = 'Received %d bytes' % size
    535       else:
    536         size = min(totalsize, size)
    537         progress = 'Received %d of %d bytes, %.2f%%' % (
    538             size, totalsize, 100.0 * size / totalsize)
    539       # Send a \r to let all progress messages use just one line of output.
    540       sys.stdout.write('\r' + progress)
    541       sys.stdout.flush()
    542 
    543   download_url = context.GetDownloadURL(rev)
    544   try:
    545     urllib.urlretrieve(download_url, filename, ReportHook)
    546     if progress_event and progress_event.isSet():
    547       print
    548   except RuntimeError:
    549     pass
    550 
    551 
    552 def RunRevision(context, revision, zip_file, profile, num_runs, command, args):
    553   """Given a zipped revision, unzip it and run the test."""
    554   print 'Trying revision %s...' % str(revision)
    555 
    556   # Create a temp directory and unzip the revision into it.
    557   cwd = os.getcwd()
    558   tempdir = tempfile.mkdtemp(prefix='bisect_tmp')
    559   UnzipFilenameToDir(zip_file, tempdir)
    560   os.chdir(tempdir)
    561 
    562   # Run the build as many times as specified.
    563   testargs = ['--user-data-dir=%s' % profile] + args
    564   # The sandbox must be run as root on Official Chrome, so bypass it.
    565   if ((context.is_official or context.flash_path or context.pdf_path) and
    566       context.platform.startswith('linux')):
    567     testargs.append('--no-sandbox')
    568   if context.flash_path:
    569     testargs.append('--ppapi-flash-path=%s' % context.flash_path)
    570     # We have to pass a large enough Flash version, which currently needs not
    571     # be correct. Instead of requiring the user of the script to figure out and
    572     # pass the correct version we just spoof it.
    573     testargs.append('--ppapi-flash-version=99.9.999.999')
    574 
    575   # TODO(vitalybuka): Remove in the future. See crbug.com/395687.
    576   if context.pdf_path:
    577     shutil.copy(context.pdf_path,
    578                 os.path.dirname(context.GetLaunchPath(revision)))
    579     testargs.append('--enable-print-preview')
    580 
    581   runcommand = []
    582   for token in shlex.split(command):
    583     if token == '%a':
    584       runcommand.extend(testargs)
    585     else:
    586       runcommand.append(
    587           token.replace('%p', os.path.abspath(context.GetLaunchPath(revision))).
    588           replace('%s', ' '.join(testargs)))
    589 
    590   results = []
    591   for _ in range(num_runs):
    592     subproc = subprocess.Popen(runcommand,
    593                                bufsize=-1,
    594                                stdout=subprocess.PIPE,
    595                                stderr=subprocess.PIPE)
    596     (stdout, stderr) = subproc.communicate()
    597     results.append((subproc.returncode, stdout, stderr))
    598 
    599   os.chdir(cwd)
    600   try:
    601     shutil.rmtree(tempdir, True)
    602   except Exception:
    603     pass
    604 
    605   for (returncode, stdout, stderr) in results:
    606     if returncode:
    607       return (returncode, stdout, stderr)
    608   return results[0]
    609 
    610 
    611 # The arguments official_builds, status, stdout and stderr are unused.
    612 # They are present here because this function is passed to Bisect which then
    613 # calls it with 5 arguments.
    614 # pylint: disable=W0613
    615 def AskIsGoodBuild(rev, official_builds, status, stdout, stderr):
    616   """Asks the user whether build |rev| is good or bad."""
    617   # Loop until we get a response that we can parse.
    618   while True:
    619     response = raw_input('Revision %s is '
    620                          '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
    621                          str(rev))
    622     if response and response in ('g', 'b', 'r', 'u'):
    623       return response
    624     if response and response == 'q':
    625       raise SystemExit()
    626 
    627 
    628 def IsGoodASANBuild(rev, official_builds, status, stdout, stderr):
    629   """Determine if an ASAN build |rev| is good or bad
    630 
    631   Will examine stderr looking for the error message emitted by ASAN. If not
    632   found then will fallback to asking the user."""
    633   if stderr:
    634     bad_count = 0
    635     for line in stderr.splitlines():
    636       print line
    637       if line.find('ERROR: AddressSanitizer:') != -1:
    638         bad_count += 1
    639     if bad_count > 0:
    640       print 'Revision %d determined to be bad.' % rev
    641       return 'b'
    642   return AskIsGoodBuild(rev, official_builds, status, stdout, stderr)
    643 
    644 class DownloadJob(object):
    645   """DownloadJob represents a task to download a given Chromium revision."""
    646 
    647   def __init__(self, context, name, rev, zip_file):
    648     super(DownloadJob, self).__init__()
    649     # Store off the input parameters.
    650     self.context = context
    651     self.name = name
    652     self.rev = rev
    653     self.zip_file = zip_file
    654     self.quit_event = threading.Event()
    655     self.progress_event = threading.Event()
    656     self.thread = None
    657 
    658   def Start(self):
    659     """Starts the download."""
    660     fetchargs = (self.context,
    661                  self.rev,
    662                  self.zip_file,
    663                  self.quit_event,
    664                  self.progress_event)
    665     self.thread = threading.Thread(target=FetchRevision,
    666                                    name=self.name,
    667                                    args=fetchargs)
    668     self.thread.start()
    669 
    670   def Stop(self):
    671     """Stops the download which must have been started previously."""
    672     assert self.thread, 'DownloadJob must be started before Stop is called.'
    673     self.quit_event.set()
    674     self.thread.join()
    675     os.unlink(self.zip_file)
    676 
    677   def WaitFor(self):
    678     """Prints a message and waits for the download to complete. The download
    679     must have been started previously."""
    680     assert self.thread, 'DownloadJob must be started before WaitFor is called.'
    681     print 'Downloading revision %s...' % str(self.rev)
    682     self.progress_event.set()  # Display progress of download.
    683     self.thread.join()
    684 
    685 
    686 def Bisect(context,
    687            num_runs=1,
    688            command='%p %a',
    689            try_args=(),
    690            profile=None,
    691            interactive=True,
    692            evaluate=AskIsGoodBuild):
    693   """Given known good and known bad revisions, run a binary search on all
    694   archived revisions to determine the last known good revision.
    695 
    696   @param context PathContext object initialized with user provided parameters.
    697   @param num_runs Number of times to run each build for asking good/bad.
    698   @param try_args A tuple of arguments to pass to the test application.
    699   @param profile The name of the user profile to run with.
    700   @param interactive If it is false, use command exit code for good or bad
    701                      judgment of the argument build.
    702   @param evaluate A function which returns 'g' if the argument build is good,
    703                   'b' if it's bad or 'u' if unknown.
    704 
    705   Threading is used to fetch Chromium revisions in the background, speeding up
    706   the user's experience. For example, suppose the bounds of the search are
    707   good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
    708   whether revision 50 is good or bad, the next revision to check will be either
    709   25 or 75. So, while revision 50 is being checked, the script will download
    710   revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
    711   known:
    712 
    713     - If rev 50 is good, the download of rev 25 is cancelled, and the next test
    714       is run on rev 75.
    715 
    716     - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
    717       is run on rev 25.
    718   """
    719 
    720   if not profile:
    721     profile = 'profile'
    722 
    723   good_rev = context.good_revision
    724   bad_rev = context.bad_revision
    725   cwd = os.getcwd()
    726 
    727   print 'Downloading list of known revisions...',
    728   if not context.use_local_repo and not context.is_official:
    729     print '(use --use-local-repo for speed if you have a local checkout)'
    730   else:
    731     print
    732   _GetDownloadPath = lambda rev: os.path.join(cwd,
    733       '%s-%s' % (str(rev), context.archive_name))
    734   if context.is_official:
    735     revlist = context.GetOfficialBuildsList()
    736   else:
    737     revlist = context.GetRevList()
    738 
    739   # Get a list of revisions to bisect across.
    740   if len(revlist) < 2:  # Don't have enough builds to bisect.
    741     msg = 'We don\'t have enough builds to bisect. revlist: %s' % revlist
    742     raise RuntimeError(msg)
    743 
    744   # Figure out our bookends and first pivot point; fetch the pivot revision.
    745   minrev = 0
    746   maxrev = len(revlist) - 1
    747   pivot = maxrev / 2
    748   rev = revlist[pivot]
    749   zip_file = _GetDownloadPath(rev)
    750   fetch = DownloadJob(context, 'initial_fetch', rev, zip_file)
    751   fetch.Start()
    752   fetch.WaitFor()
    753 
    754   # Binary search time!
    755   while fetch and fetch.zip_file and maxrev - minrev > 1:
    756     if bad_rev < good_rev:
    757       min_str, max_str = 'bad', 'good'
    758     else:
    759       min_str, max_str = 'good', 'bad'
    760     print 'Bisecting range [%s (%s), %s (%s)].' % (revlist[minrev], min_str,
    761                                                    revlist[maxrev], max_str)
    762 
    763     # Pre-fetch next two possible pivots
    764     #   - down_pivot is the next revision to check if the current revision turns
    765     #     out to be bad.
    766     #   - up_pivot is the next revision to check if the current revision turns
    767     #     out to be good.
    768     down_pivot = int((pivot - minrev) / 2) + minrev
    769     down_fetch = None
    770     if down_pivot != pivot and down_pivot != minrev:
    771       down_rev = revlist[down_pivot]
    772       down_fetch = DownloadJob(context, 'down_fetch', down_rev,
    773                                _GetDownloadPath(down_rev))
    774       down_fetch.Start()
    775 
    776     up_pivot = int((maxrev - pivot) / 2) + pivot
    777     up_fetch = None
    778     if up_pivot != pivot and up_pivot != maxrev:
    779       up_rev = revlist[up_pivot]
    780       up_fetch = DownloadJob(context, 'up_fetch', up_rev,
    781                              _GetDownloadPath(up_rev))
    782       up_fetch.Start()
    783 
    784     # Run test on the pivot revision.
    785     status = None
    786     stdout = None
    787     stderr = None
    788     try:
    789       (status, stdout, stderr) = RunRevision(context,
    790                                              rev,
    791                                              fetch.zip_file,
    792                                              profile,
    793                                              num_runs,
    794                                              command,
    795                                              try_args)
    796     except Exception, e:
    797       print >> sys.stderr, e
    798 
    799     # Call the evaluate function to see if the current revision is good or bad.
    800     # On that basis, kill one of the background downloads and complete the
    801     # other, as described in the comments above.
    802     try:
    803       if not interactive:
    804         if status:
    805           answer = 'b'
    806           print 'Bad revision: %s' % rev
    807         else:
    808           answer = 'g'
    809           print 'Good revision: %s' % rev
    810       else:
    811         answer = evaluate(rev, context.is_official, status, stdout, stderr)
    812       if ((answer == 'g' and good_rev < bad_rev)
    813           or (answer == 'b' and bad_rev < good_rev)):
    814         fetch.Stop()
    815         minrev = pivot
    816         if down_fetch:
    817           down_fetch.Stop()  # Kill the download of the older revision.
    818           fetch = None
    819         if up_fetch:
    820           up_fetch.WaitFor()
    821           pivot = up_pivot
    822           fetch = up_fetch
    823       elif ((answer == 'b' and good_rev < bad_rev)
    824             or (answer == 'g' and bad_rev < good_rev)):
    825         fetch.Stop()
    826         maxrev = pivot
    827         if up_fetch:
    828           up_fetch.Stop()  # Kill the download of the newer revision.
    829           fetch = None
    830         if down_fetch:
    831           down_fetch.WaitFor()
    832           pivot = down_pivot
    833           fetch = down_fetch
    834       elif answer == 'r':
    835         pass  # Retry requires no changes.
    836       elif answer == 'u':
    837         # Nuke the revision from the revlist and choose a new pivot.
    838         fetch.Stop()
    839         revlist.pop(pivot)
    840         maxrev -= 1  # Assumes maxrev >= pivot.
    841 
    842         if maxrev - minrev > 1:
    843           # Alternate between using down_pivot or up_pivot for the new pivot
    844           # point, without affecting the range. Do this instead of setting the
    845           # pivot to the midpoint of the new range because adjacent revisions
    846           # are likely affected by the same issue that caused the (u)nknown
    847           # response.
    848           if up_fetch and down_fetch:
    849             fetch = [up_fetch, down_fetch][len(revlist) % 2]
    850           elif up_fetch:
    851             fetch = up_fetch
    852           else:
    853             fetch = down_fetch
    854           fetch.WaitFor()
    855           if fetch == up_fetch:
    856             pivot = up_pivot - 1  # Subtracts 1 because revlist was resized.
    857           else:
    858             pivot = down_pivot
    859           zip_file = fetch.zip_file
    860 
    861         if down_fetch and fetch != down_fetch:
    862           down_fetch.Stop()
    863         if up_fetch and fetch != up_fetch:
    864           up_fetch.Stop()
    865       else:
    866         assert False, 'Unexpected return value from evaluate(): ' + answer
    867     except SystemExit:
    868       print 'Cleaning up...'
    869       for f in [_GetDownloadPath(revlist[down_pivot]),
    870                 _GetDownloadPath(revlist[up_pivot])]:
    871         try:
    872           os.unlink(f)
    873         except OSError:
    874           pass
    875       sys.exit(0)
    876 
    877     rev = revlist[pivot]
    878 
    879   return (revlist[minrev], revlist[maxrev], context)
    880 
    881 
    882 def GetBlinkDEPSRevisionForChromiumRevision(self, rev):
    883   """Returns the blink revision that was in REVISIONS file at
    884   chromium revision |rev|."""
    885 
    886   def _GetBlinkRev(url, blink_re):
    887     m = blink_re.search(url.read())
    888     url.close()
    889     if m:
    890       return m.group(1)
    891 
    892   url = urllib.urlopen(DEPS_FILE_OLD % rev)
    893   if url.getcode() == 200:
    894     # . doesn't match newlines without re.DOTALL, so this is safe.
    895     blink_re = re.compile(r'webkit_revision\D*(\d+)')
    896     return int(_GetBlinkRev(url, blink_re))
    897   else:
    898     url = urllib.urlopen(DEPS_FILE_NEW % GetGitHashFromSVNRevision(rev))
    899     if url.getcode() == 200:
    900       blink_re = re.compile(r'webkit_revision\D*\d+;\D*\d+;(\w+)')
    901       blink_git_sha = _GetBlinkRev(url, blink_re)
    902       return self.GetSVNRevisionFromGitHash(blink_git_sha, 'blink')
    903   raise Exception('Could not get Blink revision for Chromium rev %d' % rev)
    904 
    905 
    906 def GetBlinkRevisionForChromiumRevision(context, rev):
    907   """Returns the blink revision that was in REVISIONS file at
    908   chromium revision |rev|."""
    909   def _IsRevisionNumber(revision):
    910     if isinstance(revision, int):
    911       return True
    912     else:
    913       return revision.isdigit()
    914   if str(rev) in context.githash_svn_dict:
    915     rev = context.githash_svn_dict[str(rev)]
    916   file_url = '%s/%s%s/REVISIONS' % (context.base_url,
    917                                     context._listing_platform_dir, rev)
    918   url = urllib.urlopen(file_url)
    919   if url.getcode() == 200:
    920     try:
    921       data = json.loads(url.read())
    922     except ValueError:
    923       print 'ValueError for JSON URL: %s' % file_url
    924       raise ValueError
    925   else:
    926     raise ValueError
    927   url.close()
    928   if 'webkit_revision' in data:
    929     blink_rev = data['webkit_revision']
    930     if not _IsRevisionNumber(blink_rev):
    931       blink_rev = int(context.GetSVNRevisionFromGitHash(blink_rev, 'blink'))
    932     return blink_rev
    933   else:
    934     raise Exception('Could not get blink revision for cr rev %d' % rev)
    935 
    936 
    937 def FixChromiumRevForBlink(revisions_final, revisions, self, rev):
    938   """Returns the chromium revision that has the correct blink revision
    939   for blink bisect, DEPS and REVISIONS file might not match since
    940   blink snapshots point to tip of tree blink.
    941   Note: The revisions_final variable might get modified to include
    942   additional revisions."""
    943   blink_deps_rev = GetBlinkDEPSRevisionForChromiumRevision(self, rev)
    944 
    945   while (GetBlinkRevisionForChromiumRevision(self, rev) > blink_deps_rev):
    946     idx = revisions.index(rev)
    947     if idx > 0:
    948       rev = revisions[idx-1]
    949       if rev not in revisions_final:
    950         revisions_final.insert(0, rev)
    951 
    952   revisions_final.sort()
    953   return rev
    954 
    955 
    956 def GetChromiumRevision(context, url):
    957   """Returns the chromium revision read from given URL."""
    958   try:
    959     # Location of the latest build revision number
    960     latest_revision = urllib.urlopen(url).read()
    961     if latest_revision.isdigit():
    962       return int(latest_revision)
    963     return context.GetSVNRevisionFromGitHash(latest_revision)
    964   except Exception:
    965     print 'Could not determine latest revision. This could be bad...'
    966     return 999999999
    967 
    968 def GetGitHashFromSVNRevision(svn_revision):
    969   crrev_url = CRREV_URL + str(svn_revision)
    970   url = urllib.urlopen(crrev_url)
    971   if url.getcode() == 200:
    972     data = json.loads(url.read())
    973     if 'git_sha' in data:
    974       return data['git_sha']
    975 
    976 def PrintChangeLog(min_chromium_rev, max_chromium_rev):
    977   """Prints the changelog URL."""
    978 
    979   print ('  ' + CHANGELOG_URL % (GetGitHashFromSVNRevision(min_chromium_rev),
    980          GetGitHashFromSVNRevision(max_chromium_rev)))
    981 
    982 
    983 def main():
    984   usage = ('%prog [options] [-- chromium-options]\n'
    985            'Perform binary search on the snapshot builds to find a minimal\n'
    986            'range of revisions where a behavior change happened. The\n'
    987            'behaviors are described as "good" and "bad".\n'
    988            'It is NOT assumed that the behavior of the later revision is\n'
    989            'the bad one.\n'
    990            '\n'
    991            'Revision numbers should use\n'
    992            '  Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
    993            '  SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
    994            '    Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
    995            '    for earlier revs.\n'
    996            '    Chrome\'s about: build number and omahaproxy branch_revision\n'
    997            '    are incorrect, they are from branches.\n'
    998            '\n'
    999            'Tip: add "-- --no-first-run" to bypass the first run prompts.')
   1000   parser = optparse.OptionParser(usage=usage)
   1001   # Strangely, the default help output doesn't include the choice list.
   1002   choices = ['mac', 'mac64', 'win', 'win64', 'linux', 'linux64', 'linux-arm']
   1003             # linux-chromiumos lacks a continuous archive http://crbug.com/78158
   1004   parser.add_option('-a', '--archive',
   1005                     choices=choices,
   1006                     help='The buildbot archive to bisect [%s].' %
   1007                          '|'.join(choices))
   1008   parser.add_option('-o',
   1009                     action='store_true',
   1010                     dest='official_builds',
   1011                     help='Bisect across official Chrome builds (internal '
   1012                          'only) instead of Chromium archives.')
   1013   parser.add_option('-b', '--bad',
   1014                     type='str',
   1015                     help='A bad revision to start bisection. '
   1016                          'May be earlier or later than the good revision. '
   1017                          'Default is HEAD.')
   1018   parser.add_option('-f', '--flash_path',
   1019                     type='str',
   1020                     help='Absolute path to a recent Adobe Pepper Flash '
   1021                          'binary to be used in this bisection (e.g. '
   1022                          'on Windows C:\...\pepflashplayer.dll and on Linux '
   1023                          '/opt/google/chrome/PepperFlash/'
   1024                          'libpepflashplayer.so).')
   1025   parser.add_option('-d', '--pdf_path',
   1026                     type='str',
   1027                     help='Absolute path to a recent PDF plugin '
   1028                          'binary to be used in this bisection (e.g. '
   1029                          'on Windows C:\...\pdf.dll and on Linux '
   1030                          '/opt/google/chrome/libpdf.so). Option also enables '
   1031                          'print preview.')
   1032   parser.add_option('-g', '--good',
   1033                     type='str',
   1034                     help='A good revision to start bisection. ' +
   1035                          'May be earlier or later than the bad revision. ' +
   1036                          'Default is 0.')
   1037   parser.add_option('-p', '--profile', '--user-data-dir',
   1038                     type='str',
   1039                     default='profile',
   1040                     help='Profile to use; this will not reset every run. '
   1041                          'Defaults to a clean profile.')
   1042   parser.add_option('-t', '--times',
   1043                     type='int',
   1044                     default=1,
   1045                     help='Number of times to run each build before asking '
   1046                          'if it\'s good or bad. Temporary profiles are reused.')
   1047   parser.add_option('-c', '--command',
   1048                     type='str',
   1049                     default='%p %a',
   1050                     help='Command to execute. %p and %a refer to Chrome '
   1051                          'executable and specified extra arguments '
   1052                          'respectively. Use %s to specify all extra arguments '
   1053                          'as one string. Defaults to "%p %a". Note that any '
   1054                          'extra paths specified should be absolute.')
   1055   parser.add_option('-l', '--blink',
   1056                     action='store_true',
   1057                     help='Use Blink bisect instead of Chromium. ')
   1058   parser.add_option('', '--not-interactive',
   1059                     action='store_true',
   1060                     default=False,
   1061                     help='Use command exit code to tell good/bad revision.')
   1062   parser.add_option('--asan',
   1063                     dest='asan',
   1064                     action='store_true',
   1065                     default=False,
   1066                     help='Allow the script to bisect ASAN builds')
   1067   parser.add_option('--use-local-repo',
   1068                     dest='use_local_repo',
   1069                     action='store_true',
   1070                     default=False,
   1071                     help='Allow the script to convert git SHA1 to SVN '
   1072                          'revision using "git svn find-rev <SHA1>" '
   1073                          'command from a Chromium checkout.')
   1074 
   1075   (opts, args) = parser.parse_args()
   1076 
   1077   if opts.archive is None:
   1078     print 'Error: missing required parameter: --archive'
   1079     print
   1080     parser.print_help()
   1081     return 1
   1082 
   1083   if opts.asan:
   1084     supported_platforms = ['linux', 'mac', 'win']
   1085     if opts.archive not in supported_platforms:
   1086       print 'Error: ASAN bisecting only supported on these platforms: [%s].' % (
   1087             '|'.join(supported_platforms))
   1088       return 1
   1089     if opts.official_builds:
   1090       print 'Error: Do not yet support bisecting official ASAN builds.'
   1091       return 1
   1092 
   1093   if opts.asan:
   1094     base_url = ASAN_BASE_URL
   1095   elif opts.blink:
   1096     base_url = WEBKIT_BASE_URL
   1097   else:
   1098     base_url = CHROMIUM_BASE_URL
   1099 
   1100   # Create the context. Initialize 0 for the revisions as they are set below.
   1101   context = PathContext(base_url, opts.archive, opts.good, opts.bad,
   1102                         opts.official_builds, opts.asan, opts.use_local_repo,
   1103                         opts.flash_path, opts.pdf_path)
   1104   # Pick a starting point, try to get HEAD for this.
   1105   if not opts.bad:
   1106     context.bad_revision = '999.0.0.0'
   1107     context.bad_revision = GetChromiumRevision(
   1108         context, context.GetLastChangeURL())
   1109 
   1110   # Find out when we were good.
   1111   if not opts.good:
   1112     context.good_revision = '0.0.0.0' if opts.official_builds else 0
   1113 
   1114   if opts.flash_path:
   1115     msg = 'Could not find Flash binary at %s' % opts.flash_path
   1116     assert os.path.exists(opts.flash_path), msg
   1117 
   1118   if opts.pdf_path:
   1119     msg = 'Could not find PDF binary at %s' % opts.pdf_path
   1120     assert os.path.exists(opts.pdf_path), msg
   1121 
   1122   if opts.official_builds:
   1123     context.good_revision = LooseVersion(context.good_revision)
   1124     context.bad_revision = LooseVersion(context.bad_revision)
   1125   else:
   1126     context.good_revision = int(context.good_revision)
   1127     context.bad_revision = int(context.bad_revision)
   1128 
   1129   if opts.times < 1:
   1130     print('Number of times to run (%d) must be greater than or equal to 1.' %
   1131           opts.times)
   1132     parser.print_help()
   1133     return 1
   1134 
   1135   if opts.asan:
   1136     evaluator = IsGoodASANBuild
   1137   else:
   1138     evaluator = AskIsGoodBuild
   1139 
   1140   # Save these revision numbers to compare when showing the changelog URL
   1141   # after the bisect.
   1142   good_rev = context.good_revision
   1143   bad_rev = context.bad_revision
   1144 
   1145   (min_chromium_rev, max_chromium_rev, context) = Bisect(
   1146       context, opts.times, opts.command, args, opts.profile,
   1147       not opts.not_interactive, evaluator)
   1148 
   1149   # Get corresponding blink revisions.
   1150   try:
   1151     min_blink_rev = GetBlinkRevisionForChromiumRevision(context,
   1152                                                         min_chromium_rev)
   1153     max_blink_rev = GetBlinkRevisionForChromiumRevision(context,
   1154                                                         max_chromium_rev)
   1155   except Exception:
   1156     # Silently ignore the failure.
   1157     min_blink_rev, max_blink_rev = 0, 0
   1158 
   1159   if opts.blink:
   1160     # We're done. Let the user know the results in an official manner.
   1161     if good_rev > bad_rev:
   1162       print DONE_MESSAGE_GOOD_MAX % (str(min_blink_rev), str(max_blink_rev))
   1163     else:
   1164       print DONE_MESSAGE_GOOD_MIN % (str(min_blink_rev), str(max_blink_rev))
   1165 
   1166     print 'BLINK CHANGELOG URL:'
   1167     print '  ' + BLINK_CHANGELOG_URL % (max_blink_rev, min_blink_rev)
   1168 
   1169   else:
   1170     # We're done. Let the user know the results in an official manner.
   1171     if good_rev > bad_rev:
   1172       print DONE_MESSAGE_GOOD_MAX % (str(min_chromium_rev),
   1173                                      str(max_chromium_rev))
   1174     else:
   1175       print DONE_MESSAGE_GOOD_MIN % (str(min_chromium_rev),
   1176                                      str(max_chromium_rev))
   1177     if min_blink_rev != max_blink_rev:
   1178       print ('NOTE: There is a Blink roll in the range, '
   1179              'you might also want to do a Blink bisect.')
   1180 
   1181     print 'CHANGELOG URL:'
   1182     if opts.official_builds:
   1183       print OFFICIAL_CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
   1184     else:
   1185       PrintChangeLog(min_chromium_rev, max_chromium_rev)
   1186 
   1187 
   1188 if __name__ == '__main__':
   1189   sys.exit(main())
   1190