blob: e534cec6e7fb9b3d1b439a95bdea8dfc2bc7538d [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 'dart:io';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart' show compute, kIsWeb, visibleForTesting;
import 'package:flutter_dashboard/model/branch.pb.dart';
import 'package:http/http.dart' as http;
import '../logic/qualified_task.dart';
import '../model/build_status_response.pb.dart';
import '../model/commit.pb.dart';
import '../model/commit_status.pb.dart';
import '../model/key.pb.dart';
import '../model/task.pb.dart';
import 'cocoon.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();
/// 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';
final http.Client _client;
@override
Future<CocoonResponse<List<CommitStatus>>> fetchCommitStatuses({
CommitStatus? lastCommitStatus,
String? branch,
required String repo,
}) async {
final Map<String, String?> queryParameters = <String, String?>{
if (lastCommitStatus != null) 'lastCommitKey': lastCommitStatus.commit.key.child.name,
'branch': branch ?? _defaultBranch,
'repo': repo,
};
final Uri getStatusUrl = apiEndpoint('/api/public/get-status', queryParameters: queryParameters);
/// This endpoint returns JSON [List<Agent>, List<CommitStatus>]
final http.Response response = await _client.get(getStatusUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<List<CommitStatus>>.error('/api/public/get-status returned ${response.statusCode}');
}
try {
final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
return CocoonResponse<List<CommitStatus>>.data(
await compute<List<dynamic>, List<CommitStatus>>(_commitStatusesFromJson, jsonResponse['Statuses']));
} catch (error) {
return CocoonResponse<List<CommitStatus>>.error(error.toString());
}
}
@override
Future<CocoonResponse<List<String>>> fetchRepos() async {
final Uri getReposUrl = apiEndpoint('/api/public/repos');
// This endpoint returns a JSON array of strings.1
final http.Response response = await _client.get(getReposUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<List<String>>.error('$getReposUrl returned ${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');
}
return CocoonResponse<List<String>>.data(repos);
}
@override
Future<CocoonResponse<BuildStatusResponse>> fetchTreeBuildStatus({
String? branch,
required String repo,
}) async {
final Map<String, String?> queryParameters = <String, String?>{
'branch': branch ?? _defaultBranch,
'repo': repo,
};
final Uri getBuildStatusUrl = apiEndpoint('/api/public/build-status', queryParameters: queryParameters);
/// This endpoint returns JSON {AnticipatedBuildStatus: [BuildStatus]}
final http.Response response = await _client.get(getBuildStatusUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<BuildStatusResponse>.error('/api/public/build-status returned ${response.statusCode}');
}
BuildStatusResponse protoResponse;
try {
protoResponse = BuildStatusResponse.fromJson(response.body);
} on FormatException {
return const CocoonResponse<BuildStatusResponse>.error('/api/public/build-status had a malformed response');
}
return CocoonResponse<BuildStatusResponse>.data(protoResponse);
}
@override
Future<CocoonResponse<List<Branch>>> fetchFlutterBranches() async {
final Uri getBranchesUrl = apiEndpoint('/api/public/get-branches');
/// This endpoint returns JSON {"Branches": List<String>}
final http.Response response = await _client.get(getBranchesUrl);
if (response.statusCode != HttpStatus.ok) {
return CocoonResponse<List<Branch>>.error('/api/public/get-branches returned ${response.statusCode}');
}
try {
final List<dynamic> jsonResponse = jsonDecode(response.body);
final List<Branch> branches = <Branch>[];
for (final Map<String, dynamic> jsonBranch in jsonResponse) {
final Map<String, dynamic> branchInfo = jsonBranch['branch'];
branches.add(Branch()
..branch = branchInfo['branch']
..repository = branchInfo['repository'].split('/')[1]);
}
return CocoonResponse<List<Branch>>.data(branches);
} catch (error) {
return CocoonResponse<List<Branch>>.error(error.toString());
}
}
@override
Future<bool> vacuumGitHubCommits(String idToken) async {
final Uri refreshGitHubCommitsUrl = apiEndpoint('/api/vacuum-github-commits');
final http.Response response = await _client.get(
refreshGitHubCommitsUrl,
headers: <String, String>{
'X-Flutter-IdToken': idToken,
},
);
return response.statusCode == HttpStatus.ok;
}
@override
Future<CocoonResponse<bool>> rerunTask(Task task, String? idToken, String repo) async {
if (idToken == null || idToken.isEmpty) {
return const CocoonResponse<bool>.error('Sign in to trigger reruns');
}
final QualifiedTask qualifiedTask = QualifiedTask.fromTask(task);
assert(qualifiedTask.isLuci);
/// This endpoint only returns a status code.
final Uri postResetTaskUrl = apiEndpoint('/api/reset-prod-task');
final http.Response response = await _client.post(postResetTaskUrl,
headers: <String, String>{
'X-Flutter-IdToken': idToken,
},
body: jsonEncode(<String, String>{
'Key': task.key.child.name,
'Repo': repo,
}));
if (response.statusCode == HttpStatus.ok) {
return const CocoonResponse<bool>.data(true);
}
return CocoonResponse<bool>.error('HTTP Code: ${response.statusCode}, ${response.body}');
}
/// 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<dynamic>? jsonCommitStatuses) {
assert(jsonCommitStatuses != null);
// TODO(chillers): Remove adapter code to just use proto fromJson method. https://github.com/flutter/cocoon/issues/441
final List<CommitStatus> statuses = <CommitStatus>[];
for (final Map<String, dynamic> jsonCommitStatus in jsonCommitStatuses!) {
final Map<String, dynamic> checklist = jsonCommitStatus['Checklist'];
statuses.add(CommitStatus()
..commit = _commitFromJson(checklist)
..branch = _branchFromJson(checklist)!
..tasks.addAll(_tasksFromStagesJson(jsonCommitStatus['Stages'])));
}
return statuses;
}
String? _branchFromJson(Map<String, dynamic> jsonChecklist) {
final Map<String, dynamic> checklist = jsonChecklist['Checklist'];
return checklist['Branch'] as String?;
}
Commit _commitFromJson(Map<String, dynamic> jsonChecklist) {
final Map<String, dynamic> checklist = jsonChecklist['Checklist'];
final Map<String, dynamic> commit = checklist['Commit'];
final Map<String, dynamic> author = commit['Author'];
final Commit result = Commit()
..key = (RootKey()..child = (Key()..name = jsonChecklist['Key'] as String))
..timestamp = Int64() + checklist['CreateTimestamp']!
..sha = commit['Sha'] as String
..author = author['Login'] as String
..authorAvatarUrl = author['avatar_url'] as String
..repository = checklist['FlutterRepositoryPath'] as String
..branch = checklist['Branch'] as String;
if (commit['Message'] != null) {
result.message = commit['Message'] as String;
}
return result;
}
List<Task> _tasksFromStagesJson(List<dynamic> json) {
final List<Task> tasks = <Task>[];
for (final Map<String, dynamic> jsonStage in json) {
tasks.addAll(_tasksFromJson(jsonStage['Tasks']));
}
return tasks;
}
List<Task> _tasksFromJson(List<dynamic> json) {
final List<Task> tasks = <Task>[];
for (final Map<String, dynamic> jsonTask in json) {
//as Iterable<Map<String, Object>>
tasks.add(_taskFromJson(jsonTask));
}
return tasks;
}
Task _taskFromJson(Map<String, dynamic> json) {
final Map<String, dynamic> taskData = json['Task'];
final List<dynamic>? objectRequiredCapabilities = taskData['RequiredCapabilities'] as List<dynamic>?;
final Task task = Task()
..key = (RootKey()..child = (Key()..name = json['Key'] as String))
..createTimestamp = Int64(taskData['CreateTimestamp'] as int)
..startTimestamp = Int64(taskData['StartTimestamp'] as int)
..endTimestamp = Int64(taskData['EndTimestamp'] as int)
..name = taskData['Name'] as String
..attempts = taskData['Attempts'] as int
..isFlaky = taskData['Flaky'] as bool
..timeoutInMinutes = taskData['TimeoutInMinutes'] as int
..reason = taskData['Reason'] as String
..requiredCapabilities.add(objectRequiredCapabilities.toString())
..reservedForAgentId = taskData['ReservedForAgentID'] as String
..stageName = taskData['StageName'] as String
..status = taskData['Status'] as String
..isTestFlaky = taskData['TestFlaky'] as bool? ?? false;
if (taskData['StageName'] != StageName.cirrus) {
task
..buildNumberList = taskData['BuildNumberList'] as String? ?? ''
..builderName = taskData['BuilderName'] as String? ?? ''
..luciBucket = taskData['LuciBucket'] as String? ?? '';
}
return task;
}
}