blob: 43f4fd148ecc48926390f8487cc0ed6f8f50d914 [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:auto_submit/exception/bigquery_exception.dart';
import 'package:auto_submit/model/big_query_pull_request_record.dart';
import 'package:auto_submit/model/big_query_revert_request_record.dart';
import 'package:auto_submit/model/pull_request_change_type.dart';
import 'dart:async';
import 'package:auto_submit/service/bigquery.dart';
import 'package:auto_submit/service/config.dart';
import 'package:auto_submit/service/github_service.dart';
import 'package:auto_submit/service/graphql_service.dart';
import 'package:auto_submit/service/log.dart';
import 'package:auto_submit/service/process_method.dart';
import 'package:auto_submit/service/revert_review_template.dart';
import 'package:auto_submit/validations/ci_successful.dart';
import 'package:auto_submit/validations/revert.dart';
import 'package:auto_submit/validations/unknown_mergeable.dart';
import 'package:github/github.dart' as github;
import 'package:graphql/client.dart' as graphql;
import 'package:retry/retry.dart';
import '../exception/retryable_exception.dart';
import '../model/auto_submit_query_result.dart';
import '../request_handling/pubsub.dart';
import '../validations/approval.dart';
import '../validations/change_requested.dart';
import '../validations/conflicting.dart';
import '../validations/empty_checks.dart';
import '../validations/validation.dart';
import 'approver_service.dart';
/// Provides an extensible and standardized way to validate different aspects of
/// a commit to ensure it is ready to land, it has been reviewed, and it has been
/// tested. The expectation is that the list of validation will grow overtime.
class ValidationService {
ValidationService(this.config, {RetryOptions? retryOptions})
: retryOptions = retryOptions ?? Config.mergeRetryOptions {
/// Validates a PR marked with the reverts label.
revertValidation = Revert(config: config);
approverService = ApproverService(config);
validations.addAll({
/// Validates the PR has been approved following the codereview guidelines.
Approval(config: config),
/// Validates all the tests ran and where successful.
CiSuccessful(config: config),
/// Validates there are no pending change requests.
ChangeRequested(config: config),
/// Validates that the list of checks is not empty.
EmptyChecks(config: config),
/// Validates the PR state is in a well known state.
UnknownMergeable(config: config),
/// Validates the PR is conflict free.
Conflicting(config: config),
});
}
Revert? revertValidation;
ApproverService? approverService;
final Config config;
final Set<Validation> validations = <Validation>{};
final RetryOptions retryOptions;
/// Processes a pub/sub message associated with PullRequest event.
Future<void> processMessage(github.PullRequest messagePullRequest, String ackId, PubSub pubsub) async {
final ProcessMethod processMethod = await processPullRequestMethod(messagePullRequest);
switch (processMethod) {
case ProcessMethod.processAutosubmit:
await processPullRequest(
config: config,
result: await getNewestPullRequestInfo(config, messagePullRequest),
messagePullRequest: messagePullRequest,
ackId: ackId,
pubsub: pubsub,
);
break;
case ProcessMethod.processRevert:
await processRevertRequest(
config: config,
result: await getNewestPullRequestInfo(config, messagePullRequest),
messagePullRequest: messagePullRequest,
ackId: ackId,
pubsub: pubsub,
);
break;
case ProcessMethod.doNotProcess:
log.info('Should not process ${messagePullRequest.toJson()}, and ack the message.');
await pubsub.acknowledge('auto-submit-queue-sub', ackId);
break;
}
}
/// Fetch the most up to date info for the current pull request from github.
Future<QueryResult> getNewestPullRequestInfo(Config config, github.PullRequest pullRequest) async {
final github.RepositorySlug slug = pullRequest.base!.repo!.slug();
final graphql.GraphQLClient graphQLClient = await config.createGitHubGraphQLClient(slug);
final int? prNumber = pullRequest.number;
final GraphQlService graphQlService = GraphQlService();
final Map<String, dynamic> data = await graphQlService.queryGraphQL(
slug,
prNumber!,
graphQLClient,
);
return QueryResult.fromJson(data);
}
/// Checks if a pullRequest is still open and with autosubmit label before trying to process it.
Future<ProcessMethod> processPullRequestMethod(github.PullRequest pullRequest) async {
final github.RepositorySlug slug = pullRequest.base!.repo!.slug();
final GithubService githubService = await config.createGithubService(slug);
final github.PullRequest currentPullRequest = await githubService.getPullRequest(slug, pullRequest.number!);
final List<String> labelNames = (currentPullRequest.labels as List<github.IssueLabel>)
.map<String>((github.IssueLabel labelMap) => labelMap.name)
.toList();
if (currentPullRequest.state == 'open' && labelNames.contains(Config.kRevertLabel)) {
return ProcessMethod.processRevert;
} else if (currentPullRequest.state == 'open' && labelNames.contains(Config.kAutosubmitLabel)) {
return ProcessMethod.processAutosubmit;
} else {
return ProcessMethod.doNotProcess;
}
}
/// Processes a PullRequest running several validations to decide whether to
/// land the commit or remove the autosubmit label.
Future<void> processPullRequest({
required Config config,
required QueryResult result,
required github.PullRequest messagePullRequest,
required String ackId,
required PubSub pubsub,
}) async {
final List<ValidationResult> results = <ValidationResult>[];
/// Runs all the validation defined in the service.
for (Validation validation in validations) {
final ValidationResult validationResult = await validation.validate(result, messagePullRequest);
results.add(validationResult);
}
final github.RepositorySlug slug = messagePullRequest.base!.repo!.slug();
final GithubService githubService = await config.createGithubService(slug);
/// If there is at least one action that requires to remove label do so and add comments for all the failures.
bool shouldReturn = false;
final int prNumber = messagePullRequest.number!;
for (ValidationResult result in results) {
if (!result.result && result.action == Action.REMOVE_LABEL) {
final String commmentMessage = result.message.isEmpty ? 'Validations Fail.' : result.message;
final String message = 'auto label is removed for ${slug.fullName}, pr: $prNumber, due to $commmentMessage';
await removeLabelAndComment(
githubService: githubService,
repositorySlug: slug,
prNumber: prNumber,
prLabel: Config.kAutosubmitLabel,
message: message,
);
log.info(message);
shouldReturn = true;
}
}
if (shouldReturn) {
log.info('The pr ${slug.fullName}/$prNumber with message: $ackId should be acknowledged.');
await pubsub.acknowledge('auto-submit-queue-sub', ackId);
log.info('The pr ${slug.fullName}/$prNumber is not feasible for merge and message: $ackId is acknowledged.');
return;
}
// If PR has some failures to ignore temporarily do nothing and continue.
for (ValidationResult result in results) {
if (!result.result && result.action == Action.IGNORE_TEMPORARILY) {
return;
}
}
// If we got to this point it means we are ready to submit the PR.
final ProcessMergeResult processed = await processMerge(
config: config,
queryResult: result,
messagePullRequest: messagePullRequest,
);
if (!processed.result) {
final String message = 'auto label is removed for ${slug.fullName}, pr: $prNumber, ${processed.message}.';
await removeLabelAndComment(
githubService: githubService,
repositorySlug: slug,
prNumber: prNumber,
prLabel: Config.kAutosubmitLabel,
message: message,
);
log.info(message);
} else {
log.info('Pull Request ${slug.fullName}#$prNumber was merged successfully!');
log.info('Attempting to insert a pull request record into the database for $prNumber');
await insertPullRequestRecord(
config: config,
pullRequest: messagePullRequest,
pullRequestType: PullRequestChangeType.change,
);
}
log.info('Ack the processed message : $ackId.');
await pubsub.acknowledge('auto-submit-queue-sub', ackId);
}
/// The logic for processing a revert request and opening the follow up
/// review issue in github.
Future<void> processRevertRequest({
required Config config,
required QueryResult result,
required github.PullRequest messagePullRequest,
required String ackId,
required PubSub pubsub,
}) async {
final ValidationResult revertValidationResult = await revertValidation!.validate(result, messagePullRequest);
final github.RepositorySlug slug = messagePullRequest.base!.repo!.slug();
final int prNumber = messagePullRequest.number!;
final GithubService githubService = await config.createGithubService(slug);
if (revertValidationResult.result) {
// Approve the pull request automatically as it has been validated.
await approverService!.revertApproval(result, messagePullRequest);
final ProcessMergeResult processed = await processMerge(
config: config,
queryResult: result,
messagePullRequest: messagePullRequest,
);
if (processed.result) {
log.info('Revert request ${slug.fullName}#$prNumber was merged successfully.');
try {
final RevertReviewTemplate revertReviewTemplate = RevertReviewTemplate(
repositorySlug: slug.fullName,
revertPrNumber: prNumber,
revertPrAuthor: result.repository!.pullRequest!.author!.login!,
originalPrLink: revertValidation!.extractLinkFromText(messagePullRequest.body)!,
);
final github.Issue issue = await githubService.createIssue(
// Created issues are created and tracked within flutter/flutter.
slug: github.RepositorySlug(Config.flutter, Config.flutter),
title: revertReviewTemplate.title!,
body: revertReviewTemplate.body!,
labels: <String>['P1'],
assignee: result.repository!.pullRequest!.author!.login!,
);
log.info('Issue #${issue.id} was created to track the review for pr# $prNumber in ${slug.fullName}');
log.info('Attempting to insert a revert pull request record into the database for pr# $prNumber');
await insertPullRequestRecord(
config: config,
pullRequest: messagePullRequest,
pullRequestType: PullRequestChangeType.revert,
);
log.info('Attempting to insert a revert tracking request record into the database for pr# $prNumber');
await insertRevertRequestRecord(
config: config,
revertPullRequest: messagePullRequest,
reviewIssue: issue,
);
} on github.GitHubError catch (exception) {
// We have merged but failed to create follow up issue.
final String errorMessage = '''
An exception has occurred while attempting to create the follow up review issue for pr# $prNumber.
Please create a follow up issue to track a review for this pull request.
Exception: ${exception.message}
''';
log.warning(errorMessage);
await githubService.createComment(slug, prNumber, errorMessage);
}
} else {
final String message = 'revert label is removed for ${slug.fullName}, pr#: $prNumber, ${processed.message}.';
await removeLabelAndComment(
githubService: githubService,
repositorySlug: slug,
prNumber: prNumber,
prLabel: Config.kRevertLabel,
message: message,
);
log.info(message);
}
} else if (!revertValidationResult.result && revertValidationResult.action == Action.IGNORE_TEMPORARILY) {
// if required check runs have not completed process again.
log.info('Some of the required checks have not completed. Requeueing.');
return;
} else {
// since we do not temporarily ignore anything with a revert request we
// know we will report the error and remove the label.
final String commentMessage =
revertValidationResult.message.isEmpty ? 'Validations Fail.' : revertValidationResult.message;
await removeLabelAndComment(
githubService: githubService,
repositorySlug: slug,
prNumber: prNumber,
prLabel: Config.kRevertLabel,
message: commentMessage,
);
log.info('revert label is removed for ${slug.fullName}, pr: $prNumber, due to $commentMessage');
log.info('The pr ${slug.fullName}/$prNumber is not feasible for merge and message: $ackId is acknowledged.');
}
log.info('Ack the processed message : $ackId.');
await pubsub.acknowledge('auto-submit-queue-sub', ackId);
}
/// Merges the commit if the PullRequest passes all the validations.
Future<ProcessMergeResult> processMerge({
required Config config,
required QueryResult queryResult,
required github.PullRequest messagePullRequest,
}) async {
final github.RepositorySlug slug = messagePullRequest.base!.repo!.slug();
final int number = messagePullRequest.number!;
try {
github.PullRequestMerge? result;
final github.MergeMethod mergeMethod = await checkForMergeMethod(
slug: slug,
prNumber: number,
);
await retryOptions.retry(
() async {
result = await _processMergeInternal(
config: config,
slug: slug,
number: number,
mergeMethod: mergeMethod,
);
},
retryIf: (Exception e) => e is RetryableException,
);
if (result != null && !result!.merged!) {
final String message = 'Failed to merge pr#: $number with ${result!.message}';
log.severe(message);
return ProcessMergeResult(false, message);
}
} catch (e) {
// Catch graphql client init exceptions.
final String message = 'Failed to merge pr#: $number with ${e.toString()}';
log.severe(message);
return ProcessMergeResult(false, message);
}
return ProcessMergeResult.noMessage(true);
}
/// A user can provide an alternate merge method via comment by providing the
/// string @autosubmit:<merge_method>.
///
/// This function will look for the most recent occurence of the regex in the
/// pull request comments. If no comment is found with the @autosubmit string
/// or the string does not contain a supported method the default method
/// returned will be 'squash'.
static final RegExp regExpMergeMethod = RegExp(r'@autosubmit:(merge|squash|rebase)', caseSensitive: false);
Future<github.MergeMethod> checkForMergeMethod({
required github.RepositorySlug slug,
required int prNumber,
}) async {
const github.MergeMethod defaultMergeMethod = github.MergeMethod.squash;
final GithubService githubService = await config.createGithubService(slug);
final List<github.IssueComment> issueComments = await githubService.listIssueComments(slug, prNumber);
if (issueComments.isEmpty) {
return defaultMergeMethod;
}
final List<github.MergeMethod> foundMethods = [];
for (github.IssueComment comment in issueComments) {
// If comment author is not a MEMBER or OWNER we ignore this.
if (comment.authorAssociation == null ||
comment.authorAssociation != 'MEMBER' && comment.authorAssociation != 'OWNER') {
continue;
}
final String? commentBody = comment.body;
if (commentBody == null || commentBody.isEmpty) {
continue;
}
// detect all matches of the pattern in the comment and use the last one.
final Iterable<Match> allMatches = regExpMergeMethod.allMatches(commentBody);
if (allMatches.isEmpty) {
continue;
}
final Match foundMatch = allMatches.last;
if (foundMatch[0] != null) {
final String value = foundMatch.group(1)!.toLowerCase();
foundMethods.add(github.MergeMethod.values.byName(value));
}
}
// Return the last match found, this is the most recent match at the time
// collected.
return foundMethods.isEmpty ? defaultMergeMethod : foundMethods.last;
}
/// Remove a pull request label and add a comment to the pull request.
Future<void> removeLabelAndComment({
required GithubService githubService,
required github.RepositorySlug repositorySlug,
required int prNumber,
required String prLabel,
required String message,
}) async {
await githubService.removeLabel(repositorySlug, prNumber, prLabel);
await githubService.createComment(repositorySlug, prNumber, message);
}
/// Insert a merged pull request record into the database.
Future<void> insertPullRequestRecord({
required Config config,
required github.PullRequest pullRequest,
required PullRequestChangeType pullRequestType,
}) async {
final github.RepositorySlug slug = pullRequest.base!.repo!.slug();
final GithubService gitHubService = await config.createGithubService(slug);
// We need the updated time fields for the merged request from github.
final github.PullRequest currentPullRequest = await gitHubService.getPullRequest(slug, pullRequest.number!);
log.info('Updated pull request info: ${currentPullRequest.toString()}');
// add a record for the pull request into our metrics tracking
final PullRequestRecord pullRequestRecord = PullRequestRecord(
organization: currentPullRequest.base!.repo!.slug().owner,
repository: currentPullRequest.base!.repo!.slug().name,
author: currentPullRequest.user!.login,
prNumber: pullRequest.number!,
prCommit: currentPullRequest.head!.sha,
prRequestType: pullRequestType.name,
prCreatedTimestamp: currentPullRequest.createdAt!,
prLandedTimestamp: currentPullRequest.closedAt!,
);
log.info('Created pull request record: ${pullRequestRecord.toString()}');
try {
final BigqueryService bigqueryService = await config.createBigQueryService();
await bigqueryService.insertPullRequestRecord(
projectId: Config.flutterGcpProjectId,
pullRequestRecord: pullRequestRecord,
);
log.info('Record inserted for pull request pr# ${pullRequest.number} successfully.');
} on BigQueryException catch (exception) {
log.severe('Unable to insert pull request record due to: ${exception.toString()}');
}
}
Future<void> insertRevertRequestRecord({
required Config config,
required github.PullRequest revertPullRequest,
required github.Issue reviewIssue,
}) async {
final github.RepositorySlug slug = revertPullRequest.base!.repo!.slug();
final GithubService gitHubService = await config.createGithubService(slug);
// Get the updated revert issue.
final github.PullRequest currentPullRequest = await gitHubService.getPullRequest(slug, revertPullRequest.number!);
// Get the original pull request issue.
final String originalPullRequestLink = revertValidation!.extractLinkFromText(revertPullRequest.body)!;
final int originalPullRequestNumber = int.parse(originalPullRequestLink.split('#').elementAt(1));
// return int.parse(linkSplit.elementAt(1));
final github.PullRequest originalPullRequest = await gitHubService.getPullRequest(slug, originalPullRequestNumber);
final RevertRequestRecord revertRequestRecord = RevertRequestRecord(
organization: currentPullRequest.base!.repo!.slug().owner,
repository: currentPullRequest.base!.repo!.slug().name,
author: currentPullRequest.user!.login,
prNumber: revertPullRequest.number,
prCommit: currentPullRequest.head!.sha,
prCreatedTimestamp: currentPullRequest.createdAt,
prLandedTimestamp: currentPullRequest.closedAt,
originalPrAuthor: originalPullRequest.user!.login,
originalPrNumber: originalPullRequest.number,
originalPrCommit: originalPullRequest.head!.sha,
originalPrCreatedTimestamp: originalPullRequest.createdAt,
originalPrLandedTimestamp: originalPullRequest.closedAt,
reviewIssueAssignee: reviewIssue.assignee!.login,
reviewIssueNumber: reviewIssue.number,
reviewIssueCreatedTimestamp: reviewIssue.createdAt,
);
try {
final BigqueryService bigqueryService = await config.createBigQueryService();
await bigqueryService.insertRevertRequestRecord(
projectId: Config.flutterGcpProjectId,
revertRequestRecord: revertRequestRecord,
);
log.info('Record inserted for revert tracking request for pr# ${revertPullRequest.number} successfully.');
} on BigQueryException catch (exception) {
log.severe(exception.toString());
}
}
}
/// Small wrapper class to allow us to capture and create a comment in the PR with
/// the issue that caused the merge failure.
class ProcessMergeResult {
ProcessMergeResult.noMessage(this.result);
ProcessMergeResult(this.result, this.message);
bool result = false;
String? message;
}
/// Function signature that will be executed with retries.
typedef RetryHandler = Function();
/// Internal wrapper for the logic of merging a pull request into github.
Future<github.PullRequestMerge> _processMergeInternal({
required Config config,
required github.RepositorySlug slug,
required int number,
required github.MergeMethod mergeMethod,
String? commitMessage,
String? requestSha,
}) async {
// This is retryable so to guard against token expiration we get a fresh
// client each time.
final GithubService gitHubService = await config.createGithubService(slug);
final github.PullRequestMerge pullRequestMerge = await gitHubService.mergePullRequest(
slug,
number,
commitMessage: commitMessage,
mergeMethod: mergeMethod,
requestSha: requestSha,
);
if (pullRequestMerge.merged != true) {
throw RetryableException("Pull request could not be merged: ${pullRequestMerge.message}");
}
return pullRequestMerge;
}