| // 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:auto_submit/exception/retryable_exception.dart'; |
| import 'package:auto_submit/model/auto_submit_query_result.dart'; |
| import 'package:auto_submit/model/pull_request_data_types.dart'; |
| import 'package:auto_submit/requests/graphql_queries.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:github/github.dart' as github; |
| import 'package:retry/retry.dart'; |
| import 'package:cocoon_server/big_query_pull_request_record.dart'; |
| import 'package:cocoon_server/bigquery.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 github.RepositorySlug slug = pullRequest.base!.repo!.slug(); |
| final int? prNumber = pullRequest.number; |
| |
| final GraphQlService graphQlService = await GraphQlService.forRepo(config, slug); |
| |
| final FindPullRequestsWithReviewsQuery findPullRequestsWithReviewsQuery = FindPullRequestsWithReviewsQuery( |
| repositoryOwner: slug.owner, |
| repositoryName: slug.name, |
| pullRequestNumber: prNumber!, |
| ); |
| |
| final Map<String, dynamic> data = await graphQlService.queryGraphQL( |
| documentNode: findPullRequestsWithReviewsQuery.documentNode, |
| variables: findPullRequestsWithReviewsQuery.variables, |
| ); |
| |
| return QueryResult.fromJson(data); |
| } |
| |
| Future<(github.PullRequest, List<String>)> getPrWithLabels(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(); |
| return (currentPullRequest, labelNames); |
| } |
| |
| /// Merges the commit if the PullRequest passes all the validations. |
| Future<MergeResult> submitPullRequest({ |
| required Config config, |
| required github.PullRequest messagePullRequest, |
| }) async { |
| final github.RepositorySlug slug = messagePullRequest.base!.repo!.slug(); |
| final int number = messagePullRequest.number!; |
| |
| // Pass an explicit commit message from the PR title otherwise the GitHub API will use the first commit message. |
| const String revertPattern = 'Revert "Revert'; |
| String messagePrefix = ''; |
| |
| if (messagePullRequest.title!.contains(revertPattern)) { |
| // Cleanup auto-generated revert messages. |
| messagePrefix = ''' |
| ${messagePullRequest.title!.replaceFirst('Revert "Revert', 'Reland')} |
| |
| '''; |
| } |
| |
| final String prBody = _sanitizePrBody(messagePullRequest.body ?? ''); |
| final String commitMessage = '$messagePrefix$prBody'; |
| |
| if (messagePullRequest.isMergeQueueEnabled) { |
| return _enqueuePullRequest(slug, messagePullRequest); |
| } 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 == 'emergency').isNotEmpty ?? false; |
| |
| try { |
| await retryOptions.retry( |
| () async { |
| await graphQlService.enqueuePullRequest(slug, restPullRequest.number!, isEmergencyPullRequest); |
| }, |
| retryIf: (Exception e) => e is RetryableException, |
| ); |
| } catch (e) { |
| final message = 'Failed to enqueue ${slug.fullName}/${restPullRequest.number} with $e'; |
| log.severe(message); |
| 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, |
| // TODO(ricardoamador): make this configurable per repository, https://github.com/flutter/flutter/issues/114557 |
| mergeMethod: github.MergeMethod.squash, |
| ); |
| }, |
| retryIf: (Exception e) => e is RetryableException, |
| ); |
| |
| final bool merged = result?.merged ?? false; |
| if (result != null && !merged) { |
| final String message = 'Failed to merge ${slug.fullName}/$number with ${result?.message}'; |
| log.severe(message); |
| return (result: false, message: message, method: SubmitMethod.merge); |
| } |
| } catch (e) { |
| // Catch graphql client init exceptions. |
| final String message = 'Failed to merge ${slug.fullName}/$number with $e'; |
| log.severe(message); |
| 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 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 for ${slug.fullName}/${pullRequest.number}'); |
| |
| // 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 ${slug.fullName}/${pullRequest.number} successfully.'); |
| } on BigQueryException catch (exception) { |
| log.severe( |
| 'Unable to insert pull request record for pull request ${slug.fullName}/${pullRequest.number} due to: ${exception.toString()}', |
| ); |
| } |
| } |
| } |
| |
| /// 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 = 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 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 ${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(); |
| bool 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/flaux', |
| '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 slug = base!.repo!.slug(); |
| return mqEnabledRepos.contains(slug.fullName); |
| } |
| } |