OLD | NEW |
1 # Copyright (c) 2013 The Chromium Authors. All rights reserved. | 1 # Copyright (c) 2013 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 | 5 |
6 """Miscellaneous utilities needed by the Skia buildbot master.""" | 6 """Miscellaneous utilities needed by the Skia buildbot master.""" |
7 | 7 |
8 | 8 |
9 import httplib2 | 9 import httplib2 |
10 import re | 10 import re |
11 | 11 |
12 # requires Google APIs client library for Python; see | 12 # requires Google APIs client library for Python; see |
13 # https://code.google.com/p/google-api-python-client/wiki/Installation | 13 # https://code.google.com/p/google-api-python-client/wiki/Installation |
14 from apiclient.discovery import build | 14 from apiclient.discovery import build |
15 from buildbot.scheduler import AnyBranchScheduler | 15 from buildbot.scheduler import AnyBranchScheduler |
16 from buildbot.schedulers import timed | 16 from buildbot.schedulers import timed |
17 from buildbot.schedulers.filter import ChangeFilter | 17 from buildbot.schedulers.filter import ChangeFilter |
18 from buildbot.util import NotABranch | 18 from buildbot.util import NotABranch |
19 from config_private import TRY_SVN_BASEURL | 19 from config_private import TRY_SVN_BASEURL |
20 from master import master_config | 20 from master import master_config |
21 from master import try_job_svn | 21 from master import try_job_svn |
22 from master import try_job_rietveld | 22 from master import try_job_rietveld |
23 from master.builders_pools import BuildersPools | 23 from master.builders_pools import BuildersPools |
24 from oauth2client.client import SignedJwtAssertionCredentials | 24 from oauth2client.client import SignedJwtAssertionCredentials |
25 | 25 |
26 import config_private | 26 import config_private |
27 | 27 |
28 | 28 |
| 29 BUILDER_NAME_SEP = '-' |
| 30 |
| 31 # Patterns for creating builder names, based on the role of the builder. |
| 32 # TODO(borenet): Extract these into a separate file (JSON?) so that they can be |
| 33 # read by other users. |
| 34 BUILDER_ROLE_COMPILE = 'Build' |
| 35 BUILDER_ROLE_PERF = 'Perf' |
| 36 BUILDER_ROLE_TEST = 'Test' |
| 37 BUILDER_ROLE_HOUSEKEEPER = 'Housekeeper' |
| 38 BUILDER_NAME_DEFAULT_ATTRS = ['os', 'model', 'gpu', 'arch', 'configuration'] |
| 39 BUILDER_NAME_SCHEMA = { |
| 40 BUILDER_ROLE_COMPILE: ['os', 'compiler', 'target_arch', 'configuration'], |
| 41 BUILDER_ROLE_TEST: BUILDER_NAME_DEFAULT_ATTRS, |
| 42 BUILDER_ROLE_PERF: BUILDER_NAME_DEFAULT_ATTRS, |
| 43 BUILDER_ROLE_HOUSEKEEPER: ['frequency'], |
| 44 } |
| 45 |
29 CATEGORY_BUILD = ' Build' | 46 CATEGORY_BUILD = ' Build' |
30 TRYBOT_NAME_SUFFIX = '_Trybot' | 47 TRYBOT_NAME_SUFFIX = 'Trybot' |
31 TRY_SCHEDULER_SVN = 'skia_try_svn' | 48 TRY_SCHEDULER_SVN = 'skia_try_svn' |
32 TRY_SCHEDULER_RIETVELD = 'skia_try_rietveld' | 49 TRY_SCHEDULER_RIETVELD = 'skia_try_rietveld' |
33 TRY_SCHEDULERS = [TRY_SCHEDULER_SVN, TRY_SCHEDULER_RIETVELD] | 50 TRY_SCHEDULERS = [TRY_SCHEDULER_SVN, TRY_SCHEDULER_RIETVELD] |
34 TRY_SCHEDULERS_STR = '|'.join(TRY_SCHEDULERS) | 51 TRY_SCHEDULERS_STR = '|'.join(TRY_SCHEDULERS) |
35 | 52 |
36 | 53 |
37 def IsTrybot(builder_name): | 54 def IsTrybot(builder_name): |
38 return builder_name.endswith(TRYBOT_NAME_SUFFIX) | 55 return builder_name.endswith(TRYBOT_NAME_SUFFIX) |
39 | 56 |
40 | 57 |
(...skipping 201 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
242 # Skip buildbot runs of a CL if its commit log message contains the following | 259 # Skip buildbot runs of a CL if its commit log message contains the following |
243 # substring. | 260 # substring. |
244 SKIP_BUILDBOT_SUBSTRING = '(SkipBuildbotRuns)' | 261 SKIP_BUILDBOT_SUBSTRING = '(SkipBuildbotRuns)' |
245 | 262 |
246 # If the below regex is found in a CL's commit log message, only run the | 263 # If the below regex is found in a CL's commit log message, only run the |
247 # builders specified therein. | 264 # builders specified therein. |
248 RUN_BUILDERS_REGEX = '\(RunBuilders:(.+)\)' | 265 RUN_BUILDERS_REGEX = '\(RunBuilders:(.+)\)' |
249 RUN_BUILDERS_RE_COMPILED = re.compile(RUN_BUILDERS_REGEX) | 266 RUN_BUILDERS_RE_COMPILED = re.compile(RUN_BUILDERS_REGEX) |
250 | 267 |
251 | 268 |
| 269 def AndroidModelToDevice(android_model): |
| 270 """ Converts Android model names to device names which android_setup.sh will |
| 271 like. |
| 272 |
| 273 Examples: |
| 274 'NexusS' becomes 'nexus_s' |
| 275 'Nexus10' becomes 'nexus_10' |
| 276 |
| 277 android_model: string; model name for an Android device. |
| 278 """ |
| 279 name_parts = [] |
| 280 for part in re.split('(\d+)', android_model): |
| 281 if re.match('(\d+)', part): |
| 282 name_parts.append(part) |
| 283 else: |
| 284 name_parts.extend(re.findall('[A-Z][a-z]*', part)) |
| 285 return '_'.join([part.lower() for part in name_parts]) |
| 286 |
| 287 |
252 # Since we can't modify the existing Helper class, we subclass it here, | 288 # Since we can't modify the existing Helper class, we subclass it here, |
253 # overriding the necessary parts to get things working as we want. | 289 # overriding the necessary parts to get things working as we want. |
254 # Specifically, the Helper class hardcodes each registered scheduler to be | 290 # Specifically, the Helper class hardcodes each registered scheduler to be |
255 # instantiated as a 'Scheduler,' which aliases 'SingleBranchScheduler.' We add | 291 # instantiated as a 'Scheduler,' which aliases 'SingleBranchScheduler.' We add |
256 # an 'AnyBranchScheduler' method and change the implementation of Update() to | 292 # an 'AnyBranchScheduler' method and change the implementation of Update() to |
257 # instantiate the proper type. | 293 # instantiate the proper type. |
258 | 294 |
259 # TODO(borenet): modify this code upstream so that we don't need this override. | 295 # TODO(borenet): modify this code upstream so that we don't need this override. |
260 # BUG: http://code.google.com/p/skia/issues/detail?id=761 | 296 # BUG: http://code.google.com/p/skia/issues/detail?id=761 |
261 class SkiaHelper(master_config.Helper): | 297 class SkiaHelper(master_config.Helper): |
262 | 298 |
263 def Builder(self, name, factory, gatekeeper=None, scheduler=None, | 299 def Builder(self, name, factory, gatekeeper=None, scheduler=None, |
264 builddir=None, auto_reboot=False, notify_on_missing=False, | 300 builddir=None, auto_reboot=False, notify_on_missing=False): |
265 override_category=None): | |
266 if override_category: | |
267 old_category = self._defaults.get('category') | |
268 self._defaults['category'] = override_category | |
269 super(SkiaHelper, self).Builder(name=name, factory=factory, | 301 super(SkiaHelper, self).Builder(name=name, factory=factory, |
270 gatekeeper=gatekeeper, scheduler=scheduler, | 302 gatekeeper=gatekeeper, scheduler=scheduler, |
271 builddir=builddir, auto_reboot=auto_reboot, | 303 builddir=builddir, auto_reboot=auto_reboot, |
272 notify_on_missing=notify_on_missing) | 304 notify_on_missing=notify_on_missing) |
273 if override_category: | |
274 self._defaults['category'] = old_category | |
275 | 305 |
276 def AnyBranchScheduler(self, name, branches, treeStableTimer=60, | 306 def AnyBranchScheduler(self, name, branches, treeStableTimer=60, |
277 categories=None): | 307 categories=None): |
278 if name in self._schedulers: | 308 if name in self._schedulers: |
279 raise ValueError('Scheduler %s already exist' % name) | 309 raise ValueError('Scheduler %s already exist' % name) |
280 self._schedulers[name] = {'type': 'AnyBranchScheduler', | 310 self._schedulers[name] = {'type': 'AnyBranchScheduler', |
281 'branches': branches, | 311 'branches': branches, |
282 'treeStableTimer': treeStableTimer, | 312 'treeStableTimer': treeStableTimer, |
283 'builders': [], | 313 'builders': [], |
284 'categories': categories} | 314 'categories': categories} |
(...skipping 121 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
406 # been defined. | 436 # been defined. |
407 # pylint: disable=W0601 | 437 # pylint: disable=W0601 |
408 global skia_all_subdirs | 438 global skia_all_subdirs |
409 try: | 439 try: |
410 if skia_all_subdirs: | 440 if skia_all_subdirs: |
411 raise Exception('skia_all_subdirs has already been defined!') | 441 raise Exception('skia_all_subdirs has already been defined!') |
412 except NameError: | 442 except NameError: |
413 skia_all_subdirs = all_subdirs | 443 skia_all_subdirs = all_subdirs |
414 | 444 |
415 | 445 |
416 def MakeBuilderName(builder_base_name, config): | 446 def MakeBuilderName(role, extra_config=None, is_trybot=False, **kwargs): |
417 """ Inserts config into builder_base_name at '%s', or if builder_base_name | 447 schema = BUILDER_NAME_SCHEMA.get(role) |
418 does not contain '%s', appends config to the end of builder_base_name, | 448 if not schema: |
419 separated by an underscore. """ | 449 raise ValueError('%s is not a recognized role.' % role) |
420 try: | 450 for k, v in kwargs.iteritems(): |
421 return builder_base_name % config | 451 if BUILDER_NAME_SEP in v: |
422 except TypeError: | 452 raise ValueError('%s not allowed in %s.' % (v, BUILDER_NAME_SEP)) |
423 # If builder_base_name does not contain '%s' | 453 if not k in schema: |
424 return '%s_%s' % (builder_base_name, config) | 454 raise ValueError('Schema does not contain "%s": %s' %(k, schema)) |
| 455 if extra_config and BUILDER_NAME_SEP in extra_config: |
| 456 raise ValueError('%s not allowed in %s.' % (extra_config, |
| 457 BUILDER_NAME_SEP)) |
| 458 name_parts = [role] |
| 459 name_parts.extend([kwargs[attribute] for attribute in schema]) |
| 460 if extra_config: |
| 461 name_parts.append(extra_config) |
| 462 if is_trybot: |
| 463 name_parts.append(TRYBOT_NAME_SUFFIX) |
| 464 print BUILDER_NAME_SEP.join(name_parts) |
| 465 return BUILDER_NAME_SEP.join(name_parts) |
425 | 466 |
426 | 467 |
427 def MakeCompileBuilderName(builder_base_name, release=False): | 468 def _MakeBuilder(helper, role, os, model, gpu, configuration, arch, |
428 if release: | 469 gm_image_subdir, factory_type, extra_config=None, |
429 compile_name = 'Compile_Release' | 470 perf_output_basedir=None, extra_branches=None, is_trybot=False, |
430 else: | 471 **kwargs): |
431 compile_name = 'Compile_Debug' | 472 """ Creates a builder and scheduler. """ |
432 return MakeBuilderName(builder_base_name, compile_name) | |
433 | |
434 | |
435 def MakeDebugBuilderName(builder_base_name): | |
436 return MakeBuilderName(builder_base_name, 'Debug') | |
437 | |
438 | |
439 def MakeReleaseBuilderName(builder_base_name): | |
440 return MakeBuilderName(builder_base_name, 'Release') | |
441 | |
442 | |
443 def MakeBenchBuilderName(builder_base_name): | |
444 return MakeBuilderName(builder_base_name, 'Bench') | |
445 | |
446 | |
447 def MakeSchedulerName(builder_base_name): | |
448 return MakeBuilderName(builder_base_name, 'Scheduler') | |
449 | |
450 | |
451 def _MakeBuilderSet(helper, builder_base_name, gm_image_subdir, factory_type, | |
452 perf_output_basedir=None, extra_branches=None, | |
453 do_compile=True, do_debug=True, do_release=True, | |
454 do_bench=True, try_schedulers=None, | |
455 compile_bot_warnings_as_errors=True, **kwargs): | |
456 """ Creates a trio of builders for a given platform: | |
457 1. Debug mode builder which runs all steps | |
458 2. Release mode builder which runs all steps EXCEPT benchmarks | |
459 3. Release mode builder which runs ONLY benchmarks. | |
460 """ | |
461 B = helper.Builder | 473 B = helper.Builder |
462 F = helper.Factory | 474 F = helper.Factory |
463 | 475 |
464 if not extra_branches: | 476 if not extra_branches: |
465 extra_branches = [] | 477 extra_branches = [] |
466 subdirs_to_checkout = set(extra_branches) | 478 subdirs_to_checkout = set(extra_branches) |
467 if gm_image_subdir: | 479 if gm_image_subdir: |
468 gm_image_branch = 'gm-expected/%s' % gm_image_subdir | 480 gm_image_branch = 'gm-expected/%s' % gm_image_subdir |
469 subdirs_to_checkout.add(gm_image_branch) | 481 subdirs_to_checkout.add(gm_image_branch) |
470 | 482 |
471 if try_schedulers: | 483 builder_name = MakeBuilderName( |
472 scheduler_name = '|'.join(try_schedulers) | 484 role=role, |
473 builder_base_name = builder_base_name + TRYBOT_NAME_SUFFIX | 485 os=os, |
| 486 model=model, |
| 487 gpu=gpu, |
| 488 configuration=configuration, |
| 489 arch=arch, |
| 490 extra_config=extra_config, |
| 491 is_trybot=is_trybot) |
| 492 |
| 493 if is_trybot: |
| 494 scheduler_name = TRY_SCHEDULERS_STR |
474 else: | 495 else: |
475 scheduler_name = MakeSchedulerName(builder_base_name) | 496 scheduler_name = builder_name + BUILDER_NAME_SEP + 'Scheduler' |
476 branches = list(subdirs_to_checkout.union(SKIA_PRIMARY_SUBDIRS)) | 497 branches = list(subdirs_to_checkout.union(SKIA_PRIMARY_SUBDIRS)) |
477 helper.AnyBranchScheduler(scheduler_name, branches=branches) | 498 helper.AnyBranchScheduler(scheduler_name, branches=branches) |
478 | 499 |
479 if do_compile: | 500 B(builder_name, 'f_%s' % builder_name, scheduler=scheduler_name) |
480 compile_debug_builder_name = MakeCompileBuilderName(builder_base_name, | 501 F('f_%s' % builder_name, factory_type( |
481 release=False) | 502 builder_name=builder_name, |
482 B(compile_debug_builder_name, 'f_%s' % compile_debug_builder_name, | 503 other_subdirs=subdirs_to_checkout, |
483 # Do not add gatekeeper for trybots. | 504 configuration=configuration, |
484 gatekeeper='GateKeeper' if try_schedulers is None else None, | 505 gm_image_subdir=gm_image_subdir, |
485 scheduler=scheduler_name, override_category=CATEGORY_BUILD) | 506 do_patch_step=is_trybot, |
486 F('f_%s' % compile_debug_builder_name, factory_type( | 507 perf_output_basedir=perf_output_basedir, |
487 builder_name=compile_debug_builder_name, | 508 **kwargs |
488 other_subdirs=subdirs_to_checkout, | 509 ).Build(role=role)) |
489 configuration='Debug', | |
490 gm_image_subdir=gm_image_subdir, | |
491 do_patch_step=(try_schedulers is not None), | |
492 perf_output_basedir=None, | |
493 compile_warnings_as_errors=compile_bot_warnings_as_errors, | |
494 **kwargs | |
495 ).BuildCompileOnly()) | |
496 compile_release_builder_name = MakeCompileBuilderName(builder_base_name, | |
497 release=True) | |
498 B(compile_release_builder_name, 'f_%s' % compile_release_builder_name, | |
499 # Do not add gatekeeper for trybots. | |
500 gatekeeper='GateKeeper' if try_schedulers is None else None, | |
501 scheduler=scheduler_name, override_category=CATEGORY_BUILD) | |
502 F('f_%s' % compile_release_builder_name, factory_type( | |
503 builder_name=compile_release_builder_name, | |
504 other_subdirs=subdirs_to_checkout, | |
505 configuration='Release', | |
506 gm_image_subdir=gm_image_subdir, | |
507 do_patch_step=(try_schedulers is not None), | |
508 perf_output_basedir=None, | |
509 compile_warnings_as_errors=compile_bot_warnings_as_errors, | |
510 **kwargs | |
511 ).BuildCompileOnly()) | |
512 | |
513 if do_debug: | |
514 debug_builder_name = MakeDebugBuilderName(builder_base_name) | |
515 B(debug_builder_name, 'f_%s' % debug_builder_name, | |
516 scheduler=scheduler_name) | |
517 F('f_%s' % debug_builder_name, factory_type( | |
518 builder_name=debug_builder_name, | |
519 other_subdirs=subdirs_to_checkout, | |
520 configuration='Debug', | |
521 gm_image_subdir=gm_image_subdir, | |
522 do_patch_step=(try_schedulers is not None), | |
523 perf_output_basedir=None, | |
524 compile_warnings_as_errors=False, | |
525 **kwargs | |
526 ).Build()) | |
527 | |
528 if do_release: | |
529 no_perf_builder_name = MakeReleaseBuilderName(builder_base_name) | |
530 B(no_perf_builder_name, 'f_%s' % no_perf_builder_name, | |
531 scheduler=scheduler_name) | |
532 F('f_%s' % no_perf_builder_name, factory_type( | |
533 builder_name=no_perf_builder_name, | |
534 other_subdirs=subdirs_to_checkout, | |
535 configuration='Release', | |
536 gm_image_subdir=gm_image_subdir, | |
537 do_patch_step=(try_schedulers is not None), | |
538 perf_output_basedir=None, | |
539 compile_warnings_as_errors=False, | |
540 **kwargs | |
541 ).BuildNoPerf()) | |
542 | |
543 if do_bench: | |
544 perf_builder_name = MakeBenchBuilderName(builder_base_name) | |
545 B(perf_builder_name, 'f_%s' % perf_builder_name, | |
546 scheduler=scheduler_name) | |
547 F('f_%s' % perf_builder_name, factory_type( | |
548 builder_name=perf_builder_name, | |
549 other_subdirs=subdirs_to_checkout, | |
550 configuration='Release', | |
551 gm_image_subdir=gm_image_subdir, | |
552 do_patch_step=(try_schedulers is not None), | |
553 perf_output_basedir=perf_output_basedir, | |
554 compile_warnings_as_errors=False, | |
555 **kwargs | |
556 ).BuildPerfOnly()) | |
557 | 510 |
558 | 511 |
559 def _MakeBuilderAndMaybeTrybotSet(do_trybots=True, **kwargs): | 512 def _MakeBuilderAndMaybeTrybotSet(do_trybots=True, **kwargs): |
560 _MakeBuilderSet(try_schedulers=None, **kwargs) | 513 _MakeBuilder(**kwargs) |
561 if do_trybots: | 514 if do_trybots: |
562 _MakeBuilderSet(try_schedulers=TRY_SCHEDULERS, **kwargs) | 515 _MakeBuilder(is_trybot=True, **kwargs) |
563 | 516 |
564 | 517 |
565 def MakeBuilderSet(**kwargs): | 518 def MakeBuilderSet(**kwargs): |
566 _MakeBuilderAndMaybeTrybotSet(**kwargs) | 519 _MakeBuilderAndMaybeTrybotSet(**kwargs) |
567 | 520 |
568 | 521 |
569 def MakeHousekeeperBuilderSet(helper, percommit_factory_type, | 522 def _MakeCompileBuilder(helper, scheduler, os, compiler, configuration, |
570 periodic_factory_type, do_trybots, **kwargs): | 523 target_arch, factory_type, is_trybot, |
571 B = helper.Builder | 524 extra_config=None, **kwargs): |
572 F = helper.Factory | 525 builder_name = MakeBuilderName(role=BUILDER_ROLE_COMPILE, |
| 526 os=os, |
| 527 compiler=compiler, |
| 528 configuration=configuration, |
| 529 target_arch=target_arch, |
| 530 extra_config=extra_config, |
| 531 is_trybot=is_trybot) |
| 532 helper.Builder(builder_name, 'f_%s' % builder_name, |
| 533 # Do not add gatekeeper for trybots. |
| 534 gatekeeper='GateKeeper' if is_trybot else None, |
| 535 scheduler=scheduler) |
| 536 helper.Factory('f_%s' % builder_name, factory_type( |
| 537 builder_name=builder_name, |
| 538 do_patch_step=is_trybot, |
| 539 configuration=configuration, |
| 540 **kwargs |
| 541 ).Build(role=BUILDER_ROLE_COMPILE)) |
| 542 return builder_name |
573 | 543 |
574 builder_factory_scheduler = [ | 544 |
575 # The Percommit housekeeper | 545 def MakeCompileBuilderSet(scheduler, do_trybots=True, **kwargs): |
576 ('Skia_PerCommit_House_Keeping', | |
577 percommit_factory_type, | |
578 'skia_rel'), | |
579 # The Periodic housekeeper | |
580 ('Skia_Periodic_House_Keeping', | |
581 periodic_factory_type, | |
582 'skia_periodic'), | |
583 ] | |
584 if do_trybots: | 546 if do_trybots: |
585 # Add the corresponding trybot builders to the above list. | 547 _MakeCompileBuilder(scheduler=scheduler, is_trybot=True, **kwargs) |
586 builder_factory_scheduler.extend([ | 548 _MakeCompileBuilder(scheduler=TRY_SCHEDULERS_STR, is_trybot=False, **kwargs) |
587 (builder + TRYBOT_NAME_SUFFIX, factory, TRY_SCHEDULERS_STR) | |
588 for (builder, factory, _scheduler) in builder_factory_scheduler]) | |
589 | 549 |
590 for (builder_name, factory, scheduler) in builder_factory_scheduler: | |
591 B(builder_name, 'f_%s' % builder_name, scheduler=scheduler) | |
592 F('f_%s' % builder_name, | |
593 factory( | |
594 builder_name=builder_name, | |
595 do_patch_step=(scheduler == TRY_SCHEDULERS_STR), | |
596 **kwargs | |
597 ).Build()) | |
598 | 550 |
599 def CanMergeBuildRequests(req1, req2): | 551 def CanMergeBuildRequests(req1, req2): |
600 """ Determine whether or not two BuildRequests can be merged. Note that the | 552 """ Determine whether or not two BuildRequests can be merged. Note that the |
601 call to buildbot.sourcestamp.SourceStamp.canBeMergedWith() is conspicuously | 553 call to buildbot.sourcestamp.SourceStamp.canBeMergedWith() is conspicuously |
602 missing. This is because that method verifies that: | 554 missing. This is because that method verifies that: |
603 1. req1.source.repository == req2.source.repository | 555 1. req1.source.repository == req2.source.repository |
604 2. req1.source.project == req2.source.project | 556 2. req1.source.project == req2.source.project |
605 3. req1.source.branch == req2.source.branch | 557 3. req1.source.branch == req2.source.branch |
606 4. req1.patch == None and req2.patch = None | 558 4. req1.patch == None and req2.patch = None |
607 5. (req1.source.changes and req2.source.changes) or \ | 559 5. (req1.source.changes and req2.source.changes) or \ |
(...skipping 28 matching lines...) Expand all Loading... |
636 # request is associated with a change but the revisions match (#5 above). | 588 # request is associated with a change but the revisions match (#5 above). |
637 if req1.source.changes and not req2.source.changes: | 589 if req1.source.changes and not req2.source.changes: |
638 return False | 590 return False |
639 if not req1.source.changes and req2.source.changes: | 591 if not req1.source.changes and req2.source.changes: |
640 return False | 592 return False |
641 if not (req1.source.changes and req2.source.changes): | 593 if not (req1.source.changes and req2.source.changes): |
642 if req1.source.revision != req2.source.revision: | 594 if req1.source.revision != req2.source.revision: |
643 return False | 595 return False |
644 | 596 |
645 return True | 597 return True |
OLD | NEW |