blob: c6974bf9b862a71eacdee794b2c1008a7686db99 [file] [log] [blame]
// 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;
}
}