blob: 8c05af49b654f67d9e78dc587b7ecccd84fd0ebd [file] [log] [blame]
// Copyright 2019 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 'dart:convert';
import 'package:github/github.dart';
import 'package:github/hooks.dart';
import 'package:meta/meta.dart';
import '../../../protos.dart' as pb;
import '../../model/github/checks.dart' as cocoon_checks;
import '../../request_handling/body.dart';
import '../../request_handling/exceptions.dart';
import '../../request_handling/subscription_handler.dart';
import '../../service/branch_service.dart';
import '../../service/config.dart';
import '../../service/datastore.dart';
import '../../service/github_checks_service.dart';
import '../../service/logging.dart';
import '../../service/scheduler.dart';
// Filenames which are not actually tests.
const List<String> kNotActuallyATest = <String>[
'packages/flutter/lib/src/gestures/hit_test.dart',
];
/// List of repos that require check for labels and tests.
const Set<String> kNeedsCheckLabelsAndTests = <String>{
'flutter/engine',
'flutter/flutter',
'flutter/packages',
'flutter/plugins',
};
final RegExp kEngineTestRegExp = RegExp(r'(tests?|benchmarks?)\.(dart|java|mm|m|cc)$');
final List<String> kNeedsTestsLabels = <String>['needs tests'];
/// Subscription for processing GitHub webhooks.
///
/// The PubSub subscription is set up here:
/// https://cloud.google.com/cloudpubsub/subscription/detail/github-webhooks-sub?project=flutter-dashboard&tab=overview
///
/// This endpoint enables Cocoon to recover from outages.
///
/// This endpoint takes in a POST request with the GitHub event JSON.
// TODO(chillers): There's potential now to split this into seprate subscriptions
// for various activities (such as infra vs releases). This would mitigate
// breakages across Cocoon.
@immutable
class GithubWebhookSubscription extends SubscriptionHandler {
/// Creates a subscription for processing GitHub webhooks.
const GithubWebhookSubscription({
required super.cache,
required super.config,
required this.scheduler,
this.githubChecksService,
this.datastoreProvider = DatastoreService.defaultProvider,
required this.branchService,
super.authProvider,
}) : super(topicName: 'github-webhooks');
/// Cocoon scheduler to trigger tasks against changes from GitHub.
final Scheduler scheduler;
/// To provide build status updates to GitHub pull requests.
final GithubChecksService? githubChecksService;
final DatastoreServiceProvider datastoreProvider;
final BranchService branchService;
@override
Future<Body> post() async {
if (message.data == null || message.data!.isEmpty) {
log.warning('GitHub webhook message was empty. No-oping');
return Body.empty;
}
final pb.GithubWebhookMessage webhook = pb.GithubWebhookMessage.fromJson(message.data!);
log.fine('Processing ${webhook.event}');
log.finest(webhook.payload);
switch (webhook.event) {
case 'pull_request':
await _handlePullRequest(webhook.payload);
break;
case 'check_run':
final Map<String, dynamic> event = jsonDecode(webhook.payload) as Map<String, dynamic>;
final cocoon_checks.CheckRunEvent checkRunEvent = cocoon_checks.CheckRunEvent.fromJson(event);
if (await scheduler.processCheckRun(checkRunEvent) == false) {
throw InternalServerError('Failed to process $checkRunEvent');
}
break;
case 'create':
final CreateEvent createEvent = CreateEvent.fromJson(json.decode(webhook.payload) as Map<String, dynamic>);
await branchService.handleCreateRequest(createEvent);
if (createEvent.repository?.slug() == Config.flutterSlug) {
await branchService.branchFlutterRecipes(createEvent.ref!);
}
break;
}
return Body.empty;
}
Future<void> _handlePullRequest(
String rawRequest,
) async {
final PullRequestEvent? pullRequestEvent = await _getPullRequestEvent(rawRequest);
if (pullRequestEvent == null) {
throw const BadRequestException('Expected pull request event.');
}
final String? eventAction = pullRequestEvent.action;
final PullRequest pr = pullRequestEvent.pullRequest!;
// See the API reference:
// https://developer.github.com/v3/activity/events/types/#pullrequestevent
// which unfortunately is a bit light on explanations.
log.fine('Processing $eventAction for ${pr.htmlUrl}');
switch (eventAction) {
case 'closed':
// If it was closed without merging, cancel any outstanding tryjobs.
// We'll leave unfinished jobs if it was merged since we care about those
// results.
if (!pr.merged!) {
await scheduler.cancelPreSubmitTargets(pullRequest: pr, reason: 'Pull request closed');
} else {
// Merged pull requests can be added to CI.
await scheduler.addPullRequest(pr);
}
break;
case 'edited':
// Editing a PR should not trigger new jobs, but may update whether
// it has tests.
await _checkForLabelsAndTests(pullRequestEvent);
break;
case 'opened':
case 'reopened':
// These cases should trigger LUCI jobs.
await _checkForLabelsAndTests(pullRequestEvent);
await _scheduleIfMergeable(pullRequestEvent);
await _tryReleaseApproval(pullRequestEvent);
break;
case 'labeled':
break;
case 'synchronize':
// This indicates the PR has new commits. We need to cancel old jobs
// and schedule new ones.
await _scheduleIfMergeable(pullRequestEvent);
break;
// Ignore the rest of the events.
case 'ready_for_review':
case 'unlabeled':
case 'assigned':
case 'locked':
case 'review_request_removed':
case 'review_requested':
case 'unassigned':
case 'unlocked':
break;
}
}
/// This method assumes that jobs should be cancelled if they are already
/// runnning.
Future<void> _scheduleIfMergeable(
PullRequestEvent pullRequestEvent,
) async {
final PullRequest pr = pullRequestEvent.pullRequest!;
final RepositorySlug slug = pullRequestEvent.repository!.slug();
log.info(
'Scheduling tasks if mergeable(${pr.mergeable}): owner=${slug.owner} repo=${slug.name} and pr=${pr.number}');
// The mergeable flag may be null. False indicates there's a merge conflict,
// null indicates unknown. Err on the side of allowing the job to run.
if (pr.mergeable == false) {
final RepositorySlug slug = pullRequestEvent.repository!.slug();
final GitHub gitHubClient = await config.createGitHubClient(pullRequest: pr);
final String body = config.mergeConflictPullRequestMessage;
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.issues.createComment(slug, pr.number!, body);
}
return;
}
await scheduler.triggerPresubmitTargets(pullRequest: pr);
}
/// Release tooling generates cherrypick pull requests that should be granted an approval.
Future<void> _tryReleaseApproval(
PullRequestEvent pullRequestEvent,
) async {
final PullRequest pr = pullRequestEvent.pullRequest!;
final RepositorySlug slug = pullRequestEvent.repository!.slug();
final String defaultBranch = Config.defaultBranch(slug);
final String? branch = pr.base?.ref;
if (branch == null || branch.contains(defaultBranch)) {
// This isn't a release branch PR
return;
}
final List<String> releaseAccounts = await config.releaseAccounts;
if (releaseAccounts.contains(pr.user?.login) == false) {
// The author isn't in the list of release accounts, do nothing
return;
}
final GitHub gitHubClient = config.createGitHubClientWithToken(await config.githubOAuthToken);
final CreatePullRequestReview review = CreatePullRequestReview(slug.owner, slug.name, pr.number!, 'APPROVE');
await gitHubClient.pullRequests.createReview(slug, review);
}
Future<void> _checkForLabelsAndTests(PullRequestEvent pullRequestEvent) async {
final PullRequest pr = pullRequestEvent.pullRequest!;
final String? eventAction = pullRequestEvent.action;
final String repo = pr.base!.repo!.fullName.toLowerCase();
if (kNeedsCheckLabelsAndTests.contains(repo)) {
final GitHub gitHubClient = await config.createGitHubClient(pullRequest: pr);
try {
await _validateRefs(gitHubClient, pr);
if (repo == 'flutter/flutter') {
await _applyFrameworkRepoLabels(gitHubClient, eventAction, pr);
} else if (repo == 'flutter/engine') {
await _applyEngineRepoLabels(gitHubClient, eventAction, pr);
} else if (repo == 'flutter/plugins' || repo == 'flutter/packages') {
await _applyPackageTestChecks(gitHubClient, eventAction, pr);
}
} finally {
gitHubClient.dispose();
}
}
}
Future<void> _applyFrameworkRepoLabels(GitHub gitHubClient, String? eventAction, PullRequest pr) async {
if (pr.user!.login == 'engine-flutter-autoroll') {
return;
}
final RepositorySlug slug = pr.base!.repo!.slug();
log.info('Applying framework repo labels for: owner=${slug.owner} repo=${slug.name} and pr=${pr.number}');
final Stream<PullRequestFile> files = gitHubClient.pullRequests.listFiles(slug, pr.number!);
final Set<String> labels = <String>{};
bool hasTests = false;
bool needsTests = false;
await for (PullRequestFile file in files) {
// When null, do not assume 0 lines have been added.
final String filename = file.filename!;
final int linesAdded = file.additionsCount ?? 1;
final int linesDeleted = file.deletionsCount ?? 0;
final int linesTotal = file.changesCount ?? linesDeleted + linesAdded;
final bool addedCode = linesAdded > 0 || linesDeleted != linesTotal;
if (addedCode &&
!filename.contains('AUTHORS') &&
!filename.contains('pubspec.yaml') &&
!filename.contains('.ci.yaml') &&
!filename.contains('.cirrus.yml') &&
!filename.contains('.github') &&
!filename.endsWith('.md') &&
!filename.contains('CODEOWNERS') &&
!filename.startsWith('dev/bots/') &&
!filename.endsWith('.gitignore')) {
needsTests = !_allChangesAreCodeComments(file);
}
// Check to see if tests were submitted with this PR.
if (_isATest(filename)) {
hasTests = true;
}
labels.addAll(getLabelsForFrameworkPath(filename));
}
if (pr.user!.login == 'fluttergithubbot') {
needsTests = false;
labels.addAll(<String>['team', 'tech-debt', 'team: flakes']);
}
if (labels.isNotEmpty) {
await gitHubClient.issues.addLabelsToIssue(slug, pr.number!, labels.toList());
}
// We do not need to add test labels if this is an auto roller author.
if (config.rollerAccounts.contains(pr.user!.login)) {
return;
}
if (!hasTests && needsTests && !pr.draft! && !_isReleaseBranch(pr)) {
final String body = config.missingTestsPullRequestMessage;
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.issues.createComment(slug, pr.number!, body);
}
}
}
bool _isATest(String filename) {
if (kNotActuallyATest.any(filename.endsWith)) {
return false;
}
// Check for Objective-C tests which end in either "Tests.m" or "Test.m"
// in the "dev" directory.
final RegExp objectiveCTestRegex = RegExp(r'.*dev\/.*Test[s]?\.m$');
return filename.endsWith('_test.dart') ||
filename.endsWith('.expect') ||
filename.contains('test_fixes') ||
filename.startsWith('dev/bots/analyze.dart') ||
filename.startsWith('dev/bots/test.dart') ||
filename.startsWith('dev/devicelab/bin/tasks') ||
filename.startsWith('dev/devicelab/lib/tasks') ||
filename.startsWith('dev/devicelab/lib/tasks') ||
objectiveCTestRegex.hasMatch(filename);
}
/// Returns the set of labels applicable to a file in the framework repo.
static Set<String> getLabelsForFrameworkPath(String filepath) {
final Set<String> labels = <String>{};
if (filepath.endsWith('pubspec.yaml')) {
// These get updated by a script, and are updated en masse.
labels.add('team');
return labels;
}
if (filepath.endsWith('fix_data.yaml') || filepath.endsWith('.expect') || filepath.contains('test_fixes')) {
// Dart fixes
labels.add('team');
labels.add('tech-debt');
}
const Map<String, List<String>> pathPrefixLabels = <String, List<String>>{
'bin/internal/engine.version': <String>['engine'],
'dev/': <String>['team'],
'examples/': <String>['d: examples', 'team'],
'examples/api/': <String>['d: examples', 'team', 'd: api docs', 'documentation'],
'examples/flutter_gallery/': <String>['d: examples', 'team', 'team: gallery'],
'packages/flutter_tools/': <String>['tool'],
'packages/flutter_tools/lib/src/ios/': <String>['platform-ios'],
'packages/flutter/': <String>['framework'],
'packages/flutter_driver/': <String>['framework', 'a: tests'],
'packages/flutter_localizations/': <String>['a: internationalization'],
'packages/flutter_goldens/': <String>['framework', 'a: tests', 'team'],
'packages/flutter_goldens_client/': <String>['framework', 'a: tests', 'team'],
'packages/flutter_test/': <String>['framework', 'a: tests'],
'packages/fuchsia_remote_debug_protocol/': <String>['tool'],
'packages/integration_test/': <String>['integration_test'],
};
const Map<String, List<String>> pathContainsLabels = <String, List<String>>{
'accessibility': <String>['a: accessibility'],
'animation': <String>['a: animation'],
'cupertino': <String>['f: cupertino'],
'focus': <String>['f: focus'],
'gestures': <String>['f: gestures'],
'material': <String>['f: material design'],
'navigator': <String>['f: routes'],
'route': <String>['f: routes'],
'scroll': <String>['f: scrolling'],
'semantics': <String>['a: accessibility'],
'sliver': <String>['f: scrolling'],
'text': <String>['a: text input'],
'viewport': <String>['f: scrolling'],
};
pathPrefixLabels.forEach((String path, List<String> pathLabels) {
if (filepath.startsWith(path)) {
labels.addAll(pathLabels);
}
});
pathContainsLabels.forEach((String path, List<String> pathLabels) {
if (filepath.contains(path)) {
labels.addAll(pathLabels);
}
});
return labels;
}
/// Returns the set of labels applicable to a file in the engine repo.
static Set<String> getLabelsForEnginePath(String filepath) {
const Map<String, List<String>> pathPrefixLabels = <String, List<String>>{
'shell/platform/android': <String>['platform-android'],
'shell/platform/embedder': <String>['embedder'],
'shell/platform/darwin/common': <String>['platform-ios', 'platform-macos'],
'shell/platform/darwin/ios': <String>['platform-ios'],
'shell/platform/darwin/macos': <String>['platform-macos'],
'shell/platform/fuchsia': <String>['platform-fuchsia'],
'shell/platform/linux': <String>['platform-linux'],
'shell/platform/windows': <String>['platform-windows'],
'lib/web_ui': <String>['platform-web'],
'web_sdk': <String>['platform-web'],
};
final Set<String> labels = <String>{};
pathPrefixLabels.forEach((String path, List<String> pathLabels) {
if (filepath.startsWith(path)) {
labels.addAll(pathLabels);
}
});
return labels;
}
Future<void> _applyEngineRepoLabels(GitHub gitHubClient, String? eventAction, PullRequest pr) async {
// Do not apply the test labels for the autoroller accounts.
if (pr.user!.login == 'skia-flutter-autoroll') {
return;
}
final RepositorySlug slug = pr.base!.repo!.slug();
final Stream<PullRequestFile> files = gitHubClient.pullRequests.listFiles(slug, pr.number!);
final Set<String> labels = <String>{};
bool hasTests = false;
bool needsTests = false;
await for (PullRequestFile file in files) {
final String filename = file.filename!.toLowerCase();
if (filename.endsWith('.dart') ||
filename.endsWith('.mm') ||
filename.endsWith('.m') ||
filename.endsWith('.java') ||
filename.endsWith('.cc')) {
needsTests = true;
}
if (kEngineTestRegExp.hasMatch(filename)) {
hasTests = true;
}
labels.addAll(getLabelsForEnginePath(filename));
}
if (labels.isNotEmpty) {
await gitHubClient.issues.addLabelsToIssue(slug, pr.number!, labels.toList());
}
// We do not need to add test labels if this is an auto roller author.
if (config.rollerAccounts.contains(pr.user!.login)) {
return;
}
if (!hasTests && needsTests && !pr.draft! && !_isReleaseBranch(pr)) {
final String body = config.missingTestsPullRequestMessage;
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.issues.createComment(slug, pr.number!, body);
await gitHubClient.issues.addLabelsToIssue(slug, pr.number!, kNeedsTestsLabels);
}
}
}
// Runs automated test checks for both flutter/packages and flutter/plugins.
Future<void> _applyPackageTestChecks(GitHub gitHubClient, String? eventAction, PullRequest pr) async {
final RepositorySlug slug = pr.base!.repo!.slug();
final Stream<PullRequestFile> files = gitHubClient.pullRequests.listFiles(slug, pr.number!);
bool hasTests = false;
bool needsTests = false;
await for (PullRequestFile file in files) {
final String filename = file.filename!;
// When null, do not assume 0 lines have been added.
final int linesAdded = file.additionsCount ?? 1;
final int linesDeleted = file.deletionsCount ?? 0;
final int linesTotal = file.changesCount ?? linesDeleted + linesAdded;
final bool addedCode = linesAdded > 0 || linesDeleted != linesTotal;
if (addedCode &&
!filename.endsWith('AUTHORS') &&
!filename.endsWith('CODEOWNERS') &&
!filename.endsWith('pubspec.yaml') &&
!filename.endsWith('.ci.yaml') &&
!filename.endsWith('.cirrus.yml') &&
!filename.contains('.ci/') &&
!filename.contains('.github/') &&
!filename.endsWith('.md')) {
needsTests = !_allChangesAreCodeComments(file);
}
// See https://github.com/flutter/flutter/wiki/Plugin-Tests for discussion
// of various plugin test types and locations.
if (filename.endsWith('_test.dart') ||
// Native iOS/macOS tests.
filename.contains('RunnerTests/') ||
filename.contains('RunnerUITests/') ||
// Native Android tests.
filename.contains('android/src/test/') ||
filename.contains('androidTest/') ||
// Native Linux tests.
filename.endsWith('_test.cc') ||
// Native Windows tests.
filename.endsWith('_test.cpp')) {
hasTests = true;
}
}
// We do not need to add test labels if this is an auto roller author.
if (config.rollerAccounts.contains(pr.user!.login)) {
return;
}
if (!hasTests && needsTests && !pr.draft! && !_isReleaseBranch(pr)) {
final String body = config.missingTestsPullRequestMessage;
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.issues.createComment(slug, pr.number!, body);
await gitHubClient.issues.addLabelsToIssue(slug, pr.number!, kNeedsTestsLabels);
}
}
}
/// Validate the base and head refs of the PR.
Future<void> _validateRefs(
GitHub gitHubClient,
PullRequest pr,
) async {
final RepositorySlug slug = pr.base!.repo!.slug();
String body;
const List<String> releaseChannels = <String>[
'stable',
'beta',
'dev',
];
// Close PRs that use a release branch as a source.
if (releaseChannels.contains(pr.head!.ref)) {
body = config.wrongHeadBranchPullRequestMessage(pr.head!.ref!);
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.pullRequests.edit(
slug,
pr.number!,
state: 'closed',
);
await gitHubClient.issues.createComment(slug, pr.number!, body);
}
return;
}
final String defaultBranchName = Config.defaultBranch(pr.base!.repo!.slug());
final String baseName = pr.base!.ref!;
if (baseName == defaultBranchName) {
return;
}
if (_isReleaseBranch(pr)) {
body = config.releaseBranchPullRequestMessage;
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.issues.createComment(slug, pr.number!, body);
}
return;
}
// Assume this PR should be based against config.defaultBranch.
body = _getWrongBaseComment(base: baseName, defaultBranch: defaultBranchName);
if (!await _alreadyCommented(gitHubClient, pr, body)) {
await gitHubClient.pullRequests.edit(
slug,
pr.number!,
base: Config.defaultBranch(slug),
);
await gitHubClient.issues.createComment(slug, pr.number!, body);
}
}
bool _isReleaseBranch(PullRequest pr) {
final String defaultBranchName = Config.defaultBranch(pr.base!.repo!.slug());
final String baseName = pr.base!.ref!;
if (baseName == defaultBranchName) {
return false;
}
// Check if branch name confroms to the format flutter-x.x-candidate.x,
// A pr with conforming branch name is likely to be intended
// for a release branch, whereas a pr with non conforming name is likely
// caused by user misoperations, in which case bot
// will suggest open pull request against default branch instead.
final RegExp candidateTest = RegExp(r'flutter-\d+\.\d+-candidate\.\d+');
if (candidateTest.hasMatch(baseName) && candidateTest.hasMatch(pr.head!.ref!)) {
return true;
}
return false;
}
Future<bool> _alreadyCommented(
GitHub gitHubClient,
PullRequest pr,
String message,
) async {
final Stream<IssueComment> comments = gitHubClient.issues.listCommentsByIssue(pr.base!.repo!.slug(), pr.number!);
await for (IssueComment comment in comments) {
if (comment.body != null && comment.body!.contains(message)) {
return true;
}
}
return false;
}
String _getWrongBaseComment({
required String base,
required String defaultBranch,
}) {
final String messageTemplate = config.wrongBaseBranchPullRequestMessage;
return messageTemplate.replaceAll('{{target_branch}}', base).replaceAll('{{default_branch}}', defaultBranch);
}
Future<PullRequestEvent?> _getPullRequestEvent(String request) async {
try {
return PullRequestEvent.fromJson(json.decode(request) as Map<String, dynamic>);
} on FormatException {
return null;
}
}
/// Returns true if the changes to [file] are all code comments.
///
/// If that cannot be determined with confidence, returns false. False
/// negatives (e.g., for /* */-style multi-line comments) should be expected.
bool _allChangesAreCodeComments(PullRequestFile file) {
final int? linesAdded = file.additionsCount;
final int? linesDeleted = file.deletionsCount;
final String? patch = file.patch;
// If information is missing, err or the side of assuming it's a non-comment
// change.
if (linesAdded == null || linesDeleted == null || patch == null) {
return false;
}
// Ensure that the file is a language reconized by the check below.
const Set<String> codeExtensions = <String>{
'cc',
'cpp',
'dart',
'java',
'kt',
'm',
'mm',
'swift',
};
final String filename = file.filename!;
final String? extension = filename.contains('.') ? filename.split('.').last.toLowerCase() : null;
if (extension == null || !codeExtensions.contains(extension)) {
return false;
}
// Only handles single-line comments; identifying multi-line comments
// would require the full file and non-trivial parsing. Also doesn't handle
// end-of-line comments (e.g., "int x = 0; // Info about x").
final RegExp commentRegex = RegExp(r'[+-]\s*//');
for (String line in patch.split('\n')) {
if (!line.startsWith('+') && !line.startsWith('-')) {
continue;
}
if (!commentRegex.hasMatch(line)) {
return false;
}
}
return true;
}
}