| // Copyright 2026 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:collection'; |
| import 'dart:io'; |
| |
| import 'package:cocoon_common/is_release_branch.dart'; |
| import 'package:cocoon_server/logging.dart'; |
| import 'package:github/github.dart'; |
| import 'package:github/hooks.dart'; |
| import 'package:googleapis/firestore/v1.dart' hide Value; |
| |
| import '../model/firestore/pr_check_runs.dart'; |
| import '../model/firestore/pull_request_state.dart'; |
| import '../request_handling/exceptions.dart'; |
| import '../request_handling/response.dart'; |
| import 'cache_service.dart'; |
| import 'config.dart'; |
| import 'firestore.dart'; |
| import 'gerrit_service.dart'; |
| import 'github_service.dart'; |
| import 'scheduler.dart'; |
| import 'scheduler/process_check_run_result.dart'; |
| |
| // Filenames which are not actually tests. |
| const List<String> kNotActuallyATest = <String>[ |
| 'packages/flutter/lib/src/gestures/hit_test.dart', |
| ]; |
| |
| /// List of repos that require check for tests. |
| Set<RepositorySlug> kNeedsTests = <RepositorySlug>{ |
| Config.flutterSlug, |
| Config.packagesSlug, |
| }; |
| |
| // Extentions for files that use // for single line comments. |
| const Set<String> knownCommentCodeExtensions = <String>{ |
| 'cc', |
| 'cpp', |
| 'dart', |
| 'gradle', |
| 'groovy', |
| 'java', |
| 'kt', |
| 'm', |
| 'mm', |
| 'swift', |
| }; |
| |
| /// Manages the state and event queue for a single Pull Request. |
| class PullRequestManager { |
| final RepositorySlug slug; |
| final int prNumber; |
| final FirestoreService firestore; |
| final Config config; |
| final Scheduler scheduler; |
| final GerritService gerritService; |
| final PullRequestLabelProcessorProvider pullRequestLabelProcessorProvider; |
| |
| final bool _isPrivileged; |
| String _latestSha; |
| String? _scheduledSha; |
| bool _isDirty = false; |
| |
| PullRequestManager._({ |
| required this.slug, |
| required this.prNumber, |
| required this.firestore, |
| required this.config, |
| required this.scheduler, |
| required this.gerritService, |
| required this.pullRequestLabelProcessorProvider, |
| required bool isPrivileged, |
| required String latestSha, |
| String? scheduledSha, |
| }) : _isPrivileged = isPrivileged, |
| _latestSha = latestSha, |
| _scheduledSha = scheduledSha; |
| |
| /// Runs an action with a distributed lock on the pull request. |
| static Future<T> _runWithLock<T>({ |
| required PullRequestEvent event, |
| required CacheService cache, |
| required Future<T> Function() action, |
| }) async { |
| final pr = event.pullRequest!; |
| final slug = event.repository!.slug(); |
| final prNumber = pr.number!; |
| final lockKey = 'pr_lock_${slug.owner}_${slug.name}_$prNumber'; |
| |
| T? result; |
| final lockAcquired = await cache.tryLock( |
| lockKey, |
| () async { |
| result = await action(); |
| }, |
| const Duration(minutes: 5), |
| 3, // Retry up to 3 times with exponential backoff under contention |
| ); |
| |
| if (!lockAcquired) { |
| throw const ServiceUnavailable('PR is locked by another instance'); |
| } |
| |
| return result as T; |
| } |
| |
| static Future<void> handleOpened( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| ) async { |
| await _runWithLock( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final manager = await PullRequestManager._create( |
| slug: event.repository!.slug(), |
| prNumber: event.pullRequest!.number!, |
| firestore: context.firestore, |
| config: context.config, |
| scheduler: context.scheduler, |
| gerritService: context.gerritService, |
| pullRequestLabelProcessorProvider: |
| context.pullRequestLabelProcessorProvider, |
| event: event, |
| ); |
| await manager._handleOpened(event); |
| await manager.persist(); |
| }, |
| ); |
| } |
| |
| static Future<void> handleSynchronize( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| ) async { |
| await _runWithLock( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final manager = await PullRequestManager._create( |
| slug: event.repository!.slug(), |
| prNumber: event.pullRequest!.number!, |
| firestore: context.firestore, |
| config: context.config, |
| scheduler: context.scheduler, |
| gerritService: context.gerritService, |
| pullRequestLabelProcessorProvider: |
| context.pullRequestLabelProcessorProvider, |
| event: event, |
| ); |
| await manager._handleSynchronize(event); |
| await manager.persist(); |
| }, |
| ); |
| } |
| |
| static Future<void> handleEdited( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| ) async { |
| await _runWithLock( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final manager = await PullRequestManager._create( |
| slug: event.repository!.slug(), |
| prNumber: event.pullRequest!.number!, |
| firestore: context.firestore, |
| config: context.config, |
| scheduler: context.scheduler, |
| gerritService: context.gerritService, |
| pullRequestLabelProcessorProvider: |
| context.pullRequestLabelProcessorProvider, |
| event: event, |
| ); |
| await manager._handleEdited(event); |
| await manager.persist(); |
| }, |
| ); |
| } |
| |
| static Future<void> handleReopened( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| ) async { |
| await _runWithLock( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final manager = await PullRequestManager._create( |
| slug: event.repository!.slug(), |
| prNumber: event.pullRequest!.number!, |
| firestore: context.firestore, |
| config: context.config, |
| scheduler: context.scheduler, |
| gerritService: context.gerritService, |
| pullRequestLabelProcessorProvider: |
| context.pullRequestLabelProcessorProvider, |
| event: event, |
| ); |
| await manager._handleReopened(event); |
| await manager.persist(); |
| }, |
| ); |
| } |
| |
| static Future<void> handleLabeled( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| String? labelName, |
| int? labelId, |
| ) async { |
| await _runWithLock( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final manager = await PullRequestManager._create( |
| slug: event.repository!.slug(), |
| prNumber: event.pullRequest!.number!, |
| firestore: context.firestore, |
| config: context.config, |
| scheduler: context.scheduler, |
| gerritService: context.gerritService, |
| pullRequestLabelProcessorProvider: |
| context.pullRequestLabelProcessorProvider, |
| event: event, |
| ); |
| await manager._handleLabeled(event, labelName, labelId); |
| await manager.persist(); |
| }, |
| ); |
| } |
| |
| static Future<Response> handleClosed( |
| PullRequestEvent event, |
| PullRequestEventContext context, |
| Future<ProcessCheckRunResult> Function(PullRequestEvent) processClosedFn, |
| ) async { |
| return await _runWithLock<Response>( |
| event: event, |
| cache: context.cache, |
| action: () async { |
| final result = await processClosedFn(event); |
| return result.toResponse(); |
| }, |
| ); |
| } |
| |
| /// Creates and hydrates a [PullRequestManager] instance. |
| static Future<PullRequestManager> _create({ |
| required RepositorySlug slug, |
| required int prNumber, |
| required FirestoreService firestore, |
| required Config config, |
| required Scheduler scheduler, |
| required GerritService gerritService, |
| required PullRequestLabelProcessorProvider |
| pullRequestLabelProcessorProvider, |
| required PullRequestEvent event, |
| }) async { |
| final documentId = PullRequestState.getDocumentId(slug, prNumber); |
| final name = |
| '$kDocumentParent/${PullRequestState.kCollectionId}/$documentId'; |
| |
| bool isPrivileged; |
| String latestSha; |
| String? scheduledSha; |
| |
| try { |
| final doc = await firestore.getDocument(name); |
| final state = PullRequestState.fromDocument(doc); |
| isPrivileged = state.isPrivileged ?? false; |
| latestSha = state.latestSha ?? event.pullRequest!.head!.sha!; |
| scheduledSha = state.scheduledSha; |
| log.info( |
| 'Hydrated PullRequestManager for $slug/$prNumber: isPrivileged=$isPrivileged, latestSha=$latestSha, scheduledSha=$scheduledSha', |
| ); |
| } on DetailedApiRequestError catch (e) { |
| if (e.status != HttpStatus.notFound) { |
| rethrow; |
| } |
| |
| // Document not found, initialize as new |
| final pr = event.pullRequest!; |
| final author = pr.user!.login!; |
| final githubService = await config.createGithubService(slug); |
| final isRoller = config.rollerAccounts.contains(author); |
| final isFlutterHacker = await githubService.isTeamMember( |
| 'flutter-hackers', |
| author, |
| slug.owner, |
| ); |
| isPrivileged = isRoller || isFlutterHacker; |
| latestSha = pr.head!.sha!; |
| |
| // Persist initial state using the specific document ID! |
| final state = PullRequestState() |
| ..slug = slug |
| ..number = prNumber |
| ..isPrivileged = isPrivileged |
| ..latestSha = latestSha; |
| |
| final document = Document(fields: state.fields); |
| await firestore.createDocument( |
| document, |
| collectionId: PullRequestState.kCollectionId, |
| documentId: documentId, |
| ); |
| log.info('Created initial PullRequestState for $slug/$prNumber'); |
| } |
| |
| return PullRequestManager._( |
| slug: slug, |
| prNumber: prNumber, |
| firestore: firestore, |
| config: config, |
| scheduler: scheduler, |
| gerritService: gerritService, |
| pullRequestLabelProcessorProvider: pullRequestLabelProcessorProvider, |
| isPrivileged: isPrivileged, |
| latestSha: latestSha, |
| scheduledSha: scheduledSha, |
| ); |
| } |
| |
| /// Persists the current state to Firestore if modified. |
| Future<void> persist() async { |
| if (!_isDirty) { |
| return; |
| } |
| |
| final documentId = PullRequestState.getDocumentId(slug, prNumber); |
| final name = |
| '$kDocumentParent/${PullRequestState.kCollectionId}/$documentId'; |
| |
| final state = PullRequestState() |
| ..slug = slug |
| ..number = prNumber |
| ..isPrivileged = _isPrivileged |
| ..latestSha = latestSha |
| ..scheduledSha = _scheduledSha; |
| |
| final document = Document(name: name, fields: state.fields); |
| |
| await firestore.writeViaTransaction(documentsToWrites([document])); |
| log.info('Persisted PullRequestState for $slug/$prNumber'); |
| _isDirty = false; |
| } |
| |
| /// The latest SHA that we have processed for this PR. |
| String get latestSha => _latestSha; |
| set latestSha(String value) { |
| if (_latestSha != value) { |
| _latestSha = value; |
| _isDirty = true; |
| } |
| } |
| |
| /// The SHA for which we have scheduled presubmits. |
| String? get scheduledSha => _scheduledSha; |
| set scheduledSha(String? value) { |
| if (_scheduledSha != value) { |
| _scheduledSha = value; |
| _isDirty = true; |
| } |
| } |
| |
| /// Handles PR opened event. |
| Future<void> _handleOpened(PullRequestEvent event) async { |
| final pr = event.pullRequest!; |
| final sha = pr.head!.sha!; |
| latestSha = sha; // Update latest SHA. |
| |
| if (!pr.draft! && _isPrivileged) { |
| final gitHubClient = await config.createGitHubClient(pullRequest: pr); |
| await gitHubClient.issues.addLabelsToIssue(slug, pr.number!, ['CICD']); |
| log.debug('Added CICD label to PR $pr.number'); |
| await _scheduleIfMergeable(event); |
| } else { |
| await scheduler.createAwaitingCicdLabelCheckRun(slug, sha); |
| } |
| |
| await checkForTests(event); |
| await _tryReleaseApproval(event); |
| } |
| |
| /// Handles PR synchronize event. |
| Future<void> _handleSynchronize(PullRequestEvent event) async { |
| final pr = event.pullRequest!; |
| final sha = pr.head!.sha!; |
| latestSha = sha; // Update latest SHA. |
| |
| final hasLabel = |
| pr.labels?.any((IssueLabel l) => l.name == Config.kCicdLabel) ?? false; |
| final isPrivilegedUser = _isPrivileged; |
| |
| if (isPrivilegedUser) { |
| if (hasLabel) { |
| await _scheduleIfMergeable(event); |
| } else { |
| await scheduler.createAwaitingCicdLabelCheckRun(slug, sha); |
| } |
| } else { |
| if (hasLabel) { |
| final githubService = await config.createGithubService(slug); |
| await githubService.removeLabel(slug, pr.number!, Config.kCicdLabel); |
| log.debug('Removed CICD label from PR ${pr.number}'); |
| } |
| await scheduler.createAwaitingCicdLabelCheckRun(slug, sha); |
| } |
| } |
| |
| /// Handles PR edited event. |
| Future<void> _handleEdited(PullRequestEvent event) async { |
| await checkForTests(event); |
| } |
| |
| /// Handles PR reopened event. |
| Future<void> _handleReopened(PullRequestEvent event) async { |
| final pr = event.pullRequest!; |
| await _warnThatANewCommitIsNeeded(event); |
| await _processLabels(pr); |
| await _updatePullRequest(pr); |
| } |
| |
| /// Handles PR labeled event. |
| Future<void> _handleLabeled( |
| PullRequestEvent event, |
| String? labelName, |
| int? labelId, |
| ) async { |
| final pr = event.pullRequest!; |
| final sha = pr.head!.sha!; |
| |
| if (labelName == Config.kCicdLabel && |
| Config.kCicdLabelIds.contains(labelId)) { |
| log.info('new CICD label added - scheduling tests'); |
| await scheduler.resolveAwaitingCicdLabelCheckRun(slug, sha); |
| await _scheduleIfMergeable(event); |
| } |
| |
| await _processLabels(pr); |
| await _updatePullRequest(pr); |
| } |
| |
| Future<void> checkForTests(PullRequestEvent pullRequestEvent) async { |
| final pr = pullRequestEvent.pullRequest!; |
| // We do not need to add test labels if this is an auto roller author. |
| if (config.rollerAccounts.contains(pr.user!.login)) { |
| return; |
| } |
| final eventAction = pullRequestEvent.action; |
| final isTipOfTree = pr.base!.ref == Config.defaultBranch(slug); |
| final gitHubClient = await config.createGitHubClient(pullRequest: pr); |
| await _validateRefs(gitHubClient, pr); |
| if (kNeedsTests.contains(slug) && isTipOfTree) { |
| log.info( |
| 'Applying framework repo labels for: owner=${slug.owner} repo=${slug.name} and pr=${pr.number}', |
| ); |
| switch (slug.name) { |
| case 'flutter': |
| final isFusion = slug == Config.flutterSlug; |
| final files = await gitHubClient.pullRequests |
| .listFiles(slug, pr.number!) |
| .toList(); |
| await _applyFrameworkRepoLabels( |
| gitHubClient, |
| eventAction, |
| pr, |
| files, |
| slug, |
| ); |
| if (isFusion) { |
| await _applyEngineRepoLabels( |
| gitHubClient, |
| eventAction, |
| pr, |
| files, |
| slug, |
| ); |
| } |
| case 'packages': |
| return _applyPackageTestChecks(gitHubClient, eventAction, pr); |
| } |
| } |
| } |
| |
| Future<void> _applyFrameworkRepoLabels( |
| GitHub gitHubClient, |
| String? eventAction, |
| PullRequest pr, |
| List<PullRequestFile> files, |
| RepositorySlug slug, |
| ) async { |
| if (pr.user!.login == 'engine-flutter-autoroll') { |
| return; |
| } |
| |
| final labels = <String>{}; |
| var hasTests = false; |
| var needsTests = false; |
| |
| var frameworkFiles = 0; |
| |
| for (var file in files) { |
| final filename = file.filename!; |
| |
| if (!_isFusionEnginePath(filename)) { |
| frameworkFiles++; |
| } |
| |
| if (_fileContainsAddedCode(file) && |
| !_isTestExempt(filename) && |
| !filename.startsWith('dev/bots/') && |
| !filename.endsWith('.gitignore')) { |
| needsTests = !_allChangesAreCodeComments(file); |
| } |
| |
| // Check to see if tests were submitted with this PR. |
| if (_isAFrameworkTest(filename)) { |
| hasTests = true; |
| } |
| } |
| |
| if (frameworkFiles == 0) { |
| // a fusion / engine only change. |
| return; |
| } |
| |
| if (pr.user!.login == 'fluttergithubbot') { |
| needsTests = false; |
| labels.addAll(<String>['c: tech-debt', 'c: flake']); |
| } |
| |
| if (labels.isNotEmpty) { |
| await gitHubClient.issues.addLabelsToIssue( |
| slug, |
| pr.number!, |
| labels.toList(), |
| ); |
| } |
| |
| if (!hasTests && |
| needsTests && |
| !pr.draft! && |
| !_isPrUpdatingReleaseBranch(pr)) { |
| final body = config.missingTestsPullRequestMessage; |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| } |
| } |
| |
| bool _isAFrameworkTest(String filename) { |
| if (kNotActuallyATest.any(filename.endsWith)) { |
| return false; |
| } |
| // Check for Objective-C tests which end in either "Tests.m" or "Test.m" |
| // in the "dev" directory. |
| final objectiveCTestRegex = RegExp(r'.*dev\/.*Test[s]?\.m$'); |
| return filename.endsWith('_test.dart') || |
| filename.endsWith('.expect') || |
| filename.contains('test_fixes') || |
| // Include updates to test utilities or test data |
| filename.contains('packages/flutter_tools/test/') || |
| // Kotlin source tests, used in the Flutter Gradle Plugin. |
| filename.startsWith('packages/flutter_tools/gradle/src/test/') || |
| filename.startsWith('dev/bots/analyze.dart') || |
| filename.startsWith('dev/bots/test.dart') || |
| filename.startsWith('dev/devicelab/bin/tasks') || |
| filename.startsWith('dev/devicelab/lib/tasks') || |
| filename.startsWith('dev/benchmarks') || |
| objectiveCTestRegex.hasMatch(filename); |
| } |
| |
| /// Returns true if changes to [filename] are exempt from the testing |
| /// requirement, across repositories. |
| bool _isTestExempt(String filename, {String engineBasePath = ''}) { |
| final isBuildPythonScript = |
| filename.startsWith('${engineBasePath}sky/tools') && |
| filename.endsWith('.py'); |
| return filename.contains('.ci.yaml') || |
| filename.endsWith('analysis_options.yaml') || |
| filename.endsWith('AUTHORS') || |
| filename.endsWith('CODEOWNERS') || |
| filename.endsWith('TESTOWNERS') || |
| filename.endsWith('pubspec.yaml') || |
| filename.endsWith('pubspec.yaml.tmpl') || |
| // Exempt categories. |
| filename.contains('.gemini/') || |
| filename.contains('.github/') || |
| filename.endsWith('.md') || |
| // Exempt paths. |
| filename.startsWith('dev/customer_testing/tests.version') || |
| filename.startsWith('dev/devicelab/lib/versions/gallery.dart') || |
| filename.startsWith('dev/integration_tests/') || |
| filename.startsWith('docs/') || |
| filename.startsWith('${engineBasePath}docs/') || |
| filename.endsWith('test/flutter_test_config.dart') || |
| // ↓↓↓ Begin engine specific paths ↓↓↓ |
| filename == 'DEPS' || // note: in monorepo; DEPS is still at the root. |
| isBuildPythonScript || |
| filename.endsWith('.gni') || |
| filename.endsWith('.gn') || |
| filename.startsWith('${engineBasePath}impeller/fixtures/') || |
| filename.startsWith('${engineBasePath}impeller/golden_tests/') || |
| filename.startsWith('${engineBasePath}impeller/playground/') || |
| filename.startsWith( |
| '${engineBasePath}shell/platform/embedder/tests/', |
| ) || |
| filename.startsWith( |
| '${engineBasePath}shell/platform/embedder/fixtures/', |
| ) || |
| filename.startsWith('${engineBasePath}testing/') || |
| filename.startsWith('${engineBasePath}tools/clangd_check/'); |
| } |
| |
| bool _isFusionEnginePath(String path) => |
| path.startsWith('engine/') || path == 'DEPS'; |
| |
| Future<void> _applyEngineRepoLabels( |
| GitHub gitHubClient, |
| String? eventAction, |
| PullRequest pr, |
| List<PullRequestFile> files, |
| RepositorySlug slug, |
| ) async { |
| // Do not apply the test labels for the autoroller accounts. |
| if (pr.user!.login == 'skia-flutter-autoroll') { |
| return; |
| } |
| |
| var hasTests = false; |
| var needsTests = false; |
| |
| // If engine labels are being applied to the flutterSlug - we're in a fusion repo. |
| final isFusion = slug == Config.flutterSlug; |
| final engineBasePath = isFusion ? 'engine/src/flutter/' : ''; |
| |
| var engineFiles = 0; |
| |
| for (var file in files) { |
| final path = file.filename!; |
| if (isFusion && _isFusionEnginePath(path)) { |
| engineFiles++; |
| } |
| |
| if (_fileContainsAddedCode(file) && |
| !_isTestExempt(path, engineBasePath: engineBasePath) && |
| // License goldens are auto-generated. |
| !path.startsWith('${engineBasePath}ci/licenses_golden/') && |
| // Build configuration files tell CI what to run. |
| !path.startsWith('${engineBasePath}ci/builders/')) { |
| needsTests = !_allChangesAreCodeComments(file); |
| } |
| |
| if (_isAnEngineTest(path)) { |
| hasTests = true; |
| } |
| } |
| |
| if (isFusion && engineFiles == 0) { |
| // framework only change |
| return; |
| } |
| |
| if (!hasTests && |
| needsTests && |
| !pr.draft! && |
| !_isPrUpdatingReleaseBranch(pr)) { |
| final body = config.missingTestsPullRequestMessage; |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| } |
| } |
| |
| bool _isAnEngineTest(String filename) { |
| final engineTestRegExp = RegExp( |
| r'(tests?|benchmarks?)\.(dart|java|mm|m|cc|sh|py|swift)$', |
| ); |
| return filename.contains('IosBenchmarks') || |
| filename.contains('IosUnitTests') || |
| filename.contains('scenario_app') || |
| engineTestRegExp.hasMatch(filename.toLowerCase()); |
| } |
| |
| bool _fileContainsAddedCode(PullRequestFile file) { |
| // When null, do not assume 0 lines have been added. |
| final linesAdded = file.additionsCount ?? 1; |
| final linesDeleted = file.deletionsCount ?? 0; |
| final linesTotal = file.changesCount ?? linesDeleted + linesAdded; |
| return linesAdded > 0 || linesDeleted != linesTotal; |
| } |
| |
| // Runs automated test checks for both flutter/packages. |
| Future<void> _applyPackageTestChecks( |
| GitHub gitHubClient, |
| String? eventAction, |
| PullRequest pr, |
| ) async { |
| final slug = pr.base!.repo!.slug(); |
| final files = gitHubClient.pullRequests.listFiles(slug, pr.number!); |
| var hasTests = false; |
| var needsTests = false; |
| |
| await for (PullRequestFile file in files) { |
| final filename = file.filename!; |
| |
| if (_fileContainsAddedCode(file) && |
| !_isTestExempt(filename) && |
| !filename.contains('.ci/') && |
| // Custom package-specific test runners. These do not count as tests |
| // for the purposes of testing a change that otherwise needs tests, |
| // but since they are the driver for tests they don't need test |
| // coverage. |
| !filename.endsWith('tool/run_tests.dart') && |
| !filename.endsWith('run_tests.sh')) { |
| needsTests = !_allChangesAreCodeComments(file); |
| } |
| // See https://github.com/flutter/flutter/blob/master/docs/ecosystem/testing/Plugin-Tests.md for discussion |
| // of various plugin test types and locations. |
| if (filename.endsWith('_test.dart') || |
| // Native iOS/macOS tests. |
| filename.contains('RunnerTests/') || |
| filename.contains('RunnerUITests/') || |
| filename.contains('darwin/Tests/') || |
| // Native Android tests. |
| filename.contains('android/src/test/') || |
| filename.contains('androidTest/') || |
| // Native Linux tests. |
| filename.endsWith('_test.cc') || |
| // Native Windows tests. |
| filename.endsWith('_test.cpp') || |
| // Pigeon native tests. |
| filename.contains('/platform_tests/') || |
| // Test files in package-specific test folders. |
| filename.contains('go_router/test_fixes/') || |
| filename.contains('go_router_builder/test_inputs/')) { |
| hasTests = true; |
| } |
| } |
| |
| if (!hasTests && |
| needsTests && |
| !pr.draft! && |
| !_isPrUpdatingReleaseBranch(pr)) { |
| final body = config.missingTestsPullRequestMessage; |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| } |
| } |
| |
| /// Validate the base and head refs of the PR. |
| Future<void> _validateRefs(GitHub gitHubClient, PullRequest pr) async { |
| final slug = pr.base!.repo!.slug(); |
| String body; |
| const releaseChannels = <String>['stable', 'beta', 'dev']; |
| // Close PRs that use a release branch as a source. |
| if (releaseChannels.contains(pr.head!.ref)) { |
| body = config.wrongHeadBranchPullRequestMessage(pr.head!.ref!); |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.pullRequests.edit(slug, pr.number!, state: 'closed'); |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| return; |
| } |
| final defaultBranchName = Config.defaultBranch(pr.base!.repo!.slug()); |
| final baseName = pr.base!.ref!; |
| if (baseName == defaultBranchName) { |
| return; |
| } |
| if (_isPrUpdatingReleaseBranch(pr)) { |
| body = config.releaseBranchPullRequestMessage; |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| return; |
| } |
| |
| // For repos migrated to main, close PRs opened against master. |
| final isMaster = pr.base?.ref == 'master'; |
| final isMigrated = defaultBranchName == 'main'; |
| // PRs should never be open to "beta" or "stable." |
| final isReleaseChannelBranch = releaseChannels.contains(pr.base?.ref); |
| if ((isMaster && isMigrated) || isReleaseChannelBranch) { |
| body = _getWrongBaseComment( |
| base: baseName, |
| defaultBranch: defaultBranchName, |
| ); |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.pullRequests.edit( |
| slug, |
| pr.number!, |
| base: Config.defaultBranch(slug), |
| ); |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| } |
| } |
| |
| static bool _isPrUpdatingReleaseBranch(PullRequest pr) { |
| return isReleaseCandidateBranch(branchName: pr.base!.ref!); |
| } |
| |
| Future<bool> _alreadyCommented( |
| GitHub gitHubClient, |
| PullRequest pr, |
| String message, |
| ) async { |
| final comments = gitHubClient.issues.listCommentsByIssue( |
| pr.base!.repo!.slug(), |
| pr.number!, |
| ); |
| await for (IssueComment comment in comments) { |
| if (comment.body != null && comment.body!.contains(message)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| String _getWrongBaseComment({ |
| required String base, |
| required String defaultBranch, |
| }) { |
| final messageTemplate = config.wrongBaseBranchPullRequestMessage; |
| return messageTemplate |
| .replaceAll('{{target_branch}}', base) |
| .replaceAll('{{default_branch}}', defaultBranch); |
| } |
| |
| /// Returns true if the changes to [file] are all code comments. |
| /// |
| /// If that cannot be determined with confidence, returns false. False |
| /// negatives (e.g., for /* */-style multi-line comments) should be expected. |
| bool _allChangesAreCodeComments(PullRequestFile file) { |
| final linesAdded = file.additionsCount; |
| final linesDeleted = file.deletionsCount; |
| final patch = file.patch; |
| // If information is missing, err or the side of assuming it's a non-comment |
| // change. |
| if (linesAdded == null || linesDeleted == null || patch == null) { |
| return false; |
| } |
| |
| final filename = file.filename!; |
| final extension = filename.contains('.') |
| ? filename.split('.').last.toLowerCase() |
| : null; |
| if (extension == null || !knownCommentCodeExtensions.contains(extension)) { |
| return false; |
| } |
| |
| // Only handles single-line comments; identifying multi-line comments |
| // would require the full file and non-trivial parsing. Also doesn't handle |
| // end-of-line comments (e.g., "int x = 0; // Info about x"). |
| final commentRegex = RegExp(r'^[+-]\s*//'); |
| final onlyWhitespaceRegex = RegExp(r'^[+-]\s*$'); |
| for (var line in patch.split('\n')) { |
| if (!line.startsWith('+') && !line.startsWith('-')) { |
| continue; |
| } |
| |
| if (onlyWhitespaceRegex.hasMatch(line)) { |
| // whitespace only changes don't require tests |
| continue; |
| } |
| |
| if (!commentRegex.hasMatch(line)) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| /// Release tooling generates cherrypick pull requests that should be granted an approval. |
| Future<void> _tryReleaseApproval(PullRequestEvent pullRequestEvent) async { |
| final pr = pullRequestEvent.pullRequest!; |
| final slug = pullRequestEvent.repository!.slug(); |
| |
| final defaultBranch = Config.defaultBranch(slug); |
| final branch = pr.base?.ref; |
| if (branch == null || branch.contains(defaultBranch)) { |
| // This isn't a release branch PR |
| return; |
| } |
| |
| final releaseAccounts = await config.releaseAccounts; |
| if (releaseAccounts.contains(pr.user?.login) == false) { |
| // The author isn't in the list of release accounts, do nothing |
| return; |
| } |
| |
| final gitHubClient = config.createGitHubClientWithToken( |
| await config.githubOAuthToken, |
| ); |
| final review = CreatePullRequestReview( |
| slug.owner, |
| slug.name, |
| pr.number!, |
| 'APPROVE', |
| ); |
| await gitHubClient.pullRequests.createReview(slug, review); |
| } |
| |
| Future<void> _processLabels(PullRequest pullRequest) async { |
| final slug = pullRequest.base!.repo!.slug(); |
| final githubService = await config.createGithubService(slug); |
| |
| final labelProcessor = pullRequestLabelProcessorProvider( |
| config: config, |
| githubService: githubService, |
| pullRequest: pullRequest, |
| ); |
| |
| return labelProcessor.processLabels(); |
| } |
| |
| Future<void> _scheduleIfMergeable(PullRequestEvent pullRequestEvent) async { |
| final pr = pullRequestEvent.pullRequest!; |
| final slug = pullRequestEvent.repository!.slug(); |
| final sha = pr.head!.sha!; |
| |
| if (scheduledSha == sha) { |
| log.info('Presubmits already scheduled for SHA $sha. Skipping.'); |
| return; |
| } |
| |
| log.info( |
| 'Scheduling tasks if mergeable(${pr.mergeable}): owner=${slug.owner} repo=${slug.name} and pr=${pr.number}', |
| ); |
| |
| if (pr.mergeable == false) { |
| final slug = pullRequestEvent.repository!.slug(); |
| final gitHubClient = await config.createGitHubClient(pullRequest: pr); |
| final body = config.mergeConflictPullRequestMessage; |
| if (!await _alreadyCommented(gitHubClient, pr, body)) { |
| await gitHubClient.issues.createComment(slug, pr.number!, body); |
| } |
| return; |
| } |
| |
| try { |
| await scheduler.triggerPresubmitTargets(pullRequest: pr); |
| scheduledSha = sha; // Update scheduled SHA! |
| await _processLabels(pr); |
| } on DetailedApiRequestError catch (e) { |
| if (e.status != HttpStatus.conflict) { |
| rethrow; |
| } |
| await _warnThatANewCommitIsNeeded(pullRequestEvent); |
| } |
| } |
| |
| Future<void> _warnThatANewCommitIsNeeded( |
| PullRequestEvent pullRequestEvent, |
| ) async { |
| final pr = pullRequestEvent.pullRequest!; |
| final slug = pullRequestEvent.repository!.slug(); |
| final sha = pr.head!.sha!; |
| |
| final gitHubClient = config.createGitHubClientWithToken( |
| await config.githubOAuthToken, |
| ); |
| await gitHubClient.issues.createComment( |
| slug, |
| pr.number!, |
| Config.newCommitIsNeeded(sha: sha), |
| ); |
| } |
| |
| /// Update the PR stored in [PrCheckRuns] so that subsequent checks are fresh. |
| Future<void> _updatePullRequest(PullRequest pr) async { |
| final sha = pr.head!.sha!; |
| final didUpdate = await PrCheckRuns.updatePullRequestForSha( |
| firestore, |
| sha, |
| pr, |
| ); |
| if (!didUpdate) { |
| log.debug('No PR found for SHA: $sha, did not update'); |
| } else { |
| log.debug('Updated PR for SHA: $sha'); |
| } |
| } |
| } |
| |
| class PullRequestEventContext { |
| final CacheService cache; |
| final FirestoreService firestore; |
| final Config config; |
| final Scheduler scheduler; |
| final GerritService gerritService; |
| final PullRequestLabelProcessorProvider pullRequestLabelProcessorProvider; |
| |
| PullRequestEventContext({ |
| required this.cache, |
| required this.firestore, |
| required this.config, |
| required this.scheduler, |
| required this.gerritService, |
| required this.pullRequestLabelProcessorProvider, |
| }); |
| } |
| |
| typedef PullRequestLabelProcessorProvider = |
| PullRequestLabelProcessor Function({ |
| required Config config, |
| required GithubService githubService, |
| required PullRequest pullRequest, |
| }); |
| |
| class PullRequestLabelProcessor { |
| PullRequestLabelProcessor({ |
| required this.config, |
| required this.githubService, |
| required this.pullRequest, |
| }) : slug = pullRequest.base!.repo!.slug(), |
| prNumber = pullRequest.number!; |
| |
| static const kEmergencyLabelEducation = ''' |
| Detected the `emergency` label. |
| |
| If you add the `autosubmit` label, the bot will wait until all presubmits pass but ignore the tree status, allowing fixes for tree breakages while still validating that they don't break any existing presubmits. |
| |
| The "Merge" button is also unlocked. To bypass presubmits as well as the tree status, press the GitHub "Add to Merge Queue". |
| '''; |
| |
| final Config config; |
| final GithubService githubService; |
| final PullRequest pullRequest; |
| final RepositorySlug slug; |
| final int prNumber; |
| late final String logCrumb = |
| '$PullRequestLabelProcessor($slug/pull/$prNumber)'; |
| |
| void logInfo(Object? message) { |
| log.info('$logCrumb: $message'); |
| } |
| |
| void logSevere(Object? message, {Object? error, StackTrace? stackTrace}) { |
| log.error('$logCrumb: $message', error, stackTrace); |
| } |
| |
| Future<void> processLabels() async { |
| final hasEmergencyLabel = |
| pullRequest.labels?.any( |
| (label) => label.name == Config.kEmergencyLabel, |
| ) ?? |
| false; |
| if (hasEmergencyLabel) { |
| // The merge queue guard and dashboard checks can be unlocked without approval checks because: |
| // |
| // * For manual merges the GitHub repo settings already require minimum |
| // approvals before the PR can be submitted. |
| // * For `autosubmit` label Cocoon has the [Approval] validation that |
| // checks approvals before attempting to merge the PR. |
| await _unlockCheckrunsForEmergency(); |
| } else { |
| logInfo('no emergency label; moving on.'); |
| } |
| } |
| |
| Future<void> _unlockCheckrunsForEmergency() async { |
| await _unlockCheckrun(Config.kMergeQueueLockName); |
| await _unlockCheckrun(Config.kDashboardCheckName); |
| |
| // Let the developer know what is happening with the MQ when this label is found the first time. |
| try { |
| if (!await githubService.commentExists( |
| slug, |
| prNumber, |
| PullRequestLabelProcessor.kEmergencyLabelEducation, |
| )) { |
| await githubService.createComment( |
| slug, |
| issueNumber: prNumber, |
| body: PullRequestLabelProcessor.kEmergencyLabelEducation, |
| ); |
| } |
| } catch (e, s) { |
| logSevere( |
| 'failed to leave educational comment for emergency label.', |
| error: e, |
| stackTrace: s, |
| ); |
| } |
| } |
| |
| Future<void> _unlockCheckrun(String checkName) async { |
| logInfo('attempting to unlock the $checkName for emergency'); |
| |
| final guard = (await githubService.getCheckRunsFiltered( |
| slug: slug, |
| ref: pullRequest.head!.sha!, |
| checkName: checkName, |
| )).singleOrNull; |
| |
| if (guard == null) { |
| logSevere( |
| 'failed to process the emergency label. "$checkName" check run is missing.', |
| ); |
| return; |
| } |
| |
| await githubService.updateCheckRun( |
| slug: slug, |
| checkRun: guard, |
| status: CheckRunStatus.completed, |
| conclusion: CheckRunConclusion.success, |
| output: CheckRunOutput( |
| title: checkName, |
| summary: 'Emergency label applied.', |
| ), |
| ); |
| |
| logInfo('unlocked "$checkName", allowing it to land as an emergency.'); |
| } |
| } |