blob: 530d0cc089683beccc409051c0f09ecd0b6a6871 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. 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 'package:http/http.dart' as http;
import 'firestore.dart';
import 'result.dart';
/// Contains data about the commits on the tracked branch of the SDK repo.
/// An instance of this class is stored in a top-level variable, and is
/// shared between cloud function invocations.
///
/// The class fetches commits from Firestore if they are present,
/// and fetches them from gitiles if not, and saves them to Firestore.
class CommitsCache {
FirestoreService firestore;
final http.Client httpClient;
Map<String, Commit> byHash = {};
Map<int, Commit> byIndex = {};
int? startIndex;
int? endIndex;
CommitsCache(this.firestore, this.httpClient);
Future<Commit> getCommit(String hash) async {
var commit = byHash[hash] ?? await _fetchByHash(hash);
if (commit == null) {
await _getNewCommits();
commit = await _fetchByHash(hash);
}
if (commit == null) {
throw _makeError('getCommit($hash)');
}
return commit;
}
Future<Commit> getCommitByIndex(int index) async {
var commit = byIndex[index] ?? await _fetchByIndex(index);
if (commit == null) {
await _getNewCommits();
commit = await _fetchByIndex(index);
}
if (commit == null) {
throw _makeError('getCommitByIndex($index)');
}
return commit;
}
String _makeError(String message) {
final error = 'Failed to fetch commit: $message\n'
'Commit cache holds:\n'
' $startIndex: ${byIndex[startIndex ?? -1]}\n'
' ...\n'
' $endIndex: ${byIndex[endIndex ?? -1]}';
print(error);
return error;
}
/// Add a commit to the cache. The cache must be empty, or the commit
/// must be immediately before or after the current cached commits.
/// Otherwise, do nothing.
void _cacheCommit(Commit commit) {
final index = commit.index;
if (startIndex == null || startIndex == index + 1) {
startIndex = index;
endIndex ??= index;
} else if (endIndex! + 1 == index) {
endIndex = index;
} else {
return;
}
byHash[commit.hash] = commit;
byIndex[index] = commit;
}
Future<Commit?> _fetchByHash(String hash) async {
final commit = await firestore.getCommit(hash);
if (commit == null) return null;
final index = commit.index;
if (startIndex == null) {
_cacheCommit(commit);
} else if (index < startIndex!) {
for (var fetchIndex = startIndex! - 1; fetchIndex > index; --fetchIndex) {
// Other invocations may be fetching simultaneously.
if (fetchIndex < startIndex!) {
final infillCommit = await firestore.getCommitByIndex(fetchIndex);
_cacheCommit(infillCommit);
}
}
_cacheCommit(commit);
} else if (index > endIndex!) {
for (var fetchIndex = endIndex! + 1; fetchIndex < index; ++fetchIndex) {
// Other invocations may be fetching simultaneously.
if (fetchIndex > endIndex!) {
final infillCommit = await firestore.getCommitByIndex(fetchIndex);
_cacheCommit(infillCommit);
}
}
_cacheCommit(commit);
}
return commit;
}
Future<Commit?> _fetchByIndex(int index) => firestore
.getCommitByIndex(index)
.then((commit) => _fetchByHash(commit.hash));
/// This function is idempotent, so every call of it should write the
/// same info to new Firestore documents. It is safe to call multiple
/// times simultaneously. Intentionally returns null, not void.
Future<void> _getNewCommits() async {
const prefix = ")]}'\n";
final lastCommit = await firestore.getLastCommit();
final lastHash = lastCommit.hash;
final lastIndex = lastCommit.index;
final branch = 'master';
final logUrl = 'https://dart.googlesource.com/sdk/+log/';
final range = '$lastHash..$branch';
final parameters = ['format=JSON', 'topo-order', 'first-parent', 'n=1000'];
final url = Uri.parse('$logUrl$range?${parameters.join('&')}');
final response = await httpClient.get(url);
final protectedJson = response.body;
if (!protectedJson.startsWith(prefix)) {
throw Exception('Gerrit response missing prefix $prefix: $protectedJson.'
'Requested URL: $url');
}
final commits = List.castFrom<dynamic, Map<String, dynamic>>(
jsonDecode(protectedJson.substring(prefix.length))['log']);
if (commits.isEmpty) {
print('Found no new commits between $lastHash and $branch');
}
print('Fetched new commits from Gerrit (gitiles): $commits');
final first = commits.last;
if (first['parents'].first != lastHash) {
throw 'First new commit ${first['commit']} is not'
' a child of last known commit $lastHash when fetching new commits';
}
var index = lastIndex + 1;
for (final commit in commits.reversed) {
final review = _review(commit);
var reverted = _revert(commit);
var relanded = _reland(commit);
if (relanded != null) {
reverted = null;
}
if (reverted != null) {
final revertedCommit = await firestore.getCommit(reverted);
if (revertedCommit != null && revertedCommit.isRevert) {
reverted = null;
relanded = revertedCommit.revertOf;
}
}
await firestore.addCommit(commit['commit'], {
fAuthor: commit['author']['email'],
fCreated: parseGitilesDateTime(commit['committer']['time']),
fIndex: index,
fTitle: commit['message'].split('\n').first,
if (review != null) fReview: review,
if (reverted != null) fRevertOf: reverted,
if (relanded != null) fRelandOf: relanded,
});
if (review != null) {
await landReview(commit, index);
}
++index;
}
}
/// This function is idempotent and may be called multiple times
/// concurrently.
Future<void> landReview(Map<String, dynamic> commit, int index) async {
final review = _review(commit)!;
// Optimization to avoid duplicate work: if another instance has linked
// the review to its landed commit, do nothing.
if (await firestore.reviewIsLanded(review)) return;
await firestore.linkReviewToCommit(review, index);
await firestore.linkCommentsToCommit(review, index);
}
}
class TestingCommitsCache extends CommitsCache {
TestingCommitsCache(firestore, httpClient) : super(firestore, httpClient);
@override
Future<void> _getNewCommits() async {
if ((await firestore.isStaging())) {
return super._getNewCommits();
}
}
}
const months = {
'Jan': '01',
'Feb': '02',
'Mar': '03',
'Apr': '04',
'May': '05',
'Jun': '06',
'Jul': '07',
'Aug': '08',
'Sep': '09',
'Oct': '10',
'Nov': '11',
'Dec': '12'
};
DateTime parseGitilesDateTime(String gitiles) {
final parts = gitiles.split(' ');
final year = parts[4];
final month = months[parts[1]];
final day = parts[2].padLeft(2, '0');
return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
}
final reviewRegExp = RegExp(
'^Reviewed-on: https://dart-review.googlesource.com/c/sdk/\\+/(\\d+)\$',
multiLine: true);
int? _review(Map<String, dynamic> commit) {
final match = reviewRegExp.firstMatch(commit['message']);
if (match != null) return int.parse(match.group(1)!);
return null;
}
final revertRegExp =
RegExp('^This reverts commit ([\\da-f]+)\\.\$', multiLine: true);
String? _revert(Map<String, dynamic> commit) =>
revertRegExp.firstMatch(commit['message'])?.group(1);
final relandRegExp =
RegExp('^This is a reland of ([\\da-f]+)\\.?\$', multiLine: true);
String? _reland(Map<String, dynamic> commit) =>
relandRegExp.firstMatch(commit['message'])?.group(1);