blob: 3eebad3683786f03e6d26371b01f25f5ddebe665 [file] [log] [blame]
// Copyright 2020 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:async';
import 'dart:convert';
import 'dart:io';
import 'package:appengine/appengine.dart';
import 'package:cocoon_service/src/request_handling/exceptions.dart';
import 'package:github/github.dart';
import 'package:graphql/client.dart';
import 'package:meta/meta.dart';
import '../datastore/cocoon_config.dart';
import '../foundation/providers.dart';
import '../foundation/typedefs.dart';
import '../model/appengine/github_gold_status_update.dart';
import '../request_handling/api_request_handler.dart';
import '../request_handling/authentication.dart';
import '../request_handling/body.dart';
import '../service/datastore.dart';
import 'refresh_cirrus_status.dart';
@immutable
class PushGoldStatusToGithub extends ApiRequestHandler<Body> {
PushGoldStatusToGithub(
Config config,
AuthenticationProvider authenticationProvider, {
@visibleForTesting DatastoreServiceProvider datastoreProvider,
@visibleForTesting LoggingProvider loggingProvider,
HttpClient goldClient,
}) : datastoreProvider =
datastoreProvider ?? DatastoreService.defaultProvider,
loggingProvider = loggingProvider ?? Providers.serviceScopeLogger,
goldClient = goldClient ?? HttpClient(),
super(config: config, authenticationProvider: authenticationProvider);
final DatastoreServiceProvider datastoreProvider;
final LoggingProvider loggingProvider;
final HttpClient goldClient;
@override
Future<Body> get() async {
final Logging log = loggingProvider();
final DatastoreService datastore = datastoreProvider(config.db);
if (authContext.clientContext.isDevelopmentEnvironment) {
// Don't push gold status from the local dev server.
return Body.empty;
}
final RepositorySlug slug = RepositorySlug('flutter', 'flutter');
final GitHub gitHubClient =
await config.createGitHubClient(slug.owner, slug.name);
final GraphQLClient cirrusClient = await config.createCirrusGraphQLClient();
final List<GithubGoldStatusUpdate> statusUpdates =
<GithubGoldStatusUpdate>[];
final List<String> cirrusCheckStatuses = <String>[];
log.debug('Beginning Gold checks...');
await for (PullRequest pr in gitHubClient.pullRequests.list(slug)) {
// Get last known Gold status from datastore.
final GithubGoldStatusUpdate lastUpdate =
await datastore.queryLastGoldUpdate(slug, pr);
CreateStatus statusRequest;
log.debug('Last known Gold status for #${pr.number} was with sha: '
'${lastUpdate.head}, status: ${lastUpdate.status}, description: ${lastUpdate.description}');
if (lastUpdate.status == GithubGoldStatusUpdate.statusCompleted &&
lastUpdate.head == pr.head.sha) {
log.debug('Completed status already reported for this commit.');
// We have already seen this commit and it is completed or, this is not
// a change staged to land on master, which we should ignore.
continue;
}
if (pr.base.ref != 'master') {
log.debug('This change is not staged to land on master, skipping.');
// This is potentially a release branch, or another change not landing
// on master, we don't need a Gold check.
continue;
}
log.debug('Querying Cirrus for pull request #${pr.number}...');
cirrusCheckStatuses.clear();
bool runsGoldenFileTests = false;
// Query current Cirrus checks for this pr.
final List<CirrusResult> cirrusResults =
await queryCirrusGraphQL(pr.head.sha, cirrusClient, log, 'flutter');
if (!cirrusResults.any((CirrusResult cirrusResult) =>
cirrusResult.branch == 'pull/${pr.number}')) {
log.debug(
'Skip pull request #${pr.number}, commit ${pr.head.sha} since no valid CirrusResult was found');
continue;
}
final List<dynamic> cirrusChecks = cirrusResults
.firstWhere((CirrusResult cirrusResult) =>
cirrusResult.branch == 'pull/${pr.number}')
.tasks;
for (dynamic check in cirrusChecks) {
final String status = check['status'] as String;
final String taskName = check['name'] as String;
log.debug(
'Found Cirrus build status for pull request #${pr.number}, commit '
'${pr.head.sha}: $taskName ($status)');
cirrusCheckStatuses.add(status);
if (taskName.contains('framework')) {
// Any pull request that runs a framework shard runs golden file
// tests. Once identified, all checks will be awaited to check Gold
// status.
runsGoldenFileTests = true;
}
}
if (runsGoldenFileTests) {
// Make sure we account for any running Luci builds
final List<String> luciIncompleteStates = <String>[
'failure',
'unreachable',
'pending'
];
final List<String> luciStatuses = <String>[];
final Stream<RepositoryStatus> statusStream =
gitHubClient.repositories.listStatuses(slug, pr.head.sha);
await for (RepositoryStatus status in statusStream) {
if (status.description != null &&
status.description.contains('LUCI')) {
log.debug('Found Luci build status for pull request #${pr.number}, '
'commit ${pr.head.sha}: ${status.description} (${status.state})');
luciStatuses.add(status.state);
}
}
if (cirrusCheckStatuses.any(kCirrusInProgressStates.contains) ||
cirrusCheckStatuses.any(kCirrusFailedStates.contains) ||
luciStatuses.any(luciIncompleteStates.contains) ||
pr.draft) {
// If checks on an open PR are running or failing, the gold status
// should just be pending. Any draft PRs are considered pending
// until marked ready for review.
log.debug('Waiting for checks to be completed.');
statusRequest = _createStatus(GithubGoldStatusUpdate.statusRunning,
'This check is waiting for the all clear from Gold.');
} else {
// Get Gold status.
final String goldStatus = await _getGoldStatus(pr, log);
statusRequest =
_createStatus(goldStatus, _getStatusDescription(goldStatus));
log.debug(
'New status for potential update: ${statusRequest.state}, ${statusRequest.description}');
if (goldStatus == GithubGoldStatusUpdate.statusRunning &&
!await _alreadyCommented(gitHubClient, pr, slug)) {
log.debug('Notifying for triage.');
await _commentAndApplyGoldLabels(gitHubClient, pr, slug);
}
}
// Push updates if there is a status change (detected by unique description)
// or this is a new commit.
if (lastUpdate.description != statusRequest.description ||
lastUpdate.head != pr.head.sha) {
try {
log.debug(
'Pushing status to GitHub: ${statusRequest.state}, ${statusRequest.description}');
await gitHubClient.repositories
.createStatus(slug, pr.head.sha, statusRequest);
lastUpdate.status = statusRequest.state;
lastUpdate.head = pr.head.sha;
lastUpdate.updates += 1;
lastUpdate.description = statusRequest.description;
statusUpdates.add(lastUpdate);
} catch (error) {
log.error(
'Failed to post status update to ${slug.fullName}#${pr.number}: $error');
}
}
}
}
await datastore.insert(statusUpdates);
log.debug('Committed all updates');
return Body.empty;
}
/// Returns a GitHub Status for the given state and description.
CreateStatus _createStatus(String state, String description) {
final CreateStatus statusUpdate = CreateStatus(state)
..targetUrl = 'https://flutter-gold.skia.org/changelists'
..context = 'flutter-gold'
..description = description;
return statusUpdate;
}
/// Used to check for any tryjob results from Flutter Gold associated with a
/// pull request.
Future<String> _getGoldStatus(PullRequest pr, Logging log) async {
// We wait for a few seconds in case tests _just_ finished and the tryjob
// has not finished ingesting the results.
await Future<void>.delayed(const Duration(seconds: 10));
final Uri requestForTryjobStatus = Uri.parse(
'http://flutter-gold.skia.org/json/changelist/github/${pr.number}/${pr.head.sha}/untriaged');
String rawResponse;
try {
final HttpClientRequest request =
await goldClient.getUrl(requestForTryjobStatus);
final HttpClientResponse response = await request.close();
rawResponse = await utf8.decodeStream(response);
final Map<String, dynamic> decodedResponse =
json.decode(rawResponse) as Map<String, dynamic>;
if (decodedResponse['digests'] == null) {
log.debug(
'There are no unexpected image results for #${pr.number} at sha '
'${pr.head.sha}.');
return GithubGoldStatusUpdate.statusCompleted;
} else {
log.debug(
'Tryjob for #${pr.number} at sha ${pr.head.sha} generated new '
'images.}');
return GithubGoldStatusUpdate.statusRunning;
}
} on FormatException catch (_) {
throw BadRequestException('Formatting error detected requesting '
'tryjob status for pr #${pr.number} from Flutter Gold.\n'
'rawResponse: $rawResponse');
} catch (e) {
throw BadRequestException(
'Error detected requesting tryjob status for pr '
'#${pr.number} from Flutter Gold.\n'
'error: $e');
}
}
String _getStatusDescription(String status) {
if (status == GithubGoldStatusUpdate.statusRunning) {
return 'Image changes have been found for '
'this pull request. Visit https://flutter-gold.skia.org/changelists '
'to view and triage (e.g. because this is an intentional change).';
}
return 'All golden file tests have passed.';
}
String _getTriageUrl(int number) {
return 'https://flutter-gold.skia.org/search?issue=$number&new_clstore=true';
}
/// Creates a comment on a given pull request identified to have golden file
/// changes and applies the `will affect goldens` label.
Future<void> _commentAndApplyGoldLabels(
GitHub gitHubClient,
PullRequest pr,
RepositorySlug slug,
) async {
String body;
if (await _isFirstComment(gitHubClient, pr, slug)) {
body = 'Golden file changes have been found for this pull '
'request. Click [here to view and triage](${_getTriageUrl(pr.number)}) '
'(e.g. because this is an intentional change).\n\n' +
config.goldenBreakingChangeMessage +
'\n\n';
} else {
body = 'Golden file changes are available for triage from new commit, '
'Click [here to view](${_getTriageUrl(pr.number)}).\n\n';
}
body += 'If you are still iterating on this change and are not ready to '
'resolve the images on the Flutter Gold dashboard, consider marking this PR '
'as a draft pull request above. You will still be able to view image results '
'on the dashboard, and the check will not try to resolve itself until '
'marked ready for review.\n\n'
'_Changes reported for pull request #${pr.number} at sha ${pr.head.sha}_\n\n';
await gitHubClient.issues.createComment(slug, pr.number, body);
await gitHubClient.issues.addLabelsToIssue(slug, pr.number, <String>[
'will affect goldens',
'severe: API break',
]);
}
Future<bool> _alreadyCommented(
GitHub gitHubClient,
PullRequest pr,
RepositorySlug slug,
) async {
final Stream<IssueComment> comments =
gitHubClient.issues.listCommentsByIssue(slug, pr.number);
await for (IssueComment comment in comments) {
if (comment.body.contains(
'Changes reported for pull request #${pr.number} at sha ${pr.head.sha}')) {
return true;
}
}
return false;
}
Future<bool> _isFirstComment(
GitHub gitHubClient,
PullRequest pr,
RepositorySlug slug,
) async {
final Stream<IssueComment> comments =
gitHubClient.issues.listCommentsByIssue(slug, pr.number);
await for (IssueComment comment in comments) {
if (comment.body.contains(
'Golden file changes have been found for this pull request')) {
return false;
}
}
return true;
}
}