Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(242)

Side by Side Diff: tools/bisect-builds.py

Issue 6995117: bisect-builds.py: Use Google Common Data Storage instead of build.chromium.org. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: Address comments Created 9 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « no previous file | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 #!/usr/bin/python 1 #!/usr/bin/python
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. 2 # Copyright (c) 2011 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be 3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file. 4 # found in the LICENSE file.
5 5
6 """Snapshot Build Bisect Tool 6 """Snapshot Build Bisect Tool
7 7
8 This script bisects a snapshot archive using binary search. It starts at 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 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, 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, 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. 12 it will ask you whether it is good or bad before continuing the search.
13 """ 13 """
14 14
15 # Base URL to download snapshots from. 15 # The root URL for storage.
16 BUILD_BASE_URL = 'http://build.chromium.org/f/chromium/continuous/' 16 BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-continuous'
17
18 # The index file that lists all the builds. This lives in BUILD_BASE_URL.
19 BUILD_INDEX_FILE = 'all_builds.txt'
20
21 # The type (platform) of the build archive. This is what's passed in to the
22 # '-a/--archive' option.
23 BUILD_ARCHIVE_TYPE = ''
24
25 # The location of the builds. Format this with a (date, revision) tuple, which
26 # can be obtained through ParseIndexLine().
27 BUILD_ARCHIVE_URL = '/%s/%d/'
28
29 # Name of the build archive.
30 BUILD_ZIP_NAME = ''
31
32 # Directory name inside the archive.
33 BUILD_DIR_NAME = ''
34
35 # Name of the executable.
36 BUILD_EXE_NAME = ''
37 17
38 # URL to the ViewVC commit page. 18 # URL to the ViewVC commit page.
39 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d' 19 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d'
40 20
41 # Changelogs URL 21 # Changelogs URL.
42 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \ 22 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \
43 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d' 23 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d'
44 24
45 ############################################################################### 25 ###############################################################################
46 26
47 import math 27 import math
48 import optparse 28 import optparse
49 import os 29 import os
50 import pipes 30 import pipes
51 import re 31 import re
52 import shutil 32 import shutil
53 import sys 33 import sys
54 import tempfile 34 import tempfile
55 import urllib 35 import urllib
36 from xml.etree import ElementTree
56 import zipfile 37 import zipfile
57 38
39 class PathContext(object):
40 """A PathContext is used to carry the information used to construct URLs and
41 paths when dealing with the storage server and archives."""
42 def __init__(self, platform, good_revision, bad_revision):
43 super(PathContext, self).__init__()
44 # Store off the input parameters.
45 self.platform = platform # What's passed in to the '-a/--archive' option.
46 self.good_revision = good_revision
47 self.bad_revision = bad_revision
48
49 # The name of the ZIP file in a revision directory on the server.
50 self.archive_name = None
51
52 # Set some internal members:
53 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
54 # _archive_extract_dir = Uncompressed directory in the archive_name file.
55 # _binary_name = The name of the executable to run.
56 if self.platform == 'linux' or self.platform == 'linux-64':
57 self._listing_platform_dir = 'Linux/'
58 self.archive_name = 'chrome-linux.zip'
59 self._archive_extract_dir = 'chrome-linux'
60 self._binary_name = 'chrome'
61 # Linux and x64 share all the same path data except for the archive dir.
62 if self.platform == 'linux-64':
63 self._listing_platform_dir = 'Linux_x64/'
64 elif self.platform == 'mac':
65 self._listing_platform_dir = 'Mac/'
66 self.archive_name = 'chrome-mac.zip'
67 self._archive_extract_dir = 'chrome-mac'
68 self._binary_name = 'Chromium.app/Contents/MacOS/Chromium'
69 elif self.platform == 'win':
70 self._listing_platform_dir = 'Win/'
71 self.archive_name = 'chrome-win32.zip'
72 self._archive_extract_dir = 'chrome-win32'
73 self._binary_name = 'chrome.exe'
74 else:
75 raise Exception("Invalid platform")
76
77 def GetListingURL(self, marker=None):
78 """Returns the URL for a directory listing, with an optional marker."""
79 marker_param = ''
80 if marker:
81 marker_param = '&marker=' + str(marker)
82 return BASE_URL + '/?delimiter=/&prefix=' + self._listing_platform_dir + \
83 marker_param
84
85 def GetDownloadURL(self, revision):
86 """Gets the download URL for a build archive of a specific revision."""
87 return BASE_URL + '/' + self._listing_platform_dir + str(revision) + '/' + \
88 self.archive_name
89
90 def GetLastChangeURL(self):
91 """Returns a URL to the LAST_CHANGE file."""
92 return BASE_URL + '/' + self._listing_platform_dir + 'LAST_CHANGE'
93
94 def GetLaunchPath(self):
95 """Returns a relative path (presumably from the archive extraction location)
96 that is used to run the executable."""
97 return os.path.join(self._archive_extract_dir, self._binary_name)
98
58 99
59 def UnzipFilenameToDir(filename, dir): 100 def UnzipFilenameToDir(filename, dir):
60 """Unzip |filename| to directory |dir|.""" 101 """Unzip |filename| to directory |dir|."""
61 zf = zipfile.ZipFile(filename) 102 zf = zipfile.ZipFile(filename)
62 # Make base. 103 # Make base.
63 pushd = os.getcwd() 104 pushd = os.getcwd()
64 try: 105 try:
65 if not os.path.isdir(dir): 106 if not os.path.isdir(dir):
66 os.mkdir(dir) 107 os.mkdir(dir)
67 os.chdir(dir) 108 os.chdir(dir)
(...skipping 11 matching lines...) Expand all
79 out.write(zf.read(name)) 120 out.write(zf.read(name))
80 out.close() 121 out.close()
81 # Set permissions. Permission info in external_attr is shifted 16 bits. 122 # Set permissions. Permission info in external_attr is shifted 16 bits.
82 os.chmod(name, info.external_attr >> 16L) 123 os.chmod(name, info.external_attr >> 16L)
83 os.chdir(pushd) 124 os.chdir(pushd)
84 except Exception, e: 125 except Exception, e:
85 print >>sys.stderr, e 126 print >>sys.stderr, e
86 sys.exit(1) 127 sys.exit(1)
87 128
88 129
89 def SetArchiveVars(archive): 130 def ParseDirectoryIndex(context):
90 """Set a bunch of global variables appropriate for the specified archive.""" 131 """Parses the Google Storage directory listing into a list of revision
91 global BUILD_ARCHIVE_TYPE 132 numbers. The range starts with context.good_revision and goes until the latest
92 global BUILD_ZIP_NAME 133 revision."""
93 global BUILD_DIR_NAME 134 def _FetchAndParse(url):
94 global BUILD_EXE_NAME 135 """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
95 global BUILD_BASE_URL 136 next-marker is not None, then the listing is a partial listing and another
137 fetch should be performed with next-marker being the marker= GET
138 parameter."""
139 handle = urllib.urlopen(url)
140 document = ElementTree.parse(handle)
96 141
97 BUILD_ARCHIVE_TYPE = archive 142 # All nodes in the tree are namespaced. Get the root's tag name to extract
143 # the namespace. Etree does namespaces as |{namespace}tag|.
144 root_tag = document.getroot().tag
145 end_ns_pos = root_tag.find('}')
146 if end_ns_pos == -1:
147 raise Exception("Could not locate end namespace for directory index")
148 namespace = root_tag[:end_ns_pos + 1]
98 149
99 if BUILD_ARCHIVE_TYPE in ('linux', 'linux64', 'linux-chromiumos'): 150 # Find the prefix (_listing_platform_dir) and whether or not the list is
100 BUILD_ZIP_NAME = 'chrome-linux.zip' 151 # truncated.
101 BUILD_DIR_NAME = 'chrome-linux' 152 prefix = document.find(namespace + 'Prefix').text
102 BUILD_EXE_NAME = 'chrome' 153 next_marker = None
103 elif BUILD_ARCHIVE_TYPE in ('mac'): 154 is_truncated = document.find(namespace + 'IsTruncated')
104 BUILD_ZIP_NAME = 'chrome-mac.zip' 155 if is_truncated is not None and is_truncated.text.lower() == 'true':
105 BUILD_DIR_NAME = 'chrome-mac' 156 next_marker = document.find(namespace + 'NextMarker').text
106 BUILD_EXE_NAME = 'Chromium.app/Contents/MacOS/Chromium' 157
107 elif BUILD_ARCHIVE_TYPE in ('win'): 158 # Get a list of all the revisions.
108 BUILD_ZIP_NAME = 'chrome-win32.zip' 159 all_prefixes = document.findall(namespace + 'CommonPrefixes/' +
109 BUILD_DIR_NAME = 'chrome-win32' 160 namespace + 'Prefix')
110 BUILD_EXE_NAME = 'chrome.exe' 161 # The <Prefix> nodes have content of the form of
162 # |_listing_platform_dir/revision/|. Strip off the platform dir and the
163 # trailing slash to just have a number.
164 revisions = map(lambda x: x.text[len(prefix):-1], all_prefixes)
165 return (revisions, next_marker)
166
167 # Fetch the first list of revisions.
168 (revisions, next_marker) = _FetchAndParse(context.GetListingURL())
169 # If the result list was truncated, refetch with the next marker. Do this
170 # until an entire directory listing is done.
171 while next_marker:
172 (new_revisions, next_marker) = _FetchAndParse(
173 context.GetListingURL(next_marker))
174 revisions.extend(new_revisions)
175
176 return revisions
111 177
112 178
113 def ParseDirectoryIndex(url): 179 def GetRevList(context):
114 """Parses the all_builds.txt index file. The format of this file is: 180 """Gets the list of revision numbers between |good_revision| and
115 mac/2011-02-16/75130 181 |bad_revision| of the |context|."""
116 mac/2011-02-16/75218 182 # Download the revlist and filter for just the range between good and bad.
117 mac/2011-02-16/75226 183 rev_range = range(context.good_revision, context.bad_revision)
118 mac/2011-02-16/75234 184 revlist = map(int, ParseDirectoryIndex(context))
119 mac/2011-02-16/75184 185 revlist = filter(lambda r: r in rev_range, revlist)
120 This function will return a list of DATE/REVISION strings for the platform
121 specified by BUILD_ARCHIVE_TYPE.
122 """
123 handle = urllib.urlopen(url)
124 dirindex = handle.readlines()
125 handle.close()
126
127 # Only return values for the specified platform. Include the trailing slash to
128 # not confuse linux and linux64.
129 archtype = BUILD_ARCHIVE_TYPE + '/'
130 dirindex = filter(lambda l: l.startswith(archtype), dirindex)
131
132 # Remove the newline separator and the platform token.
133 dirindex = map(lambda l: l[len(archtype):].strip(), dirindex)
134 dirindex.sort()
135 return dirindex
136
137
138 def ParseIndexLine(iline):
139 """Takes an index line returned by ParseDirectoryIndex() and returns a
140 2-tuple of (date, revision). |date| is a string and |revision| is an int."""
141 split = iline.split('/')
142 assert(len(split) == 2)
143 return (split[0], int(split[1]))
144
145
146 def GetRevision(iline):
147 """Takes an index line, parses it, and returns the revision."""
148 return ParseIndexLine(iline)[1]
149
150
151 def GetRevList(good, bad):
152 """Gets the list of revision numbers between |good| and |bad|."""
153 # Download the main revlist.
154 revlist = ParseDirectoryIndex(BUILD_BASE_URL + BUILD_INDEX_FILE)
155 revrange = range(good, bad)
156 revlist = filter(lambda r: GetRevision(r) in revrange, revlist)
157 revlist.sort() 186 revlist.sort()
158 return revlist 187 return revlist
159 188
160 189
161 def TryRevision(iline, profile, args): 190 def TryRevision(context, rev, profile, args):
162 """Downloads revision from |iline|, unzips it, and opens it for the user to 191 """Downloads revision |rev|, unzips it, and opens it for the user to test.
163 test. |profile| is the profile to use.""" 192 |profile| is the profile to use."""
164 # Do this in a temp dir so we don't collide with user files. 193 # Do this in a temp dir so we don't collide with user files.
165 cwd = os.getcwd() 194 cwd = os.getcwd()
166 tempdir = tempfile.mkdtemp(prefix='bisect_tmp') 195 tempdir = tempfile.mkdtemp(prefix='bisect_tmp')
167 os.chdir(tempdir) 196 os.chdir(tempdir)
168 197
169 # Download the file. 198 # Download the file.
170 download_url = BUILD_BASE_URL + BUILD_ARCHIVE_TYPE + \ 199 download_url = context.GetDownloadURL(rev)
171 (BUILD_ARCHIVE_URL % ParseIndexLine(iline)) + BUILD_ZIP_NAME
172 def _ReportHook(blocknum, blocksize, totalsize): 200 def _ReportHook(blocknum, blocksize, totalsize):
173 size = blocknum * blocksize 201 size = blocknum * blocksize
174 if totalsize == -1: # Total size not known. 202 if totalsize == -1: # Total size not known.
175 progress = "Received %d bytes" % size 203 progress = "Received %d bytes" % size
176 else: 204 else:
177 size = min(totalsize, size) 205 size = min(totalsize, size)
178 progress = "Received %d of %d bytes, %.2f%%" % ( 206 progress = "Received %d of %d bytes, %.2f%%" % (
179 size, totalsize, 100.0 * size / totalsize) 207 size, totalsize, 100.0 * size / totalsize)
180 # Send a \r to let all progress messages use just one line of output. 208 # Send a \r to let all progress messages use just one line of output.
181 sys.stdout.write("\r" + progress) 209 sys.stdout.write("\r" + progress)
182 sys.stdout.flush() 210 sys.stdout.flush()
183 try: 211 try:
184 print 'Fetching ' + download_url 212 print 'Fetching ' + download_url
185 urllib.urlretrieve(download_url, BUILD_ZIP_NAME, _ReportHook) 213 urllib.urlretrieve(download_url, context.archive_name, _ReportHook)
186 print 214 print
187 except Exception, e: 215 except Exception, e:
188 print('Could not retrieve the download. Sorry.') 216 print('Could not retrieve the download. Sorry.')
189 sys.exit(-1) 217 sys.exit(-1)
190 218
191 # Unzip the file. 219 # Unzip the file.
192 print 'Unzipping ...' 220 print 'Unzipping ...'
193 UnzipFilenameToDir(BUILD_ZIP_NAME, os.curdir) 221 UnzipFilenameToDir(context.archive_name, os.curdir)
194 222
195 # Tell the system to open the app. 223 # Tell the system to open the app.
196 args = ['--user-data-dir=%s' % profile] + args 224 args = ['--user-data-dir=%s' % profile] + args
197 flags = ' '.join(map(pipes.quote, args)) 225 flags = ' '.join(map(pipes.quote, args))
198 exe = os.path.join(os.getcwd(), BUILD_DIR_NAME, BUILD_EXE_NAME) 226 cmd = '%s %s' % (context.GetLaunchPath(), flags)
199 cmd = '%s %s' % (exe, flags)
200 print 'Running %s' % cmd 227 print 'Running %s' % cmd
201 os.system(cmd) 228 os.system(cmd)
202 229
203 os.chdir(cwd) 230 os.chdir(cwd)
204 print 'Cleaning temp dir ...' 231 print 'Cleaning temp dir ...'
205 try: 232 try:
206 shutil.rmtree(tempdir, True) 233 shutil.rmtree(tempdir, True)
207 except Exception, e: 234 except Exception, e:
208 pass 235 pass
209 236
210 237
211 def AskIsGoodBuild(iline): 238 def AskIsGoodBuild(rev):
212 """Ask the user whether build from index line |iline| is good or bad.""" 239 """Ask the user whether build |rev| is good or bad."""
213 # Loop until we get a response that we can parse. 240 # Loop until we get a response that we can parse.
214 while True: 241 while True:
215 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % GetRevision(iline)) 242 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % int(rev))
216 if response and response in ('g', 'b'): 243 if response and response in ('g', 'b'):
217 return response == 'g' 244 return response == 'g'
218 245
246
219 def main(): 247 def main():
220 usage = ('%prog [options] [-- chromium-options]\n' 248 usage = ('%prog [options] [-- chromium-options]\n'
221 'Perform binary search on the snapshot builds.\n' 249 'Perform binary search on the snapshot builds.\n'
222 '\n' 250 '\n'
223 'Tip: add "-- --no-first-run" to bypass the first run prompts.') 251 'Tip: add "-- --no-first-run" to bypass the first run prompts.')
224 parser = optparse.OptionParser(usage=usage) 252 parser = optparse.OptionParser(usage=usage)
225 # Strangely, the default help output doesn't include the choice list. 253 # Strangely, the default help output doesn't include the choice list.
226 choices = ['mac', 'win', 'linux', 'linux64'] 254 choices = ['mac', 'win', 'linux', 'linux64']
227 # linux-chromiumos lacks a continuous archive http://crbug.com/78158 255 # linux-chromiumos lacks a continuous archive http://crbug.com/78158
228 parser.add_option('-a', '--archive', 256 parser.add_option('-a', '--archive',
(...skipping 14 matching lines...) Expand all
243 print 271 print
244 parser.print_help() 272 parser.print_help()
245 return 1 273 return 1
246 274
247 if opts.bad and opts.good and (opts.good > opts.bad): 275 if opts.bad and opts.good and (opts.good > opts.bad):
248 print ('The good revision (%d) must precede the bad revision (%d).\n' % 276 print ('The good revision (%d) must precede the bad revision (%d).\n' %
249 (opts.good, opts.bad)) 277 (opts.good, opts.bad))
250 parser.print_help() 278 parser.print_help()
251 return 1 279 return 1
252 280
253 SetArchiveVars(opts.archive) 281 # Create the context. Initialize 0 for the revisions as they are set below.
282 context = PathContext(opts.archive, 0, 0)
254 283
255 # Pick a starting point, try to get HEAD for this. 284 # Pick a starting point, try to get HEAD for this.
256 if opts.bad: 285 if opts.bad:
257 bad_rev = opts.bad 286 bad_rev = opts.bad
258 else: 287 else:
259 bad_rev = 0 288 bad_rev = 0
260 try: 289 try:
261 # Location of the latest build revision number 290 # Location of the latest build revision number
262 BUILD_LATEST_URL = '%s/LATEST/REVISION' % (BUILD_BASE_URL) 291 nh = urllib.urlopen(context.GetLastChangeURL())
263 nh = urllib.urlopen(BUILD_LATEST_URL)
264 latest = int(nh.read()) 292 latest = int(nh.read())
265 nh.close() 293 nh.close()
266 bad_rev = raw_input('Bad revision [HEAD:%d]: ' % latest) 294 bad_rev = raw_input('Bad revision [HEAD:%d]: ' % latest)
267 if (bad_rev == ''): 295 if (bad_rev == ''):
268 bad_rev = latest 296 bad_rev = latest
269 bad_rev = int(bad_rev) 297 bad_rev = int(bad_rev)
270 except Exception, e: 298 except Exception, e:
271 print('Could not determine latest revision. This could be bad...') 299 print('Could not determine latest revision. This could be bad...')
272 bad_rev = int(raw_input('Bad revision: ')) 300 bad_rev = int(raw_input('Bad revision: '))
273 301
274 # Find out when we were good. 302 # Find out when we were good.
275 if opts.good: 303 if opts.good:
276 good_rev = opts.good 304 good_rev = opts.good
277 else: 305 else:
278 good_rev = 0 306 good_rev = 0
279 try: 307 try:
280 good_rev = int(raw_input('Last known good [0]: ')) 308 good_rev = int(raw_input('Last known good [0]: '))
281 except Exception, e: 309 except Exception, e:
282 pass 310 pass
283 311
312 # Set the input parameters now that they've been validated.
313 context.good_revision = good_rev
314 context.bad_revision = bad_rev
315
284 # Get a list of revisions to bisect across. 316 # Get a list of revisions to bisect across.
285 revlist = GetRevList(good_rev, bad_rev) 317 revlist = GetRevList(context)
286 if len(revlist) < 2: # Don't have enough builds to bisect 318 if len(revlist) < 2: # Don't have enough builds to bisect
287 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist 319 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist
288 sys.exit(1) 320 sys.exit(1)
289 321
290 # If we don't have a |good_rev|, set it to be the first revision possible. 322 # If we don't have a |good_rev|, set it to be the first revision possible.
291 if good_rev == 0: 323 if good_rev == 0:
292 good_rev = revlist[0] 324 good_rev = revlist[0]
293 325
294 # These are indexes of |revlist|. 326 # These are indexes of |revlist|.
295 good = 0 327 good = 0
296 bad = len(revlist) - 1 328 bad = len(revlist) - 1
297 last_known_good_rev = revlist[good] 329 last_known_good_rev = revlist[good]
298 330
299 # Binary search time! 331 # Binary search time!
300 while good < bad: 332 while good < bad:
301 candidates = revlist[good:bad] 333 candidates = revlist[good:bad]
302 num_poss = len(candidates) 334 num_poss = len(candidates)
303 if num_poss > 10: 335 if num_poss > 10:
304 print('%d candidates. %d tries left.' % 336 print('%d candidates. %d tries left.' %
305 (num_poss, round(math.log(num_poss, 2)))) 337 (num_poss, round(math.log(num_poss, 2))))
306 else: 338 else:
307 print('Candidates: %s' % map(GetRevision, revlist[good:bad])) 339 print('Candidates: %s' % revlist[good:bad])
308 340
309 # Cut the problem in half... 341 # Cut the problem in half...
310 test = int((bad - good) / 2) + good 342 test = int((bad - good) / 2) + good
311 test_rev = revlist[test] 343 test_rev = revlist[test]
312 344
313 # Let the user give this rev a spin (in her own profile, if she wants). 345 # Let the user give this rev a spin (in her own profile, if she wants).
314 profile = opts.profile 346 profile = opts.profile
315 if not profile: 347 if not profile:
316 profile = 'profile' # In a temp dir. 348 profile = 'profile' # In a temp dir.
317 TryRevision(test_rev, profile, args) 349 TryRevision(context, test_rev, profile, args)
318 if AskIsGoodBuild(test_rev): 350 if AskIsGoodBuild(test_rev):
319 last_known_good_rev = revlist[good] 351 last_known_good_rev = revlist[good]
320 good = test + 1 352 good = test + 1
321 else: 353 else:
322 bad = test 354 bad = test
323 355
324 # We're done. Let the user know the results in an official manner. 356 # We're done. Let the user know the results in an official manner.
325 bad_revision = GetRevision(revlist[bad]) 357 print('You are probably looking for build %d.' % revlist[bad])
326 print('You are probably looking for build %d.' % bad_revision)
327 print('CHANGELOG URL:') 358 print('CHANGELOG URL:')
328 print(CHANGELOG_URL % (GetRevision(last_known_good_rev), bad_revision)) 359 print(CHANGELOG_URL % (last_known_good_rev, revlist[bad]))
329 print('Built at revision:') 360 print('Built at revision:')
330 print(BUILD_VIEWVC_URL % bad_revision) 361 print(BUILD_VIEWVC_URL % revlist[bad])
331 362
332 if __name__ == '__main__': 363 if __name__ == '__main__':
333 sys.exit(main()) 364 sys.exit(main())
OLDNEW
« no previous file with comments | « no previous file | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698