blob: ac9cd42f30519100fafd725fdec1e88836f33869 [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:cocoon_service/src/request_handling/exceptions.dart';
import 'package:github/github.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:http/http.dart' as http;
import 'package:http/retry.dart';
import 'package:meta/meta.dart';
import '../model/gerrit/commit.dart';
import 'config.dart';
import 'logging.dart';
/// Communicates with gerrit APIs https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html
/// to get information about projects hosted in Git on Borg.
class GerritService {
GerritService({
required this.config,
http.Client? httpClient,
@visibleForTesting this.authClientProvider = clientViaApplicationDefaultCredentials,
@visibleForTesting this.retryDelay,
}) : httpClient = httpClient ?? http.Client();
final Config config;
final http.Client httpClient;
final Duration? retryDelay;
/// Provider for generating a [http.Client] that is authenticated to make calls to GCP services.
final Future<AutoRefreshingAuthClient> Function({
http.Client? baseClient,
required List<String> scopes,
}) authClientProvider;
/// Gets the branches from a remote git repository using the gerrit APIs.
///
/// [filterRegex] a regular expression string to filter the branches list to
/// the ones matching the regex.
///
/// See more:
/// * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#list-branches
Future<List<String>> branches(String repo, String project, {String? filterRegex}) async {
final Map<String, String> queryParameters = <String, String>{
if (filterRegex != null && filterRegex.isNotEmpty) 'r': filterRegex,
};
final Uri url = Uri.https(repo, 'projects/$project/branches', queryParameters);
final List<dynamic> response = await _getJson(url) as List<dynamic>;
final List<String> branches = <String>[];
final Iterable<Map<String, dynamic>> json = response.map((dynamic e) => e as Map<String, dynamic>);
for (Map<String, dynamic> element in json) {
branches.add(element['ref'] as String);
}
return branches;
}
/// Gets the commit log for a project-branch pair.
Future<Iterable<GerritCommit>> commits(RepositorySlug slug, String branch) async {
final Uri url =
Uri.https('${slug.owner}.googlesource.com', '${slug.name}/+log/refs/heads/$branch', <String, String>{
'format': 'json',
});
final Map<String, dynamic> response = await _getJson(url) as Map<String, dynamic>;
final List<dynamic> commitsJson = response['log'] as List<dynamic>;
return commitsJson.map((dynamic part) => GerritCommit.fromJson(part as Map<String, dynamic>));
}
/// Finds a commit on a GoB mirror using the GitHub [slug] and commit [sha].
///
/// The [slug] will be validated by checking if it represents a presubmit or postsubmit supported repo.
Future<GerritCommit?> findMirroredCommit(RepositorySlug slug, String sha) async {
if (!config.supportedRepos.contains(slug)) return null;
final gobMirrorName = 'mirrors/${slug.name}';
return getCommit(RepositorySlug(slug.owner, gobMirrorName), sha);
}
/// Gets the commit info from Gob.
///
/// See more:
/// * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-commit
Future<GerritCommit?> getCommit(RepositorySlug slug, String commitId) async {
// URL encode because "mirrors/flutter" should be "mirrors%2Fflutter".
final String projectName = Uri.encodeComponent(slug.name);
// Uses [Uri] constructor instead of [Uri.https], where the latter uses [unencodedPath].
// Our mirror repo, say [mirrors/cocoon], will not be encoded
// as [mirrors%2Fcocoon] if injected directly. On the other hand, if we inject an encoded
// version [mirrors%2Fcocoon], [Uri.https] will translate that to [mirrors%252Fcocoon].
// Neither works with [Uri.https].
final Uri url = Uri(
scheme: 'https',
host: '${slug.owner}-review.googlesource.com',
path: 'projects/$projectName/commits/$commitId',
);
log.info('Gerrit get-commit url: $url');
final http.Response response = await _get(url);
log.info('Gob commit response for commit $commitId: ${response.body}');
if (!_responseIsAcceptable(response)) return null;
final String jsonBody = _stripXssToken(response.body);
final Map<String, dynamic> json = jsonDecode(jsonBody) as Map<String, dynamic>;
return GerritCommit.fromJson(json);
}
/// Strips magic prefix from response body.
///
/// To prevent against Cross Site Script Inclusion (XSSI) attacks, the JSON response body starts with a magic prefix line that
/// must be stripped before feeding the rest of the response body to a JSON parser. The magic prefix is ")]}'".
String _stripXssToken(String body) {
return body.replaceRange(0, 4, '');
}
/// Creates a new branch.
///
/// See more:
/// * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
Future<void> createBranch(RepositorySlug slug, String branchName, String revision) async {
log.info('Creating branch $branchName at $revision');
final Uri url = Uri.https('${slug.owner}-review.googlesource.com', 'projects/${slug.name}/branches/$branchName');
final Map<String, dynamic> response = await _put(
url,
body: revision,
) as Map<String, dynamic>;
log.info(response);
if (response['revision'] != revision) {
throw const InternalServerError('Failed to create branch');
}
log.info('Created branch $branchName');
}
Future<dynamic> _getJson(Uri url) async {
final RetryClient client = RetryClient(httpClient);
final http.Response response = await client.get(url);
final String jsonBody = _stripXssToken(response.body);
return jsonDecode(jsonBody) as dynamic;
}
Future<http.Response> _get(Uri url) async {
final RetryClient client = RetryClient(httpClient);
return client.get(url);
}
Future<dynamic> _put(
Uri url, {
Object? body,
}) async {
final http.Client authClient = await authClientProvider(baseClient: httpClient, scopes: <String>[]);
// GoB replicas may not have all the Flutter state, and can require several retries
final http.Client client = RetryClient(
authClient,
when: (http.BaseResponse response) => _responseIsAcceptable(response) == false,
delay: (int attempt) => retryDelay ?? const Duration(seconds: 1) * attempt,
);
final http.Response response = await client.put(
url,
body: body,
);
if (_responseIsAcceptable(response) == false) {
throw InternalServerError('Gerrit returned ${response.statusCode} which is not 200 or 202');
}
log.info('Sent PUT to $url');
log.info(response.body);
// Remove XSS token
final String jsonBody = _stripXssToken(response.body);
log.info(jsonBody);
return jsonDecode(jsonBody);
}
bool _responseIsAcceptable(http.BaseResponse response) =>
response.statusCode == HttpStatus.ok || response.statusCode == HttpStatus.accepted;
}