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