| // Copyright 2022 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 'package:cocoon_server/logging.dart'; |
| import 'package:github/github.dart' as github; |
| |
| import '../model/auto_submit_query_result.dart'; |
| import '../model/pull_request_data_types.dart'; |
| import '../service/config.dart'; |
| import 'validation.dart'; |
| |
| /// Validates all the CI build/tests ran and were successful. |
| class CiSuccessful extends Validation { |
| /// The status checks that are not related to changes in this PR. |
| static const Set<String> notInAuthorsControl = <String>{ |
| 'tree-status', // flutter/engine repo |
| 'submit-queue', // packages repo |
| }; |
| |
| CiSuccessful({required super.config}); |
| |
| @override |
| String get name => 'CiSuccessful'; |
| |
| @override |
| /// Implements the CI build/tests validations. |
| Future<ValidationResult> validate( |
| QueryResult result, |
| github.PullRequest messagePullRequest, |
| ) async { |
| var allSuccess = true; |
| final slug = messagePullRequest.base!.repo!.slug(); |
| final prNumber = messagePullRequest.number!; |
| final prState = (messagePullRequest.state == 'closed') |
| ? PullRequestState.closed |
| : PullRequestState.open; |
| final pullRequest = result.repository!.pullRequest!; |
| final failures = <FailureDetail>{}; |
| |
| final statuses = <ContextNode>[]; |
| final commit = pullRequest.commits!.nodes!.single.commit!; |
| final author = result.repository!.pullRequest!.author!; |
| |
| // Recently most of the repositories have migrated away of using the status |
| // APIs and for those repos commit.status is null. |
| if (commit.status != null && commit.status!.contexts!.isNotEmpty) { |
| statuses.addAll(commit.status!.contexts!); |
| } |
| |
| final repositoryConfiguration = await config.getRepositoryConfiguration( |
| slug, |
| ); |
| final targetBranch = repositoryConfiguration.defaultBranch; |
| // Check tree status of repos. If the tree status is not ready, |
| // we want to hold and wait for the status, same as waiting |
| // for checks to finish. |
| final baseBranch = messagePullRequest.base!.ref; |
| if (baseBranch == targetBranch) { |
| // Only validate tree status where base branch is the default branch. |
| if (!isTreeStatusReporting(slug, prNumber, statuses)) { |
| log.warn( |
| 'Statuses were not ready for ${slug.fullName}/$prNumber, sha: $commit.', |
| ); |
| return ValidationResult( |
| false, |
| Action.IGNORE_TEMPORARILY, |
| 'Hold to wait for the tree status ready.', |
| ); |
| } |
| } else { |
| log.info( |
| 'Target branch is $baseBranch for ${slug.fullName}/$prNumber, skipping tree status check.', |
| ); |
| } |
| |
| // List of labels associated with the pull request. |
| final labelNames = (messagePullRequest.labels as List<github.IssueLabel>) |
| .map<String>((github.IssueLabel labelMap) => labelMap.name) |
| .toList(); |
| |
| /// Validate if all statuses have been successful. |
| allSuccess = validateStatuses( |
| slug, |
| prNumber, |
| prState, |
| author, |
| labelNames, |
| statuses, |
| failures, |
| allSuccess, |
| ); |
| |
| final gitHubService = await config.createGithubService(slug); |
| final sha = commit.oid; |
| |
| final checkRuns = <github.CheckRun>[]; |
| if (messagePullRequest.head != null && sha != null) { |
| checkRuns.addAll(await gitHubService.getCheckRuns(slug, sha)); |
| } |
| |
| /// Validate if all checkRuns have succeeded. |
| allSuccess = validateCheckRuns( |
| slug, |
| prNumber, |
| prState, |
| checkRuns, |
| failures, |
| allSuccess, |
| author, |
| ); |
| |
| if (!allSuccess && failures.isEmpty) { |
| return ValidationResult(allSuccess, Action.IGNORE_TEMPORARILY, ''); |
| } |
| |
| final buffer = StringBuffer(); |
| if (failures.isNotEmpty) { |
| for (var detail in failures) { |
| buffer.writeln( |
| '- The status or check suite ${detail.markdownLink} has failed. Please fix the ' |
| 'issues identified (or deflake) before re-applying this label.', |
| ); |
| } |
| } |
| final action = labelNames.contains(Config.kEmergencyLabel) |
| ? Action.IGNORE_FAILURE |
| : Action.REMOVE_LABEL; |
| return ValidationResult(allSuccess, action, buffer.toString()); |
| } |
| |
| /// Return true if the tree status check has been reported, or if doesn't have |
| /// to be reported. |
| /// |
| /// Tree status is pushed by [PushBuildStatusToGithub], which is asynchronous |
| /// relative to the creation of the PR. At the time the CI status is being |
| /// checked, the tree status may not have been reported yet. |
| /// |
| /// If a repo has a tree status, we should wait for it to show up instead of posting |
| /// a failure to GitHub pull request. |
| /// |
| /// If a repo doesn't have a tree status, simply return `true`. |
| bool isTreeStatusReporting( |
| github.RepositorySlug slug, |
| int prNumber, |
| List<ContextNode> statuses, |
| ) { |
| var treeStatusValid = false; |
| if (!Config.reposWithTreeStatus.contains(slug)) { |
| return true; |
| } |
| if (statuses.isEmpty) { |
| return false; |
| } |
| const treeStatusName = 'tree-status'; |
| log.info( |
| '${slug.fullName}/$prNumber: Validating tree status for ${slug.name}/tree-status, statuses: $statuses', |
| ); |
| |
| /// Scan list of statuses to see if the tree status exists (this list is expected to be <5 items) |
| for (var status in statuses) { |
| if (status.context == treeStatusName) { |
| // Does only one tree status need to be set for the condition? |
| treeStatusValid = true; |
| } |
| } |
| return treeStatusValid; |
| } |
| |
| /// Validate the ci build test run statuses to see which have succeeded and |
| /// which have failed. |
| /// |
| /// Failures will be added the set of overall failures. |
| /// Returns allSuccess unmodified if there were no failures, false otherwise. |
| bool validateStatuses( |
| github.RepositorySlug slug, |
| int prNumber, |
| PullRequestState prState, |
| Author author, |
| List<String> labelNames, |
| List<ContextNode> statuses, |
| Set<FailureDetail> failures, |
| bool allSuccess, |
| ) { |
| log.info('Validating name: ${slug.name}/$prNumber, statuses: $statuses'); |
| |
| final staleStatuses = <ContextNode>[]; |
| for (var status in statuses) { |
| // How can name be null but presumed to not be null below when added to failure? |
| final name = status.context; |
| |
| if (status.state != STATUS_SUCCESS) { |
| if (notInAuthorsControl.contains(name) && |
| labelNames.contains(Config.kEmergencyLabel)) { |
| continue; |
| } |
| allSuccess = false; |
| if (status.state == STATUS_FAILURE && |
| !notInAuthorsControl.contains(name)) { |
| failures.add(FailureDetail(name!, status.targetUrl!)); |
| } |
| if (status.state == STATUS_PENDING && |
| prState == PullRequestState.open && |
| status.createdAt != null && |
| isStale(status.createdAt!) && |
| supportStale(author, slug)) { |
| staleStatuses.add(status); |
| } |
| } |
| } |
| if (staleStatuses.isNotEmpty) { |
| log.warn( |
| 'Pull request https://github.com/${slug.fullName}/pull/$prNumber from ${slug.name} repo auto roller has been running over ${Config.kGitHubCheckStaleThreshold} hours due to: ${staleStatuses.map((e) => e.context).toList()}', |
| ); |
| } |
| |
| return allSuccess; |
| } |
| |
| /// Validate the checkRuns to see if all have completed successfully or not. |
| /// |
| /// Failures will be added the set of overall failures. |
| /// Returns allSuccess unmodified if there were no failures, false otherwise. |
| bool validateCheckRuns( |
| github.RepositorySlug slug, |
| int prNumber, |
| PullRequestState prState, |
| List<github.CheckRun> checkRuns, |
| Set<FailureDetail> failures, |
| bool allSuccess, |
| Author author, |
| ) { |
| log.info('Validating name: ${slug.name}/$prNumber, checkRuns: $checkRuns'); |
| |
| final staleCheckRuns = <github.CheckRun>[]; |
| for (var checkRun in checkRuns) { |
| final name = checkRun.name; |
| |
| if (checkRun.name == Config.kMergeQueueLockName) { |
| // Merge Queue Guard is not used to determine the status of CI. |
| continue; |
| } |
| |
| if (checkRun.conclusion == github.CheckRunConclusion.skipped || |
| checkRun.conclusion == github.CheckRunConclusion.success || |
| (checkRun.status == github.CheckRunStatus.completed && |
| checkRun.conclusion == github.CheckRunConclusion.neutral)) { |
| // checkrun has passed. |
| continue; |
| } else if (checkRun.status == github.CheckRunStatus.completed) { |
| // checkrun has failed. |
| log.info('${slug.name}/$prNumber: CheckRun $name failed.'); |
| failures.add(FailureDetail(name!, checkRun.detailsUrl as String)); |
| } else if (checkRun.status == github.CheckRunStatus.queued) { |
| if (prState == PullRequestState.open && |
| isStale(checkRun.startedAt) && |
| supportStale(author, slug)) { |
| staleCheckRuns.add(checkRun); |
| } |
| } |
| allSuccess = false; |
| } |
| if (staleCheckRuns.isNotEmpty) { |
| log.warn( |
| 'Pull request https://github.com/${slug.fullName}/pull/$prNumber from ${slug.name} repo auto roller has been running over ${Config.kGitHubCheckStaleThreshold} hours due to: ${staleCheckRuns.map((e) => e.name).toList()}', |
| ); |
| } |
| |
| return allSuccess; |
| } |
| |
| // Treat any GitHub check run as stale if created over [Config.kGitHubCheckStaleThreshold] hours ago. |
| bool isStale(DateTime dateTime) { |
| return dateTime.compareTo( |
| DateTime.now().subtract( |
| const Duration(hours: Config.kGitHubCheckStaleThreshold), |
| ), |
| ) < |
| 0; |
| } |
| |
| /// Perform stale check only on Engine related rolled PRs. |
| /// |
| /// This includes those rolled PRs from upstream to Engine repo and those |
| /// rolled PRs from Engine to Framework. |
| bool supportStale(Author author, github.RepositorySlug slug) { |
| return isEngineToFrameworkRoller(author, slug); |
| } |
| |
| bool isEngineToFrameworkRoller(Author author, github.RepositorySlug slug) { |
| return author.login! == 'engine-flutter-autoroll' && |
| slug == Config.flutterSlug; |
| } |
| } |