blob: 2a06d7fb260101451314ddd7bc0816584b76b60c [file] [log] [blame]
// Copyright 2023 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/big_query_pull_request_record.dart';
import 'package:cocoon_server/bigquery.dart';
import 'package:cocoon_server/logging.dart';
import 'package:github/github.dart' as github;
import 'package:retry/retry.dart';
import '../exception/retryable_exception.dart';
import '../model/auto_submit_query_result.dart';
import '../model/pull_request_data_types.dart';
import '../requests/graphql_queries.dart';
import 'config.dart';
import 'graphql_service.dart';
/// Class containing common methods to each of the pull request type validation
/// services.
class ValidationService {
ValidationService(this.config, {RetryOptions? retryOptions})
: retryOptions = retryOptions ?? Config.mergeRetryOptions;
final Config config;
final RetryOptions retryOptions;
/// Fetch the most up to date info for the current pull request from github.
Future<QueryResult> getNewestPullRequestInfo(
Config config,
github.PullRequest pullRequest,
) async {
final slug = pullRequest.base!.repo!.slug();
final prNumber = pullRequest.number;
final graphQlService = await GraphQlService.forRepo(config, slug);
final findPullRequestsWithReviewsQuery = FindPullRequestsWithReviewsQuery(
repositoryOwner: slug.owner,
repositoryName: slug.name,
pullRequestNumber: prNumber!,
);
final data = await graphQlService.queryGraphQL(
documentNode: findPullRequestsWithReviewsQuery.documentNode,
variables: findPullRequestsWithReviewsQuery.variables,
);
return QueryResult.fromJson(data);
}
Future<github.PullRequest> getFullPullRequest(
github.RepositorySlug slug,
int pullRequestNumber,
) async {
final githubService = await config.createGithubService(slug);
return githubService.getPullRequest(slug, pullRequestNumber);
}
/// Merges the commit if the PullRequest passes all the validations.
Future<MergeResult> submitPullRequest({
required Config config,
required github.PullRequest pullRequest,
}) async {
final slug = pullRequest.base!.repo!.slug();
final number = pullRequest.number!;
// Pass an explicit commit message from the PR title otherwise the GitHub API will use the first commit message.
const revertPattern = 'Revert "Revert';
var messagePrefix = '';
if (pullRequest.title!.contains(revertPattern)) {
// Cleanup auto-generated revert messages.
messagePrefix =
'''
${pullRequest.title!.replaceFirst('Revert "Revert', 'Reland')}
''';
}
final prBody = _sanitizePrBody(pullRequest.body ?? '');
final commitMessage = '$messagePrefix$prBody';
if (pullRequest.isMergeQueueEnabled) {
return _enqueuePullRequest(slug, pullRequest);
} else {
return _mergePullRequest(number, commitMessage, slug);
}
}
Future<MergeResult> _enqueuePullRequest(
github.RepositorySlug slug,
github.PullRequest restPullRequest,
) async {
final graphQlService = await GraphQlService.forRepo(config, slug);
final isEmergencyPullRequest =
restPullRequest.labels
?.where((label) => label.name == Config.kEmergencyLabel)
.isNotEmpty ??
false;
try {
await retryOptions.retry(() async {
await graphQlService.enqueuePullRequest(
slug,
restPullRequest.number!,
isEmergencyPullRequest,
);
}, retryIf: (Exception e) => e is RetryableException);
} catch (e, s) {
final message =
'Failed to enqueue ${slug.fullName}/${restPullRequest.number} with $e';
log.error(message, e, s);
return (result: false, message: message, method: SubmitMethod.enqueue);
}
return (
result: true,
message: restPullRequest.title!,
method: SubmitMethod.enqueue,
);
}
Future<MergeResult> _mergePullRequest(
int number,
String commitMessage,
github.RepositorySlug slug,
) async {
try {
github.PullRequestMerge? result;
await retryOptions.retry(() async {
result = await _processMergeInternal(
config: config,
commitMessage: commitMessage,
slug: slug,
number: number,
mergeMethod: github.MergeMethod.squash,
);
}, retryIf: (Exception e) => e is RetryableException);
final merged = result?.merged ?? false;
if (result != null && !merged) {
final message =
'Failed to merge ${slug.fullName}/$number with ${result?.message}';
log.error(message);
return (result: false, message: message, method: SubmitMethod.merge);
}
} catch (e, s) {
// Catch graphql client init exceptions.
final message = 'Failed to merge ${slug.fullName}/$number with $e';
log.error(message, e, s);
return (result: false, message: message, method: SubmitMethod.merge);
}
return (result: true, message: commitMessage, method: SubmitMethod.merge);
}
/// Insert a merged pull request record into the database.
Future<void> insertPullRequestRecord({
required Config config,
required github.PullRequest pullRequest,
required PullRequestChangeType pullRequestType,
}) async {
final slug = pullRequest.base!.repo!.slug();
final gitHubService = await config.createGithubService(slug);
// We need the updated time fields for the merged request from github.
final currentPullRequest = await gitHubService.getPullRequest(
slug,
pullRequest.number!,
);
// add a record for the pull request into our metrics tracking
final 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!,
);
try {
final bigqueryService = await config.createBigQueryService();
await bigqueryService.insertPullRequestRecord(
projectId: Config.flutterGcpProjectId,
pullRequestRecord: pullRequestRecord,
);
} on BigQueryException catch (e) {
log.error(
'Failed to insert pull request record into BigQuery for pull request '
'${slug.fullName}/${pullRequest.number} due to',
e,
);
}
}
}
/// Method used to submit the PR for merging.
enum SubmitMethod {
/// The PR is enqueued into the merge queue, and the merge queue is responsible
/// for merging the PR.
enqueue('enqueued'),
/// The PR is immediately merged into the target branch.
///
/// This is the old method for merging PRs, used by repos where merge queues
/// are not (yet?) enabled.
merge('merged');
const SubmitMethod(this.pastTenseLabel);
/// The verb in past tense used to describe what happened to a PR when this
/// submit method was used, e.g. "merged".
final String pastTenseLabel;
}
/// Small wrapper class to allow us to capture and create a comment in the PR with
/// the issue that caused the merge failure.
typedef MergeResult = ({bool result, String message, SubmitMethod method});
/// Function signature that will be executed with retries.
typedef RetryHandler = void 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.
log.info('Attempting to merge ${slug.fullName}/$number.');
final gitHubService = await config.createGithubService(slug);
final pullRequestMerge = await gitHubService.mergePullRequest(
slug,
number,
commitMessage: commitMessage,
mergeMethod: mergeMethod,
requestSha: requestSha,
);
if (pullRequestMerge.merged != true) {
throw RetryableException(
'Pull request ${slug.fullName}/$number could not be merged: ${pullRequestMerge.message}',
);
}
return pullRequestMerge;
}
final RegExp _kCheckboxPattern = RegExp(r'^\s*-[ ]?\[( |x|X)\]');
final RegExp _kCommentPattern = RegExp(r'<!--.*-->');
final RegExp _kMarkdownLinkRefDef = RegExp(r'^\[[\w\/ -]+\]:');
final RegExp _kPreLaunchHeader = RegExp(r'## Pre-launch Checklist');
final RegExp _kDiscordPattern = RegExp(r'#hackers-new');
String _sanitizePrBody(String rawPrBody) {
final buffer = StringBuffer();
var lastLineWasEmpty = false;
for (final line in rawPrBody.split('\n')) {
if (_kCheckboxPattern.hasMatch(line) ||
_kCommentPattern.hasMatch(line) ||
_kMarkdownLinkRefDef.hasMatch(line) ||
_kPreLaunchHeader.hasMatch(line) ||
_kDiscordPattern.hasMatch(line)) {
continue;
}
if (line.trim().isEmpty) {
// we don't need to include multiple empty lines
if (lastLineWasEmpty) {
continue;
}
lastLineWasEmpty = true;
} else {
lastLineWasEmpty = false;
}
buffer.writeln(line);
}
return buffer.toString().trim();
}
/// Repos that use MQ-based workflow.
///
/// This variable is read-write to allow tests to choose which repos they want
/// to test in which mode.
List<String> mqEnabledRepos = const <String>['flutter/flutter'];
/// Convenience extension so one can just do `pullRequest.isMergeQueueEnabled`.
extension PullRequestExtension on github.PullRequest {
/// Whether this pull requests must be merged via a merge queue.
bool get isMergeQueueEnabled {
final baseRef = base!.ref;
if (baseRef != 'main' && baseRef != 'master') {
// MQ is only enabled for main and master branches.
return false;
}
final slug = base!.repo!.slug();
return mqEnabledRepos.contains(slug.fullName);
}
/// Extracts label names from the `IssueLabel` list [labels].
List<String> get labelNames {
final labels = this.labels;
if (labels == null) {
return const <String>[];
}
return labels.map<String>((label) => label.name).toList();
}
}