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