blob: 6ca22f4492a14e294b10f236e247223e9d8a3016 [file] [log] [blame] [edit]
// 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 'dart:io';
import 'package:cocoon_common/rpc_model.dart';
import 'package:cocoon_common/task_status.dart';
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
import 'package:http/http.dart' as http;
import 'cocoon.dart';
import 'scenarios.dart';
/// CocoonService for interacting with flutter/flutter production build data.
///
/// This queries API endpoints that are hosted on AppEngine.
class AppEngineCocoonService implements CocoonService {
/// Creates a new [AppEngineCocoonService].
///
/// If a [client] is not specified, a new [http.Client] instance is created.
AppEngineCocoonService({http.Client? client})
: _client = client ?? http.Client();
@override
void resetScenario(Scenario scenario) {}
/// Branch on flutter/flutter to default requests for.
final String _defaultBranch = 'master';
/// The Cocoon API endpoint to query
///
/// This is the base for all API requests to cocoon
static const String _baseApiUrl = 'flutter-dashboard.appspot.com';
/// Json keys from response data.
static const String kCommitAvatar = 'Avatar';
static const String kCommitAuthor = 'Author';
static const String kCommitBranch = 'Branch';
static const String kCommitCreateTimestamp = 'CreateTimestamp';
static const String kCommitDocumentName = 'DocumentName';
static const String kCommitMessage = 'Message';
static const String kCommitRepositoryPath = 'RepositoryPath';
static const String kCommitSha = 'Sha';
static const String kTaskAttempts = 'Attempts';
static const String kTaskBringup = 'Bringup';
static const String kTaskBuildList = 'BuildList';
static const String kTaskBuildNumber = 'BuildNumber';
static const String kTaskCommitSha = 'CommitSha';
static const String kTaskCreateTimestamp = 'CreateTimestamp';
static const String kTaskDocumentName = 'DocumentName';
static const String kTaskEndTimestamp = 'EndTimestamp';
static const String kTaskStartTimestamp = 'StartTimestamp';
static const String kTaskStatus = 'Status';
static const String kTaskTaskName = 'TaskName';
static const String kTaskTestFlaky = 'TestFlaky';
final http.Client _client;
@override
Future<CocoonResponse<List<CommitStatus>>> fetchCommitStatuses({
CommitStatus? lastCommitStatus,
String? branch,
required String repo,
}) async {
final queryParameters = <String, String?>{
if (lastCommitStatus != null)
'lastCommitSha': lastCommitStatus.commit.sha,
'branch': branch ?? _defaultBranch,
'repo': repo,
};
final getStatusUrl = apiEndpoint(
'/api/public/get-status',
queryParameters: queryParameters,
);
/// This endpoint returns JSON [List<Agent>, List<CommitStatus>]
final response = await _client.get(getStatusUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<List<CommitStatus>>.error(
'/api/public/get-status returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as Map<String, dynamic>;
return CocoonResponse<List<CommitStatus>>.data(
_commitStatusesFromJson(jsonResponse['Commits'] as List<Object?>),
);
} catch (error) {
return CocoonResponse<List<CommitStatus>>.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<List<String>>> fetchRepos() async {
final getReposUrl = apiEndpoint('/api/public/repos');
// This endpoint returns a JSON array of strings.1
final response = await _client.get(getReposUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<List<String>>.error(
'$getReposUrl returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
List<String> repos;
try {
repos = List<String>.from(jsonDecode(response.body) as List<dynamic>);
} on FormatException {
return CocoonResponse<List<String>>.error(
'$getReposUrl had a malformed response',
statusCode: response.statusCode,
);
}
return CocoonResponse<List<String>>.data(repos);
}
@override
Future<CocoonResponse<BuildStatusResponse>> fetchTreeBuildStatus({
String? branch,
required String repo,
}) async {
final queryParameters = <String, String?>{
'branch': branch ?? _defaultBranch,
'repo': repo,
};
final getBuildStatusUrl = apiEndpoint(
'/api/public/build-status',
queryParameters: queryParameters,
);
/// This endpoint returns JSON {AnticipatedBuildStatus: [BuildStatus]}
final response = await _client.get(getBuildStatusUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<BuildStatusResponse>.error(
'/api/public/build-status returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
BuildStatusResponse protoResponse;
try {
protoResponse = BuildStatusResponse.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
} on FormatException {
return CocoonResponse<BuildStatusResponse>.error(
'/api/public/build-status had a malformed response',
statusCode: response.statusCode,
);
}
return CocoonResponse<BuildStatusResponse>.data(protoResponse);
}
@override
Future<CocoonResponse<List<Branch>>> fetchFlutterBranches() async {
final getBranchesUrl = apiEndpoint('/api/public/get-release-branches');
/// This endpoint returns JSON {"Branches": List<String>}
final response = await _client.get(getBranchesUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/public/get-release-branches returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as List<Object?>;
final branches = <Branch>[];
for (final jsonBranch in jsonResponse.cast<Map<String, Object?>>()) {
branches.add(Branch.fromJson(jsonBranch));
}
return CocoonResponse<List<Branch>>.data(branches);
} catch (error) {
return CocoonResponse<List<Branch>>.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<List<TreeStatusChange>>> fetchTreeStatusChanges({
required String idToken,
required String repo,
}) async {
final getTreeStatusChangesUrl = apiEndpoint(
'/api/get-tree-status',
queryParameters: {'repo': repo},
);
final response = await _client.get(
getTreeStatusChangesUrl,
headers: {'X-Flutter-IdToken': idToken},
);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/get-tree-status returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as List<Object?>;
final changes = <TreeStatusChange>[];
for (final jsonChange in jsonResponse.cast<Map<String, Object?>>()) {
changes.add(TreeStatusChange.fromJson(jsonChange));
}
return CocoonResponse.data(changes);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<List<SuppressedTest>>> fetchSuppressedTests({
String? repo,
}) async {
final getSuppressedTestsUrl = apiEndpoint(
'/api/public/suppressed-tests',
queryParameters: {'repo': ?repo},
);
final response = await _client.get(getSuppressedTestsUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/public/suppressed-tests returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as List<Object?>;
final suppressedTests = <SuppressedTest>[];
for (final jsonTest in jsonResponse.cast<Map<String, Object?>>()) {
suppressedTests.add(SuppressedTest.fromJson(jsonTest));
}
return CocoonResponse.data(suppressedTests);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<void>> updateTestSuppression({
required String idToken,
required String repo,
required String testName,
required bool suppress,
String? issueLink,
String? note,
}) async {
final updateTestSuppressionUrl = apiEndpoint('/api/update-suppressed-test');
final response = await _client.post(
updateTestSuppressionUrl,
headers: {'X-Flutter-IdToken': idToken},
body: jsonEncode({
'repository': repo,
'testName': testName,
'action': suppress ? 'SUPPRESS' : 'UNSUPPRESS',
'issueLink': ?issueLink,
'note': ?note,
}),
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(null);
}
return CocoonResponse.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<PresubmitGuardResponse>> fetchPresubmitGuard({
required String sha,
String repo = 'flutter',
String owner = 'flutter',
}) async {
final queryParameters = <String, String?>{
'owner': owner,
'repo': repo,
'sha': sha,
};
final getGuardUrl = apiEndpoint(
'/api/public/get-presubmit-guard',
queryParameters: queryParameters,
);
final response = await _client.get(getGuardUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/public/get-presubmit-guard returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
return CocoonResponse.data(
PresubmitGuardResponse.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
),
);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<List<PresubmitCheckResponse>>>
fetchPresubmitCheckDetails({
required int checkRunId,
required String buildName,
String repo = 'flutter',
String owner = 'flutter',
}) async {
final queryParameters = <String, String?>{
'check_run_id': checkRunId.toString(),
'build_name': buildName,
'repo': repo,
'owner': owner,
};
final getChecksUrl = apiEndpoint(
'/api/public/get-presubmit-checks',
queryParameters: queryParameters,
);
final response = await _client.get(getChecksUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/public/get-presubmit-checks returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as List<Object?>;
return CocoonResponse.data(
jsonResponse
.cast<Map<String, Object?>>()
.map(PresubmitCheckResponse.fromJson)
.toList(),
);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<List<PresubmitGuardSummary>>>
fetchPresubmitGuardSummaries({
required String pr,
String repo = 'flutter',
String owner = 'flutter',
}) async {
final queryParameters = <String, String?>{
'owner': owner,
'repo': repo,
'pr': pr,
};
final getSummariesUrl = apiEndpoint(
'/api/public/get-presubmit-guard-summaries',
queryParameters: queryParameters,
);
final response = await _client.get(getSummariesUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/public/get-presubmit-guard-summaries returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as List<Object?>;
return CocoonResponse.data(
jsonResponse
.cast<Map<String, Object?>>()
.map(PresubmitGuardSummary.fromJson)
.toList(),
);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<void>> updateTreeStatus({
required String idToken,
required String repo,
required TreeStatus status,
String? reason,
}) async {
final updateTreeStatusUrl = apiEndpoint('/api/update-tree-status');
final response = await _client.post(
updateTreeStatusUrl,
headers: {'X-Flutter-IdToken': idToken},
body: jsonEncode({
'repo': repo,
'passing': status == TreeStatus.success,
'reason': ?reason,
}),
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(null);
}
return CocoonResponse.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<bool>> vacuumGitHubCommits(
String idToken, {
required String repo,
required String branch,
}) async {
final refreshGitHubCommitsUrl = apiEndpoint(
'/api/vacuum-github-commits',
queryParameters: {'repo': repo, 'branch': branch},
);
final response = await _client.get(
refreshGitHubCommitsUrl,
headers: <String, String>{'X-Flutter-IdToken': idToken},
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(true);
}
return CocoonResponse.error(
'Failed to vacuum github commits: ${response.reasonPhrase}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<bool>> rerunTask({
required String? idToken,
required String taskName,
required String commitSha,
required String repo,
required String branch,
Iterable<TaskStatus>? include,
}) async {
if (idToken == null || idToken.isEmpty) {
return const CocoonResponse<bool>.error(
'Sign in to trigger reruns',
statusCode: 401 /* HTTP Unathorized */,
);
}
/// This endpoint only returns a status code.
final postResetTaskUrl = apiEndpoint('/api/rerun-prod-task');
final response = await _client.post(
postResetTaskUrl,
headers: {'X-Flutter-IdToken': idToken},
body: jsonEncode({
'branch': branch,
'repo': repo,
'commit': commitSha,
'task': taskName,
if (include != null) 'include': include.join(','),
}),
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse<bool>.data(true);
}
return CocoonResponse<bool>.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<void>> rerunFailedJob({
required String? idToken,
required String repo,
required int pr,
required String buildName,
String owner = 'flutter',
}) async {
if (idToken == null || idToken.isEmpty) {
return const CocoonResponse<void>.error(
'Sign in to trigger reruns',
statusCode: HttpStatus.unauthorized,
);
}
final rerunUrl = apiEndpoint('/api/rerun-failed-job');
final response = await _client.post(
rerunUrl,
headers: {'X-Flutter-IdToken': idToken},
body: jsonEncode({
'owner': owner,
'repo': repo,
'pr': pr,
'build_name': buildName,
}),
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(null);
}
return CocoonResponse.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<void>> rerunAllFailedJobs({
required String? idToken,
required String repo,
required int pr,
String owner = 'flutter',
}) async {
if (idToken == null || idToken.isEmpty) {
return const CocoonResponse<void>.error(
'Sign in to trigger reruns',
statusCode: HttpStatus.unauthorized,
);
}
final rerunUrl = apiEndpoint('/api/rerun-all-failed-jobs');
final response = await _client.post(
rerunUrl,
headers: {'X-Flutter-IdToken': idToken},
body: jsonEncode({'owner': owner, 'repo': repo, 'pr': pr}),
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(null);
}
return CocoonResponse.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
@override
Future<CocoonResponse<void>> rerunCommit({
required String? idToken,
required String commitSha,
required String repo,
required String branch,
Iterable<TaskStatus>? include,
}) async {
return rerunTask(
idToken: idToken,
taskName: 'all',
include: include,
commitSha: commitSha,
repo: repo,
branch: branch,
);
}
@override
Future<CocoonResponse<List<MergeGroupHook>>> fetchMergeQueueHooks({
required String idToken,
}) async {
final getMergeQueueHooksUrl = apiEndpoint('/api/merge_queue_hooks');
final response = await _client.get(
getMergeQueueHooksUrl,
headers: {'X-Flutter-IdToken': idToken},
);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse.error(
'/api/merge_queue_hooks returned ${response.statusCode}',
statusCode: response.statusCode,
);
}
try {
final jsonResponse = jsonDecode(response.body) as Map<String, Object?>;
final hooks = MergeGroupHooks.fromJson(jsonResponse);
return CocoonResponse.data(hooks.hooks);
} catch (error) {
return CocoonResponse.error(
error.toString(),
statusCode: response.statusCode,
);
}
}
@override
Future<CocoonResponse<void>> replayGitHubWebhook({
required String idToken,
required String id,
}) async {
if (idToken.isEmpty) {
return const CocoonResponse.error(
'Sign in to replay events',
statusCode: 401,
);
}
final replayUrl = apiEndpoint(
'/api/github-webhook-replay',
queryParameters: {'id': id},
);
final response = await _client.post(
replayUrl,
headers: {'X-Flutter-IdToken': idToken},
);
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse.data(null);
}
return CocoonResponse.error(
'HTTP Code: ${response.statusCode}, ${response.body}',
statusCode: response.statusCode,
);
}
/// Construct the API endpoint based on the priority of using a local endpoint
/// before falling back to the production endpoint.
///
/// This functions resolves the relative url endpoint to the production endpoint
/// that can be used on web to the production endpoint if running not on web.
/// This is because only on web a Cocoon backend can be running from the same
/// host as this Flutter application, but on mobile we need to ping a separate
/// production endpoint.
///
/// The urlSuffix begins with a slash, e.g. "/api/public/get-status".
///
/// [queryParameters] are appended to the url and are url encoded.
@visibleForTesting
Uri apiEndpoint(String urlSuffix, {Map<String, String?>? queryParameters}) {
if (kIsWeb) {
return Uri.base.replace(
path: urlSuffix,
queryParameters: queryParameters,
);
}
return Uri.https(_baseApiUrl, urlSuffix, queryParameters);
}
List<CommitStatus> _commitStatusesFromJson(List<Object?> commits) {
return [...commits.cast<Map<String, Object?>>().map(CommitStatus.fromJson)];
}
}