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

Side by Side Diff: scripts/slave/recipe_modules/auto_bisect/bisector.py

Issue 2247373002: Refactor stages 1, 2 and test_api overhaul. (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/build.git@master
Patch Set: Rebasing, addressing feedback. Created 4 years, 3 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
OLDNEW
1 # Copyright 2015 The Chromium Authors. All rights reserved. 1 # Copyright 2015 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be 2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file. 3 # found in the LICENSE file.
4 4
5 import json 5 import json
6 import re 6 import re
7 import time 7 import time
8 import urllib 8 import urllib
9 9
10 from . import config_validation 10 from . import config_validation
(...skipping 28 matching lines...) Expand all
39 'LO_FINAL_CONF', # The bisect completed without a culprit. 39 'LO_FINAL_CONF', # The bisect completed without a culprit.
40 ) 40 )
41 41
42 # When we look for the next revision to build, we search nearby revisions 42 # When we look for the next revision to build, we search nearby revisions
43 # looking for a revision that's already been archived. Since we don't want 43 # looking for a revision that's already been archived. Since we don't want
44 # to move *too* far from the original revision, we'll cap the search at 25%. 44 # to move *too* far from the original revision, we'll cap the search at 25%.
45 DEFAULT_SEARCH_RANGE_PERCENTAGE = 0.25 45 DEFAULT_SEARCH_RANGE_PERCENTAGE = 0.25
46 46
47 # How long to re-test the initial good-bad range for until significant 47 # How long to re-test the initial good-bad range for until significant
48 # difference is established. 48 # difference is established.
49 REGRESSION_CHECK_TIMEOUT = 20 * 60 * 60 # 20 hours. A build times out after 24. 49 REGRESSION_CHECK_TIMEOUT = 2 * 60 * 60
eakuefner 2016/09/09 20:35:42 Why? Comment?
RobertoCN 2016/09/13 22:11:40 I don't know. I'll set it back to what it was. A l
50 # If we reach this number of samples on the reference range and have not
51 # achieved statistical significance, bail.
52 MAX_REQUIRED_SAMPLES = 15
53
54 # Significance level to use for determining difference between revisions via
55 # hypothesis testing.
56 SIGNIFICANCE_LEVEL = 0.01
57 50
58 _FAILED_INITIAL_CONFIDENCE_ABORT_REASON = ( 51 _FAILED_INITIAL_CONFIDENCE_ABORT_REASON = (
59 'The metric values for the initial "good" and "bad" revisions ' 52 'The metric values for the initial "good" and "bad" revisions '
60 'do not represent a clear regression.') 53 'do not represent a clear regression.')
61 54
62 _DIRECTION_OF_IMPROVEMENT_ABORT_REASON = ( 55 _DIRECTION_OF_IMPROVEMENT_ABORT_REASON = (
63 'The metric values for the initial "good" and "bad" revisions match the ' 56 'The metric values for the initial "good" and "bad" revisions match the '
64 'expected direction of improvement. Thus, likely represent an improvement ' 57 'expected direction of improvement. Thus, likely represent an improvement '
65 'and not a regression.') 58 'and not a regression.')
66 59
67 60
68 class Bisector(object): 61 class Bisector(object):
69 """This class abstracts an ongoing bisect (or n-sect) job.""" 62 """This class abstracts an ongoing bisect (or n-sect) job."""
70 63
64 SIGNIFICANTLY_DIFFERENT = True
prasadv 2016/09/09 18:52:30 I think declaring enum here would be better.
RobertoCN 2016/09/13 22:11:40 Done.
65 NOT_SIGNIFICANTLY_DIFFERENT = False
66 NEED_MORE_DATA = 'needMoreData'
67
71 def __init__(self, api, bisect_config, revision_class, init_revisions=True, 68 def __init__(self, api, bisect_config, revision_class, init_revisions=True,
72 **flags): 69 **flags):
73 """Initializes the state of a new bisect job from a dictionary. 70 """Initializes the state of a new bisect job from a dictionary.
74 71
75 Note that the initial good_rev and bad_rev MUST resolve to a commit position 72 Note that the initial good_rev and bad_rev MUST resolve to a commit position
76 in the chromium repo. 73 in the chromium repo.
77 """ 74 """
78 super(Bisector, self).__init__() 75 super(Bisector, self).__init__()
79 self.flags = flags 76 self.flags = flags
80 self._api = api 77 self._api = api
(...skipping 67 matching lines...) Expand 10 before | Expand all | Expand 10 after
148 145
149 Returns: 146 Returns:
150 A 40-digit git commit hash string. 147 A 40-digit git commit hash string.
151 """ 148 """
152 if self._is_sha1(rev): # pragma: no cover 149 if self._is_sha1(rev): # pragma: no cover
153 return rev 150 return rev
154 if rev.isdigit(): 151 if rev.isdigit():
155 commit_position = self._api.m.commit_position.construct( 152 commit_position = self._api.m.commit_position.construct(
156 branch='refs/heads/master', value=rev) 153 branch='refs/heads/master', value=rev)
157 try: 154 try:
158 return self._api.m.crrev.to_commit_hash(commit_position) 155 return self._api.m.crrev.to_commit_hash(
156 commit_position,
157 step_test_data=lambda: self.api._test_data['hash_cp_map'][rev])
159 except self.api.m.step.StepFailure: # pragma: no cover 158 except self.api.m.step.StepFailure: # pragma: no cover
160 self.surface_result('BAD_REV') 159 self.surface_result('BAD_REV')
161 raise 160 raise
162 self.surface_result('BAD_REV') # pragma: no cover 161 self.surface_result('BAD_REV') # pragma: no cover
163 raise self.api.m.step.StepFailure( 162 raise self.api.m.step.StepFailure(
164 'Invalid input revision: %r' % (rev,)) # pragma: no cover 163 'Invalid input revision: %r' % (rev,)) # pragma: no cover
165 164
166 @staticmethod 165 @staticmethod
167 def _is_sha1(s): 166 def _is_sha1(s):
168 return bool(re.match('^[0-9A-Fa-f]{40}$', s)) 167 return bool(re.match('^[0-9A-Fa-f]{40}$', s))
169 168
170 def significantly_different( 169 def compare_revisions(self, revision_a, revision_b):
171 self, list_a, list_b, 170 """
172 significance_level=SIGNIFICANCE_LEVEL): # pragma: no cover 171 Returns:
173 """Uses an external script to run hypothesis testing with scipy. 172 SIGNIFICANTLY_DIFFERENT if the samples are significantly different.
173 NEED_MORE_DATA if there is not enough data to tell.
174 NOT_SIGNIFICANTLY_DIFFERENT if if there's enough data but still can't
175 tell the samples apart.
176 """
177 output_format = 'chartjson'
178 values_a = revision_a.chartjson_paths
179 values_b = revision_b.chartjson_paths
180 if revision_a.valueset_paths and revision_b.valueset_paths:
181 output_format = 'valueset'
prasadv 2016/09/09 18:52:30 shouldn't we re-assign values_a and values_b here?
RobertoCN 2016/09/13 22:11:40 Nice catch! Done.
174 182
175 The reason why we need an external script is that scipy is not available to 183 result = self.api.stat_compare(
176 the default python installed in all platforms. We instead rely on an 184 values_a,
177 anaconda environment to provide those packages. 185 values_b,
186 self.bisect_config['metric'],
187 output_format=output_format,
188 step_test_data=lambda: self.api.test_api.compare_samples_data(
189 self.api._test_data.get('revision_data'), revision_a, revision_b))
178 190
179 Args: 191 revision_a.debug_values = result['sample_a']['debug_values']
180 list_a, list_b: Two lists representing samples to be compared. 192 revision_b.debug_values = result['sample_b']['debug_values']
181 significance_level: Self-describing. As a decimal fraction.
182 193
183 Returns: 194 if result['result'] == self.NEED_MORE_DATA:
184 A boolean indicating whether the null hypothesis ~(that the lists are 195 return self.NEED_MORE_DATA
185 samples from the same population) can be rejected at the specified 196 elif result['result']:
186 significance level. 197 return self.SIGNIFICANTLY_DIFFERENT
187 """ 198 # TODO(robertocn): Remove this pragma and cover this line.
188 step_result = self.api.m.python( 199 else: # pragma: no cover
189 'Checking sample difference', 200 return self.NOT_SIGNIFICANTLY_DIFFERENT
190 self.api.resource('significantly_different.py'),
191 [json.dumps(list_a), json.dumps(list_b), str(significance_level)],
192 stdout=self.api.m.json.output())
193 results = step_result.stdout
194 if results is None:
195 assert self.dummy_builds
196 return True
197 significantly_different = results['significantly_different']
198 step_result.presentation.logs[str(significantly_different)] = [
199 'See json.output for details']
200 return significantly_different
201 201
202 def config_step(self): 202 def config_step(self):
203 """Yields a step that prints the bisect config.""" 203 """Yields a step that prints the bisect config."""
204 api = self.api 204 api = self.api
205 205
206 # bisect_config may come as a FrozenDict (which is not serializable). 206 # bisect_config may come as a FrozenDict (which is not serializable).
207 bisect_config = dict(self.bisect_config) 207 bisect_config = dict(self.bisect_config)
208 208
209 def fix_windows_backslashes(s): 209 def fix_windows_backslashes(s):
210 backslash_regex = re.compile(r'(?<!\\)\\(?!\\)') 210 backslash_regex = re.compile(r'(?<!\\)\\(?!\\)')
(...skipping 17 matching lines...) Expand all
228 except config_validation.ValidationFail as error: 228 except config_validation.ValidationFail as error:
229 self.surface_result('BAD_CONFIG') 229 self.surface_result('BAD_CONFIG')
230 self.api.m.halt(error.message) 230 self.api.m.halt(error.message)
231 raise self.api.m.step.StepFailure(error.message) 231 raise self.api.m.step.StepFailure(error.message)
232 232
233 @property 233 @property
234 def api(self): 234 def api(self):
235 return self._api 235 return self._api
236 236
237 def compute_relative_change(self): 237 def compute_relative_change(self):
238 old_value = float(self.good_rev.mean_value) 238 old_value = float(self.good_rev.mean or 0)
239 new_value = float(self.bad_rev.mean_value) 239 new_value = float(self.bad_rev.mean or 0)
240 240
241 if new_value and not old_value: # pragma: no cover 241 if new_value and not old_value: # pragma: no cover
242 self.relative_change = ZERO_TO_NON_ZERO 242 self.relative_change = ZERO_TO_NON_ZERO
243 return 243 return
244 244
245 rel_change = self.api.m.math_utils.relative_change(old_value, new_value) 245 rel_change = self.api.m.math_utils.relative_change(old_value, new_value)
246 self.relative_change = '%.2f%%' % (100 * rel_change) 246 self.relative_change = '%.2f%%' % (100 * rel_change)
247 247
248 def make_deps_sha_file(self, deps_sha): 248 def make_deps_sha_file(self, deps_sha):
249 """Make a diff patch that creates DEPS.sha. 249 """Make a diff patch that creates DEPS.sha.
(...skipping 16 matching lines...) Expand all
266 file is to be written to. 266 file is to be written to.
267 commit_hash (str): An identifier for the step. 267 commit_hash (str): An identifier for the step.
268 268
269 Returns: 269 Returns:
270 A string containing the hash of the interned object. 270 A string containing the hash of the interned object.
271 """ 271 """
272 cmd = 'hash-object -t blob -w --stdin'.split(' ') 272 cmd = 'hash-object -t blob -w --stdin'.split(' ')
273 stdin = self.api.m.raw_io.input(file_contents) 273 stdin = self.api.m.raw_io.input(file_contents)
274 stdout = self.api.m.raw_io.output() 274 stdout = self.api.m.raw_io.output()
275 step_name = 'Hashing modified DEPS file with revision ' + commit_hash 275 step_name = 'Hashing modified DEPS file with revision ' + commit_hash
276 step_result = self.api.m.git(*cmd, cwd=cwd, stdin=stdin, stdout=stdout, 276 step_result = self.api.m.git(
277 name=step_name) 277 *cmd, cwd=cwd, stdin=stdin, stdout=stdout, name=step_name,
278 step_test_data=lambda:
279 self.api.m.raw_io.test_api.stream_output(commit_hash))
278 hash_string = step_result.stdout.splitlines()[0] 280 hash_string = step_result.stdout.splitlines()[0]
279 try: 281 try:
280 if hash_string: 282 if hash_string:
281 int(hash_string, 16) 283 int(hash_string, 16)
282 return hash_string 284 return hash_string
283 except ValueError: # pragma: no cover 285 except ValueError: # pragma: no cover
284 reason = 'Git did not output a valid hash for the interned file.' 286 reason = 'Git did not output a valid hash for the interned file.'
285 self.api.m.halt(reason) 287 self.api.m.halt(reason)
286 raise self.api.m.step.StepFailure(reason) 288 raise self.api.m.step.StepFailure(reason)
287 289
288 def _gen_diff_patch(self, git_object_a, git_object_b, src_alias, dst_alias, 290 def _gen_diff_patch(self, git_object_a, git_object_b, src_alias, dst_alias,
prasadv 2016/09/09 18:52:30 Do we still need this? Since we are using buildbuc
RobertoCN 2016/09/13 22:11:41 Removed it. Now, we'll hash self.revision_string()
289 cwd, deps_rev): 291 cwd, deps_rev):
290 """Produces a git diff patch. 292 """Produces a git diff patch.
291 293
292 Args: 294 Args:
293 git_object_a (str): Tree-ish git object identifier. 295 git_object_a (str): Tree-ish git object identifier.
294 git_object_b (str): Another tree-ish git object identifier. 296 git_object_b (str): Another tree-ish git object identifier.
295 src_alias (str): Label to replace the tree-ish identifier on 297 src_alias (str): Label to replace the tree-ish identifier on
296 the resulting diff patch. (git_object_a) 298 the resulting diff patch. (git_object_a)
297 dst_alias (str): Same as above for (git_object_b) 299 dst_alias (str): Same as above for (git_object_b)
298 cwd (recipe_config_types.Path): Path to the checkout whose repo contains 300 cwd (recipe_config_types.Path): Path to the checkout whose repo contains
299 the objects to be compared. 301 the objects to be compared.
300 deps_rev (str): Deps revision to identify the patch generating step. 302 deps_rev (str): Deps revision to identify the patch generating step.
301 303
302 Returns: 304 Returns:
303 A string containing the diff patch as produced by the 'git diff' command. 305 A string containing the diff patch as produced by the 'git diff' command.
304 """ 306 """
305 # The prefixes used in the command below are used to find and replace the 307 # The prefixes used in the command below are used to find and replace the
306 # tree-ish git object id's on the diff output more easily. 308 # tree-ish git object id's on the diff output more easily.
307 cmd = 'diff %s %s --src-prefix=IAMSRC: --dst-prefix=IAMDST:' 309 cmd = 'diff %s %s --src-prefix=IAMSRC: --dst-prefix=IAMDST:'
308 cmd %= (git_object_a, git_object_b) 310 cmd %= (git_object_a, git_object_b)
309 cmd = cmd.split(' ') 311 cmd = cmd.split(' ')
310 stdout = self.api.m.raw_io.output() 312 stdout = self.api.m.raw_io.output()
311 step_name = 'Generating patch for %s to %s' % (git_object_a, deps_rev) 313 step_name = 'Generating patch for %s to %s' % (git_object_a, deps_rev)
312 step_result = self.api.m.git(*cmd, cwd=cwd, stdout=stdout, name=step_name) 314 step_result = self.api.m.git(
315 *cmd, cwd=cwd, stdout=stdout, name=step_name,
316 step_test_data=lambda: self.api._test_data['diff_patch'])
313 patch_text = step_result.stdout 317 patch_text = step_result.stdout
314 src_string = 'IAMSRC:' + git_object_a 318 src_string = 'IAMSRC:' + git_object_a
315 dst_string = 'IAMDST:' + git_object_b 319 dst_string = 'IAMDST:' + git_object_b
316 patch_text = patch_text.replace(src_string, src_alias) 320 patch_text = patch_text.replace(src_string, src_alias)
317 patch_text = patch_text.replace(dst_string, dst_alias) 321 patch_text = patch_text.replace(dst_string, dst_alias)
318 return patch_text 322 return patch_text
319 323
320 def make_deps_patch(self, base_revision, base_file_contents, 324 def make_deps_patch(self, base_revision, base_file_contents,
321 depot, new_commit_hash): 325 depot, new_commit_hash):
322 """Make a diff patch that updates a specific dependency revision. 326 """Make a diff patch that updates a specific dependency revision.
(...skipping 42 matching lines...) Expand 10 before | Expand all | Expand 10 after
365 interned_deps_hash, deps_file, deps_file, 369 interned_deps_hash, deps_file, deps_file,
366 cwd=cwd, 370 cwd=cwd,
367 deps_rev=new_commit_hash) 371 deps_rev=new_commit_hash)
368 return patch_text, patched_contents 372 return patch_text, patched_contents
369 373
370 def _expand_initial_revision_range(self): 374 def _expand_initial_revision_range(self):
371 """Sets the initial contents of |self.revisions|.""" 375 """Sets the initial contents of |self.revisions|."""
372 with self.api.m.step.nest('Expanding revision range'): 376 with self.api.m.step.nest('Expanding revision range'):
373 good_hash = self.good_rev.commit_hash 377 good_hash = self.good_rev.commit_hash
374 bad_hash = self.bad_rev.commit_hash 378 bad_hash = self.bad_rev.commit_hash
379 depot = self.good_rev.depot_name
375 step_name = 'for revisions %s:%s' % (good_hash, bad_hash) 380 step_name = 'for revisions %s:%s' % (good_hash, bad_hash)
376 revisions = self._revision_range( 381 revisions = self._revision_range(
377 start=good_hash, 382 start=good_hash,
378 end=bad_hash, 383 end=bad_hash,
379 depot_name=self.base_depot, 384 depot_name=self.base_depot,
380 step_name=step_name, 385 step_name=step_name,
381 exclude_end=True) 386 exclude_end=True,
387 step_test_data=lambda: self.api._test_data['revision_list'][depot]
388 )
382 self.revisions = [self.good_rev] + revisions + [self.bad_rev] 389 self.revisions = [self.good_rev] + revisions + [self.bad_rev]
383 self._update_revision_list_indexes() 390 self._update_revision_list_indexes()
384 391
385 def _revision_range(self, start, end, depot_name, base_revision=None, 392 def _revision_range(self, start, end, depot_name, base_revision=None,
386 step_name=None, exclude_end=False): 393 step_name=None, exclude_end=False, **kwargs):
387 """Returns a list of RevisionState objects between |start| and |end|. 394 """Returns a list of RevisionState objects between |start| and |end|.
388 395
389 When expanding the initial revision range we want to exclude the last 396 When expanding the initial revision range we want to exclude the last
390 revision, since both good and bad have already been created and tested. 397 revision, since both good and bad have already been created and tested.
391 When bisecting into a roll on the other hand, we want to include the last 398 When bisecting into a roll on the other hand, we want to include the last
392 revision in the roll, because although the code should be equivalent to 399 revision in the roll, because although the code should be equivalent to
393 the roll, we want to blame the right culprit and not the roll. 400 the roll, we want to blame the right culprit and not the roll.
394 401
395 Args: 402 Args:
396 start (str): Start commit hash. 403 start (str): Start commit hash.
397 end (str): End commit hash. 404 end (str): End commit hash.
398 depot_name (str): Short string name of repo, e.g. chromium or v8. 405 depot_name (str): Short string name of repo, e.g. chromium or v8.
399 base_revision (str): Base revision in the downstream repo (e.g. chromium). 406 base_revision (str): Base revision in the downstream repo (e.g. chromium).
400 step_name (str): Optional step name. 407 step_name (str): Optional step name.
401 exclude_end (bool): Whether to exclude the last revision in the range, 408 exclude_end (bool): Whether to exclude the last revision in the range,
402 i.e. the revision given as end. 409 i.e. the revision given as end.
403 410
404 Returns: 411 Returns:
405 A list of RevisionState objects. 412 A list of RevisionState objects.
406 """ 413 """
407 if self.internal_bisect: # pragma: no cover 414 if self.internal_bisect: # pragma: no cover
408 return self._revision_range_with_gitiles( 415 return self._revision_range_with_gitiles(
409 start, end, depot_name, base_revision, step_name) 416 start, end, depot_name, base_revision, step_name)
410 try: 417 try:
411 step_result = self.api.m.python( 418 step_result = self.api.m.python(
412 step_name, 419 step_name,
413 self.api.resource('fetch_intervening_revisions.py'), 420 self.api.resource('fetch_intervening_revisions.py'),
414 [start, end, depot_config.DEPOT_DEPS_NAME[depot_name]['url']], 421 [start, end, depot_config.DEPOT_DEPS_NAME[depot_name]['url']],
415 stdout=self.api.m.json.output()) 422 stdout=self.api.m.json.output(), **kwargs)
416 except self.api.m.step.StepFailure: # pragma: no cover 423 except self.api.m.step.StepFailure: # pragma: no cover
417 self.surface_result('BAD_REV') 424 self.surface_result('BAD_REV')
418 raise 425 raise
419 revisions = [] 426 revisions = []
420 revision_hashes = step_result.stdout 427 revision_hashes = step_result.stdout
421 if exclude_end: 428 if exclude_end:
422 revision_hashes = revision_hashes[:-1] 429 revision_hashes = revision_hashes[:-1]
423 for commit_hash, _ in revision_hashes: 430 for commit_hash, _ in revision_hashes:
424 revisions.append(self.revision_class( 431 revisions.append(self.revision_class(
425 bisector=self, 432 bisector=self,
(...skipping 88 matching lines...) Expand 10 before | Expand all | Expand 10 after
514 dep_revision_max = max_revision.deps[depot_name] 521 dep_revision_max = max_revision.deps[depot_name]
515 if (dep_revision_min and dep_revision_max and 522 if (dep_revision_min and dep_revision_max and
516 dep_revision_min != dep_revision_max): 523 dep_revision_min != dep_revision_max):
517 step_name = ('Expanding revision range for revision %s' 524 step_name = ('Expanding revision range for revision %s'
518 ' on depot %s' % (dep_revision_max, depot_name)) 525 ' on depot %s' % (dep_revision_max, depot_name))
519 rev_list = self._revision_range( 526 rev_list = self._revision_range(
520 start=dep_revision_min, 527 start=dep_revision_min,
521 end=dep_revision_max, 528 end=dep_revision_max,
522 depot_name=depot_name, 529 depot_name=depot_name,
523 base_revision=min_revision, 530 base_revision=min_revision,
524 step_name=step_name) 531 step_name=step_name,
532 step_test_data=lambda:
533 self.api._test_data['revision_list'][depot_name])
525 new_revisions = self.revisions[:max_revision.list_index] 534 new_revisions = self.revisions[:max_revision.list_index]
526 new_revisions += rev_list 535 new_revisions += rev_list
527 new_revisions += self.revisions[max_revision.list_index:] 536 new_revisions += self.revisions[max_revision.list_index:]
528 self.revisions = new_revisions 537 self.revisions = new_revisions
529 self._update_revision_list_indexes() 538 self._update_revision_list_indexes()
530 return True 539 return True
531 except RuntimeError: # pragma: no cover 540 except RuntimeError: # pragma: no cover
532 warning_text = ('Could not expand dependency revisions for ' + 541 warning_text = ('Could not expand dependency revisions for ' +
533 revision_to_expand.commit_hash) 542 revision_to_expand.commit_hash)
534 self.surface_result('BAD_REV') 543 self.surface_result('BAD_REV')
(...skipping 16 matching lines...) Expand all
551 560
552 The change between the test results obtained for the given 'good' and 561 The change between the test results obtained for the given 'good' and
553 'bad' revisions is expected to be considered a regression. The 562 'bad' revisions is expected to be considered a regression. The
554 `improvement_direction` attribute is positive if a larger number is 563 `improvement_direction` attribute is positive if a larger number is
555 considered better, and negative if a smaller number is considered better. 564 considered better, and negative if a smaller number is considered better.
556 565
557 Returns: 566 Returns:
558 True if the check passes (i.e. no problem), False if the change is not 567 True if the check passes (i.e. no problem), False if the change is not
559 a regression according to the improvement direction. 568 a regression according to the improvement direction.
560 """ 569 """
561 good = self.good_rev.mean_value 570 good = self.good_rev.mean
562 bad = self.bad_rev.mean_value 571 bad = self.bad_rev.mean
563 572
564 if self.is_return_code_mode(): 573 if self.is_return_code_mode():
565 return True 574 return True
566 575
567 direction = self.improvement_direction 576 direction = self.improvement_direction
568 if direction is None: 577 if direction is None:
569 return True 578 return True
570 if (bad > good and direction > 0) or (bad < good and direction < 0): 579 if (bad > good and direction > 0) or (bad < good and direction < 0):
571 self._set_failed_direction_results() 580 self._set_failed_direction_results()
572 return False 581 return False
(...skipping 14 matching lines...) Expand all
587 """Checks that the initial range presents a clear enough regression. 596 """Checks that the initial range presents a clear enough regression.
588 597
589 We ensure that the good and bad revisions produce significantly different 598 We ensure that the good and bad revisions produce significantly different
590 results, increasing the sample size until MAX_REQUIRED_SAMPLES is reached 599 results, increasing the sample size until MAX_REQUIRED_SAMPLES is reached
591 or REGRESSION_CHECK_TIMEOUT seconds have elapsed. 600 or REGRESSION_CHECK_TIMEOUT seconds have elapsed.
592 601
593 Returns: True if the revisions produced results that differ from each 602 Returns: True if the revisions produced results that differ from each
594 other in a statistically significant manner. False if such difference could 603 other in a statistically significant manner. False if such difference could
595 not be established in the time or sample size allowed. 604 not be established in the time or sample size allowed.
596 """ 605 """
597 if self.test_type == 'return_code': 606 if self.is_return_code_mode():
598 return (self.good_rev.overall_return_code != 607 return (self.good_rev.overall_return_code !=
599 self.bad_rev.overall_return_code) 608 self.bad_rev.overall_return_code)
600 609
601 if self.bypass_stats_check: 610 if self.bypass_stats_check:
602 dummy_result = self.good_rev.values != self.bad_rev.values 611 self.compare_revisions(self.good_rev, self.bad_rev)
612 dummy_result = self.good_rev.mean != self.bad_rev.mean
603 if not dummy_result: 613 if not dummy_result:
604 self._set_insufficient_confidence_warning() 614 self._set_insufficient_confidence_warning()
605 return dummy_result 615 return dummy_result
606 616
617 # TODO(robertocn): This step should not be necessary in some cases.
607 with self.api.m.step.nest('Re-testing reference range'): 618 with self.api.m.step.nest('Re-testing reference range'):
608 expiration_time = time.time() + REGRESSION_CHECK_TIMEOUT 619 expiration_time = time.time() + REGRESSION_CHECK_TIMEOUT
609 while time.time() < expiration_time: 620 while time.time() < expiration_time:
610 if len(self.good_rev.values) >= 5 and len(self.bad_rev.values) >= 5: 621 if (self.good_rev.test_run_count >= 5
611 if self.significantly_different(self.good_rev.values, 622 and self.bad_rev.test_run_count >= 5):
612 self.bad_rev.values): 623 if self.compare_revisions(self.good_rev, self.bad_rev):
613 return True 624 return True
614 if len(self.good_rev.values) == len(self.bad_rev.values): 625 if self.good_rev.test_run_count == self.bad_rev.test_run_count:
615 revision_to_retest = self.last_tested_revision 626 revision_to_retest = self.last_tested_revision
616 else: 627 else:
617 revision_to_retest = min(self.good_rev, self.bad_rev, 628 revision_to_retest = min(self.good_rev, self.bad_rev,
618 key=lambda x: len(x.values)) 629 key=lambda x: x.test_run_count)
619 if len(revision_to_retest.values) < MAX_REQUIRED_SAMPLES: 630 revision_to_retest._do_test()
620 revision_to_retest.retest() 631
621 else:
622 break
623 self._set_insufficient_confidence_warning() 632 self._set_insufficient_confidence_warning()
624 return False 633 return False
625 634
626 635
627 def get_exception(self): 636 def get_exception(self):
628 raise NotImplementedError() # pragma: no cover 637 raise NotImplementedError() # pragma: no cover
629 # TODO: should return an exception with the details of the failure. 638 # TODO: should return an exception with the details of the failure.
630 639
631 def _set_insufficient_confidence_warning( 640 def _set_insufficient_confidence_warning(
632 self): # pragma: no cover 641 self): # pragma: no cover
633 """Adds a warning about the lack of initial regression confidence.""" 642 """Adds a warning about the lack of initial regression confidence."""
634 self.failed_initial_confidence = True 643 self.failed_initial_confidence = True
635 self.surface_result('LO_INIT_CONF') 644 self.surface_result('LO_INIT_CONF')
636 self.warnings.append( 645 self.warnings.append(
637 'Bisect failed to reproduce the regression with enough confidence.') 646 'Bisect failed to reproduce the regression with enough confidence.')
638 647
639 def _results_debug_message(self): 648 def _results_debug_message(self):
640 """Returns a string with values used to debug a bisect result.""" 649 """Returns a string with values used to debug a bisect result."""
641 result = 'bisector.lkgr: %r\n' % self.lkgr 650 result = 'bisector.lkgr: %r\n' % self.lkgr
642 result += 'bisector.fkbr: %r\n\n' % self.fkbr 651 result += 'bisector.fkbr: %r\n\n' % self.fkbr
643 result += self._revision_value_table() 652 result += self._revision_value_table()
644 if (self.lkgr and self.lkgr.values and self.fkbr and self.fkbr.values): 653 if (self.lkgr and self.lkgr.test_run_count and self.fkbr and
645 result += '\n' + self._t_test_results() 654 self.fkbr.test_run_count):
655 result += '\n' + '\n'.join([
656 'LKGR values: %r' % list(self.lkgr.debug_values),
657 'FKBR values: %r' % list(self.fkbr.debug_values),
658 ])
646 return result 659 return result
647 660
648 def _revision_value_table(self): 661 def _revision_value_table(self):
649 """Returns a string table showing revisions and their values.""" 662 """Returns a string table showing revisions and their values."""
650 header = [['Revision', 'Values']] 663 header = [['Revision', 'Values']]
651 rows = [[r.revision_string(), str(r.values)] for r in self.revisions] 664 rows = [[r.revision_string(), str(r.debug_values)] for r in self.revisions]
652 return self._pretty_table(header + rows) 665 return self._pretty_table(header + rows)
653 666
654 def _pretty_table(self, data): 667 def _pretty_table(self, data):
655 results = [] 668 results = []
656 for row in data: 669 for row in data:
657 results.append('%-15s' * len(row) % tuple(row)) 670 results.append('%-15s' * len(row) % tuple(row))
658 return '\n'.join(results) 671 return '\n'.join(results)
659 672
660 def _t_test_results(self):
661 """Returns a string showing t-test results for lkgr and fkbr."""
662 t, df, p = self.api.m.math_utils.welchs_t_test(
663 self.lkgr.values, self.fkbr.values)
664 lines = [
665 'LKGR values: %r' % self.lkgr.values,
666 'FKBR values: %r' % self.fkbr.values,
667 't-statistic: %r' % t,
668 'deg. of freedom: %r' % df,
669 'p-value: %r' % p,
670 'Confidence score: %r' % (100 * (1 - p))
671 ]
672 return '\n'.join(lines)
673
674 def print_result_debug_info(self): 673 def print_result_debug_info(self):
675 """Prints extra debug info at the end of the bisect process.""" 674 """Prints extra debug info at the end of the bisect process."""
676 lines = self._results_debug_message().splitlines() 675 lines = self._results_debug_message().splitlines()
677 # If we emit a null step then add a log to it, the log should be kept 676 # If we emit a null step then add a log to it, the log should be kept
678 # longer than 7 days (which is often needed to debug some issues). 677 # longer than 7 days (which is often needed to debug some issues).
679 self.api.m.step('Debug Info', []) 678 self.api.m.step('Debug Info', [])
680 self.api.m.step.active_result.presentation.logs['Debug Info'] = lines 679 self.api.m.step.active_result.presentation.logs['Debug Info'] = lines
681 680
682 def post_result(self, halt_on_failure=False): 681 def post_result(self, halt_on_failure=False):
683 """Posts bisect results to Perf Dashboard.""" 682 """Posts bisect results to Perf Dashboard."""
684 self.api.m.perf_dashboard.set_default_config() 683 self.api.m.perf_dashboard.set_default_config()
685 self.api.m.perf_dashboard.post_bisect_results( 684 self.api.m.perf_dashboard.post_bisect_results(
686 self.get_result(), halt_on_failure) 685 self.get_result(), halt_on_failure)
687 686
688 def get_revision_to_eval(self): 687 def get_revision_to_eval(self):
689 """Gets the next RevisionState object in the candidate range. 688 """Gets the next RevisionState object in the candidate range.
690 689
691 Returns: 690 Returns:
692 The next Revision object in a list. 691 The next Revision object in a list.
693 """ 692 """
694 self._update_candidate_range() 693 self._update_candidate_range()
695 candidate_range = [revision for revision in 694 candidate_range = [revision for revision in
696 self.revisions[self.lkgr.list_index + 1: 695 self.revisions[self.lkgr.list_index + 1:
697 self.fkbr.list_index] 696 self.fkbr.list_index]
698 if not revision.tested and not revision.failed] 697 if not revision.failed]
699 if len(candidate_range) == 1: 698 if len(candidate_range) == 1:
700 return candidate_range[0] 699 return candidate_range[0]
701 if len(candidate_range) == 0: 700 if len(candidate_range) == 0:
702 return None 701 return None
703 702
704 default_revision = candidate_range[len(candidate_range) / 2] 703 default_revision = candidate_range[len(candidate_range) / 2]
705 704
706 with self.api.m.step.nest( 705 with self.api.m.step.nest(
707 'Wiggling revision ' + default_revision.revision_string()): 706 'Wiggling revision ' + default_revision.revision_string()):
708 # We'll search up to 25% of the range (in either direction) to try and 707 # We'll search up to 25% of the range (in either direction) to try and
(...skipping 22 matching lines...) Expand all
731 return False 730 return False
732 731
733 def check_bisect_finished(self, revision): 732 def check_bisect_finished(self, revision):
734 """Checks if this revision completes the bisection process. 733 """Checks if this revision completes the bisection process.
735 734
736 In this case 'finished' refers to finding one revision considered 'good' 735 In this case 'finished' refers to finding one revision considered 'good'
737 immediately preceding a revision considered 'bad' where the 'bad' revision 736 immediately preceding a revision considered 'bad' where the 'bad' revision
738 does not contain a DEPS change. 737 does not contain a DEPS change.
739 """ 738 """
740 if (revision.bad and revision.previous_revision and 739 if (revision.bad and revision.previous_revision and
741 revision.previous_revision.good): # pragma: no cover 740 revision.previous_revision.good):
742 if revision.deps_change() and self._expand_deps_revisions(revision): 741 if revision.deps_change() and self._expand_deps_revisions(revision):
743 return False 742 return False
744 self.culprit = revision 743 self.culprit = revision
745 return True 744 return True
746 if (revision.good and revision.next_revision and 745 if (revision.good and revision.next_revision and
747 revision.next_revision.bad): 746 revision.next_revision.bad):
748 if (revision.next_revision.deps_change() 747 if (revision.next_revision.deps_change()
749 and self._expand_deps_revisions(revision.next_revision)): 748 and self._expand_deps_revisions(revision.next_revision)):
750 return False 749 return False
751 self.culprit = revision.next_revision 750 self.culprit = revision.next_revision
752 return True 751 return True
753 return False 752 # We'll never get here because revision adjacency is checked before this
754 753 # function is called.
755 def wait_for_all(self, revision_list): 754 assert False # pragma: no cover
756 """Waits for all revisions in list to finish."""
757 for r in revision_list:
758 self.wait_for(r)
759
760 def wait_for(self, revision, nest_check=True):
761 """Waits for the revision to finish its job."""
762 if nest_check and not self.flags.get(
763 'do_not_nest_wait_for_revision'): # pragma: no cover
764 with self.api.m.step.nest('Waiting for ' + revision.revision_string()):
765 return self.wait_for(revision, nest_check=False)
766 while True:
767 revision.update_status()
768 if revision.in_progress:
769 self.api.m.python.inline(
770 'sleeping',
771 """
772 import sys
773 import time
774 time.sleep(20*60)
775 sys.exit(0)
776 """)
777 else:
778 break
779 755
780 def _update_candidate_range(self): 756 def _update_candidate_range(self):
781 """Updates lkgr and fkbr (last known good/first known bad) revisions. 757 """Updates lkgr and fkbr (last known good/first known bad) revisions.
782 758
783 lkgr and fkbr are 'pointers' to the appropriate RevisionState objects in 759 lkgr and fkbr are 'pointers' to the appropriate RevisionState objects in
784 bisectors.revisions.""" 760 bisectors.revisions."""
785 for r in self.revisions: 761 for r in self.revisions:
786 if r.tested: 762 if r.test_run_count:
787 if r.good: 763 if r.good:
788 self.lkgr = r 764 self.lkgr = r
789 elif r.bad: 765 elif r.bad:
790 self.fkbr = r 766 self.fkbr = r
791 break 767 break
792 assert self.lkgr and self.fkbr 768 assert self.lkgr and self.fkbr
793 769
794 def get_perf_tester_name(self): 770 def get_perf_tester_name(self):
795 """Gets the name of the tester bot (on tryserver.chromium.perf) to use. 771 """Gets the name of the tester bot (on tryserver.chromium.perf) to use.
796 772
(...skipping 17 matching lines...) Expand all
814 790
815 # TODO(prasadv): Refactor this code to remove hard coded values. 791 # TODO(prasadv): Refactor this code to remove hard coded values.
816 bot_name = self.get_perf_tester_name() 792 bot_name = self.get_perf_tester_name()
817 if 'win' in bot_name: 793 if 'win' in bot_name:
818 if any(b in bot_name for b in ['x64', 'gpu']): 794 if any(b in bot_name for b in ['x64', 'gpu']):
819 return 'winx64_bisect_builder' 795 return 'winx64_bisect_builder'
820 return 'win_perf_bisect_builder' 796 return 'win_perf_bisect_builder'
821 797
822 # TODO(prasadv): Refactor this code to remove hard coded values and use 798 # TODO(prasadv): Refactor this code to remove hard coded values and use
823 # target_bit from the bot config. crbug.com/640287 799 # target_bit from the bot config. crbug.com/640287
824 if 'android' in bot_name: 800 if 'android' in bot_name: # pragma: no cover
825 if any(b in bot_name for b in ['arm64', 'nexus9', 'nexus5X']): 801 if any(b in bot_name for b in ['arm64', 'nexus9', 'nexus5X']):
826 return 'android_arm64_perf_bisect_builder' 802 return 'android_arm64_perf_bisect_builder'
827 return 'android_perf_bisect_builder' 803 return 'android_perf_bisect_builder'
828 804
829 if 'mac' in bot_name: 805 if 'mac' in bot_name:
830 return 'mac_perf_bisect_builder' 806 return 'mac_perf_bisect_builder'
831 807
832 return 'linux_perf_bisect_builder' 808 return 'linux_perf_bisect_builder'
833 809
834 def get_platform_gs_prefix(self): 810 def get_platform_gs_prefix(self):
835 """Returns the prefix of a GS URL where a build can be found. 811 """Returns the prefix of a GS URL where a build can be found.
836 812
837 This prefix includes the schema, bucket, directory and beginning 813 This prefix includes the schema, bucket, directory and beginning
838 of filename. It is joined together with the part of the filename 814 of filename. It is joined together with the part of the filename
839 that includes the revision and the file extension to form the 815 that includes the revision and the file extension to form the
840 full GS URL. 816 full GS URL.
841 """ 817 """
842 if self.api.buildurl_gs_prefix: # pragma: no cover 818 if self.api.buildurl_gs_prefix: # pragma: no cover
843 return self.api.buildurl_gs_prefix 819 return self.api.buildurl_gs_prefix
844 820
845 # TODO(prasadv): Refactor this code to remove hard coded values. 821 # TODO(prasadv): Refactor this code to remove hard coded values.
846 bot_name = self.get_perf_tester_name() 822 bot_name = self.get_perf_tester_name()
847 if 'win' in bot_name: 823 if 'win' in bot_name:
848 if any(b in bot_name for b in ['x64', 'gpu']): 824 if any(b in bot_name for b in ['x64', 'gpu']):
849 return 'gs://chrome-perf/Win x64 Builder/full-build-win32_' 825 return 'gs://chrome-perf/Win x64 Builder/full-build-win32_'
850 return 'gs://chrome-perf/Win Builder/full-build-win32_' 826 return 'gs://chrome-perf/Win Builder/full-build-win32_'
851 827
852 # TODO(prasadv): Refactor this code to remove hard coded values and use 828 # TODO(prasadv): Refactor this code to remove hard coded values and use
853 # target_bit from the bot config. crbug.com/640287 829 # target_bit from the bot config. crbug.com/640287
854 if 'android' in bot_name: 830 if 'android' in bot_name: #pragma: no cover
855 if any(b in bot_name for b in ['arm64', 'nexus9', 'nexus5X']): 831 if any(b in bot_name for b in ['arm64', 'nexus9', 'nexus5X']):
856 return 'gs://chrome-perf/Android arm64 Builder/full-build-linux_' 832 return 'gs://chrome-perf/Android arm64 Builder/full-build-linux_'
857 return 'gs://chrome-perf/Android Builder/full-build-linux_' 833 return 'gs://chrome-perf/Android Builder/full-build-linux_'
858 834
859 if 'mac' in bot_name: 835 if 'mac' in bot_name:
860 return 'gs://chrome-perf/Mac Builder/full-build-mac_' 836 return 'gs://chrome-perf/Mac Builder/full-build-mac_'
861 837
862 return 'gs://chrome-perf/Linux Builder/full-build-linux_' 838 return 'gs://chrome-perf/Linux Builder/full-build-linux_'
863 839
864 def ensure_sync_master_branch(self): 840 def ensure_sync_master_branch(self):
865 """Make sure the local master is in sync with the fetched origin/master. 841 """Make sure the local master is in sync with the fetched origin/master.
866 842
867 We have seen on several occasions that the local master branch gets reset 843 We have seen on several occasions that the local master branch gets reset
868 to previous revisions and also detached head states. Running this should 844 to previous revisions and also detached head states. Running this should
869 take care of either situation. 845 take care of either situation.
870 """ 846 """
871 # TODO(robertocn): Investigate what causes the states mentioned in the 847 # TODO(robertocn): Investigate what causes the states mentioned in the
872 # docstring in the first place. 848 # docstring in the first place.
873 self.api.m.git('update-ref', 'refs/heads/master', 849 self.api.m.git('update-ref', 'refs/heads/master',
874 'refs/remotes/origin/master') 850 'refs/remotes/origin/master')
875 self.api.m.git('checkout', 'master', cwd=self.api.m.path['checkout']) 851 self.api.m.git('checkout', 'master', cwd=self.api.m.path['checkout'])
876 852
877 def is_return_code_mode(self): 853 def is_return_code_mode(self):
878 """Checks whether this is a bisect on the test's exit code.""" 854 """Checks whether this is a bisect on the test's exit code."""
879 return self.bisect_config.get('test_type') == 'return_code' 855 return self.test_type == 'return_code'
880 856
881 def surface_result(self, result_string): 857 def surface_result(self, result_string):
882 assert result_string in VALID_RESULT_CODES 858 assert result_string in VALID_RESULT_CODES
883 prefix = 'B4T_' # To avoid collision. Stands for bisect (abbr. `a la i18n). 859 prefix = 'B4T_' # To avoid collision. Stands for bisect (abbr. `a la i18n).
884 result_code = prefix + result_string 860 result_code = prefix + result_string
885 assert len(result_code) <= 20 861 assert len(result_code) <= 20
886 if result_code not in self.result_codes: 862 if result_code not in self.result_codes:
887 self.result_codes.add(result_code) 863 self.result_codes.add(result_code)
888 properties = self.api.m.step.active_result.presentation.properties 864 properties = self.api.m.step.active_result.presentation.properties
889 properties['extra_result_code'] = sorted(self.result_codes) 865 properties['extra_result_code'] = sorted(self.result_codes)
890 866
891 def get_result(self): 867 def get_result(self):
892 """Returns the results as a jsonable object.""" 868 """Returns the results as a jsonable object."""
893 config = self.bisect_config 869 config = self.bisect_config
894 results_confidence = 0
895 if self.culprit:
896 results_confidence = self.api.m.math_utils.confidence_score(
897 self.lkgr.values, self.fkbr.values)
898 870
899 if self.failed: 871 if self.failed:
900 status = 'failed' 872 status = 'failed'
901 elif self.bisect_over: 873 elif self.bisect_over:
902 status = 'completed' 874 status = 'completed'
903 else: 875 else:
904 status = 'started' 876 status = 'started'
905 877
906 aborted_reason = None 878 aborted_reason = None
907 if self.failed_initial_confidence: 879 if self.failed_initial_confidence:
908 aborted_reason = _FAILED_INITIAL_CONFIDENCE_ABORT_REASON 880 aborted_reason = _FAILED_INITIAL_CONFIDENCE_ABORT_REASON
909 elif self.failed_direction: 881 elif self.failed_direction:
910 aborted_reason = _DIRECTION_OF_IMPROVEMENT_ABORT_REASON 882 aborted_reason = _DIRECTION_OF_IMPROVEMENT_ABORT_REASON
911 return { 883 return {
912 'try_job_id': config.get('try_job_id'), 884 'try_job_id': config.get('try_job_id'),
913 'bug_id': config.get('bug_id'), 885 'bug_id': config.get('bug_id'),
914 'status': status, 886 'status': status,
915 'buildbot_log_url': self._get_build_url(), 887 'buildbot_log_url': self._get_build_url(),
916 'bisect_bot': self.get_perf_tester_name(), 888 'bisect_bot': self.get_perf_tester_name(),
917 'command': config['command'], 889 'command': config['command'],
918 'test_type': config['test_type'], 890 'test_type': config['test_type'],
919 'metric': config['metric'], 891 'metric': config.get('metric'),
920 'change': self.relative_change, 892 'change': self.relative_change,
921 'score': results_confidence,
922 'good_revision': self.good_rev.commit_hash, 893 'good_revision': self.good_rev.commit_hash,
923 'bad_revision': self.bad_rev.commit_hash, 894 'bad_revision': self.bad_rev.commit_hash,
924 'warnings': self.warnings, 895 'warnings': self.warnings,
925 'aborted_reason': aborted_reason, 896 'aborted_reason': aborted_reason,
926 'culprit_data': self._culprit_data(), 897 'culprit_data': self._culprit_data(),
927 'revision_data': self._revision_data() 898 'revision_data': self._revision_data()
928 } 899 }
929 900
930 def _culprit_data(self): 901 def _culprit_data(self):
931 culprit = self.culprit 902 culprit = self.culprit
(...skipping 11 matching lines...) Expand all
943 'email': culprit_info['email'], 914 'email': culprit_info['email'],
944 'cl_date': culprit_info['date'], 915 'cl_date': culprit_info['date'],
945 'commit_info': culprit_info['body'], 916 'commit_info': culprit_info['body'],
946 'revisions_links': [], 917 'revisions_links': [],
947 'cl': culprit.commit_hash 918 'cl': culprit.commit_hash
948 } 919 }
949 920
950 def _revision_data(self): 921 def _revision_data(self):
951 revision_rows = [] 922 revision_rows = []
952 for r in self.revisions: 923 for r in self.revisions:
953 if r.tested or r.aborted: 924 if r.test_run_count:
954 revision_rows.append({ 925 revision_rows.append({
955 'depot_name': r.depot_name, 926 'depot_name': r.depot_name,
956 'commit_hash': r.commit_hash, 927 'commit_hash': r.commit_hash,
957 'revision_string': r.revision_string(), 928 'revision_string': r.revision_string(),
958 'mean_value': r.mean_value, 929 'mean_value': r.mean,
959 'std_dev': r.std_dev, 930 'std_dev': r.std_dev,
960 'values': r.values, 931 'values': r.debug_values,
961 'result': 'good' if r.good else 'bad' if r.bad else 'unknown', 932 'result': 'good' if r.good else 'bad' if r.bad else 'unknown',
962 }) 933 })
963 return revision_rows 934 return revision_rows
964 935
965 def _get_build_url(self): 936 def _get_build_url(self):
966 properties = self.api.m.properties 937 properties = self.api.m.properties
967 bot_url = properties.get('buildbotURL', 938 bot_url = properties.get('buildbotURL',
968 'http://build.chromium.org/p/chromium/') 939 'http://build.chromium.org/p/chromium/')
969 builder_name = urllib.quote(properties.get('buildername', '')) 940 builder_name = urllib.quote(properties.get('buildername', ''))
970 builder_number = str(properties.get('buildnumber', '')) 941 builder_number = str(properties.get('buildnumber', ''))
971 return '%sbuilders/%s/builds/%s' % (bot_url, builder_name, builder_number) 942 return '%sbuilders/%s/builds/%s' % (bot_url, builder_name, builder_number)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698