| // 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(); |
| } |
| } |