blob: 727006206b50aed68e8e73cf7c17e0dc55543642 [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/exception/retryable_merge_exception.dart';
import 'package:auto_submit/requests/check_pull_request_queries.dart';
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 '../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 ??
const RetryOptions(
delayFactor: Duration(milliseconds: Config.backOfMultiplier),
maxDelay: Duration(seconds: Config.maxDelaySeconds),
maxAttempts: Config.backoffAttempts,
) {
/// 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 {
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('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) {
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 {
// 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 {
// The createGitHubGraphQLClient can throw Exception on github permissions
// errors.
final graphql.GraphQLClient client = await config.createGitHubGraphQLClient(slug);
graphql.QueryResult? result;
await _runProcessMergeWithRetries(
() async {
result = await _processMergeInternal(
client: client,
config: config,
queryResult: queryResult,
messagePullRequest: messagePullRequest,
);
},
retryOptions,
);
if (result != null && result!.hasException) {
final String message = 'Failed to merge pr#: $number with ${result!.exception}';
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);
}
/// 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
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 {
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.
String originalPullRequestLink = revertValidation!.extractLinkFromText(revertPullRequest.body)!;
int originalPullRequestNumber = int.parse(originalPullRequestLink.split('#').elementAt(1));
// return int.parse(linkSplit.elementAt(1));
final github.PullRequest originalPullRequest = await gitHubService.getPullRequest(slug, originalPullRequestNumber);
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 {
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();
/// Runs the internal processMerge with retries.
Future<void> _runProcessMergeWithRetries(RetryHandler retryHandler, RetryOptions retryOptions) {
return retryOptions.retry(
retryHandler,
retryIf: (Exception e) => e is RetryableMergeException,
);
}
/// Internal wrapper for the logic of merging a pull request into github.
Future<graphql.QueryResult> _processMergeInternal({
required graphql.GraphQLClient client,
required Config config,
required QueryResult queryResult,
required github.PullRequest messagePullRequest,
}) async {
final String id = queryResult.repository!.pullRequest!.id!;
final PullRequest pullRequest = queryResult.repository!.pullRequest!;
final Commit commit = pullRequest.commits!.nodes!.single.commit!;
final String? sha = commit.oid;
final int number = messagePullRequest.number!;
final graphql.QueryResult result = await client.mutate(
graphql.MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': id,
'oid': sha,
'title': '${queryResult.repository!.pullRequest!.title} (#$number)',
},
),
);
// We have to make this check because mutate does not explicitely throw an
// exception, rather it wraps any exceptions encountered.
if (result.hasException) {
if (result.exception!.graphqlErrors.length == 1 &&
result.exception!.graphqlErrors.first.message
.contains('Base branch was modified. Review and try the merge again')) {
// This exception will bubble up if retries are exhausted.
throw RetryableMergeException(result.exception!.graphqlErrors.first.message, result.exception!.graphqlErrors);
}
}
return result;
}