blob: aac46939399023661e2f173ed8ee89ce7eec6b32 [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_server/logging.dart';
import 'package:github/github.dart';
import 'package:http/http.dart' as http;
import 'package:http/retry.dart';
import 'package:meta/meta.dart';
import '../model/gerrit/commit.dart';
import '../request_handling/exceptions.dart';
import 'config.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.
interface class GerritService {
GerritService({
required Config config,
required http.Client authClient,
@visibleForTesting Duration? retryDelay,
}) : _authClient = authClient,
_retryDelay = retryDelay,
_config = config;
final Config _config;
/// GoB replicas may not have all the Flutter state, and can require several retries
final Duration? _retryDelay;
/// Provider for generating a [http.Client] that is authenticated to make calls to GCP services.
final http.Client _authClient;
/// 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 queryParameters = <String, String>{
if (filterRegex != null && filterRegex.isNotEmpty) 'r': filterRegex,
};
final url = Uri.https(repo, 'projects/$project/branches', queryParameters);
final response = await _getJson(url) as List<dynamic>;
final branches = <String>[];
final json = response.map((dynamic e) => e as Map<String, dynamic>);
for (var 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 url = Uri.https(
'${slug.owner}.googlesource.com',
'${slug.name}/+log/refs/heads/$branch',
<String, String>{'format': 'json'},
);
final response = await _getJson(url) as Map<String, dynamic>;
final 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 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 url = Uri(
scheme: 'https',
host: '${slug.owner}-review.googlesource.com',
path: 'projects/$projectName/commits/$commitId',
);
log.info('Gerrit get-commit url: $url');
final response = await _get(url, throwOnError: false);
log.info('Gob commit response for commit $commitId: ${response.body}');
if (!_responseIsAcceptable(response)) return null;
final jsonBody = _stripXssToken(response.body);
final 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 url = Uri.https(
'${slug.owner}-review.googlesource.com',
'projects/${slug.name}/branches/$branchName',
);
final 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<Object?> _getJson(Uri url, {bool throwOnError = true}) async {
final response = await _get(url, throwOnError: throwOnError);
final jsonBody = _stripXssToken(response.body);
return jsonDecode(jsonBody) as Object?;
}
Future<http.Response> _get(Uri url, {bool throwOnError = true}) async {
final client = RetryClient(
_authClient,
when: (response) => !_responseIsAcceptable(response),
delay: (attempt) => _retryDelay ?? const Duration(seconds: 1) * attempt,
);
final response = await client.get(url);
if (throwOnError && !_responseIsAcceptable(response)) {
throw InternalServerError(
'Gerrit returned ${response.statusCode} which is not 200 or 202',
);
}
return response;
}
Future<dynamic> _put(Uri url, {Object? body}) async {
final client = RetryClient(
_authClient,
when: (response) => !_responseIsAcceptable(response),
delay: (attempt) => _retryDelay ?? const Duration(seconds: 1) * attempt,
);
final response = await client.put(url, body: body);
if (!_responseIsAcceptable(response)) {
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 jsonBody = _stripXssToken(response.body);
log.info(jsonBody);
return jsonDecode(jsonBody);
}
bool _responseIsAcceptable(http.BaseResponse response) =>
response.statusCode == HttpStatus.ok ||
response.statusCode == HttpStatus.accepted;
}