[builder] Port results processing from cloud functions to script
This CL changes the result processing to a single script that uses
the firestore REST API to update the results for a build in a single
run, instead of processing multiple chunks in cloud functions.
Change-Id: Ic4cec086e5a167c58b8624de18fab17eaca271e3
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/203220
Reviewed-by: William Hesse <whesse@google.com>
diff --git a/builder/.gitignore b/builder/.gitignore
new file mode 100644
index 0000000..3c8a157
--- /dev/null
+++ b/builder/.gitignore
@@ -0,0 +1,6 @@
+# Files and directories created by pub.
+.dart_tool/
+.packages
+
+# Conventional directory for build output.
+build/
diff --git a/builder/analysis_options.yaml b/builder/analysis_options.yaml
new file mode 100644
index 0000000..5cc08da
--- /dev/null
+++ b/builder/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Defines a default set of lint rules enforced for projects at Google. For
+# details and rationale, see
+# https://github.com/dart-lang/pedantic#enabled-lints.
+
+include: package:pedantic/analysis_options.yaml
diff --git a/builder/bin/update_results_database.dart b/builder/bin/update_results_database.dart
new file mode 100644
index 0000000..a6e5a15
--- /dev/null
+++ b/builder/bin/update_results_database.dart
@@ -0,0 +1,87 @@
+// @dart = 2.9
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:args/args.dart';
+import 'package:builder/src/builder.dart';
+import 'package:builder/src/commits_cache.dart';
+import 'package:builder/src/firestore.dart';
+import 'package:builder/src/result.dart';
+import 'package:builder/src/tryjob.dart';
+import 'package:googleapis/firestore/v1.dart';
+import 'package:googleapis_auth/auth_io.dart';
+import 'package:http/http.dart' as http;
+
+// TODO(karlklose): Convert to streaming
+// Stream<Map> readChangedResults(File resultsFile) {
+// return resultsFile.openRead()
+// .transform(utf8.decoder) // Decode bytes to UTF-8.
+// .transform(LineSplitter())
+// .map((line) => jsonDecode as Map)
+// .where((result) => result['changed']);
+// }
+Future<List<Map<String, dynamic>>> readChangedResults(File resultsFile) async {
+ return (await resultsFile.readAsLines())
+ .map((line) => jsonDecode(line) as Map<String, dynamic>)
+ .where(isChangedResult)
+ .toList();
+}
+
+File fileOption(options, String name) {
+ final path = options[name];
+ if (path == null) {
+ print("Required option '$name'!");
+ exit(1);
+ }
+ final file = File(path);
+ if (!file.existsSync()) {
+ print('File not found: "$path"');
+ exit(1);
+ }
+ return file;
+}
+
+Future<void> processResults(options, client, firestore) async {
+ final project = options['project'] as String;
+ // TODO(karlklose): remove this when switching to production.
+ if (project != 'dart-ci-staging') throw 'Unuspported mode';
+ final inputFile = fileOption(options, 'results');
+ final results = await readChangedResults(inputFile);
+ if (results.isNotEmpty) {
+ final first = results.first;
+ final String commit = first['commit_hash'];
+ final String buildbucketID = options['buildbucket_id'];
+ final String baseRevision = options['base_revision'];
+ final commitCache = CommitsCache(firestore, client);
+ final process = commit.startsWith('refs/changes')
+ ? Tryjob(commit, buildbucketID, baseRevision, commitCache, firestore,
+ client)
+ .process
+ : Build(commit, first, commitCache, firestore).process;
+ await process(results);
+ }
+}
+
+void main(List<String> arguments) async {
+ final options = (ArgParser()
+ ..addOption('project',
+ abbr: 'p',
+ defaultsTo: 'dart-ci-staging',
+ allowed: ['dart-ci-staging'])
+ ..addOption('results', abbr: 'r')
+ ..addOption('buildbucket_id', abbr: 'i')
+ ..addOption('base_revision', abbr: 'b'))
+ .parse(arguments);
+
+ final baseClient = http.Client();
+ final client = await clientViaApplicationDefaultCredentials(
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
+ baseClient: baseClient);
+ final api = FirestoreApi(client);
+ final firestore = FirestoreService(api, client);
+
+ await processResults(options, client, firestore);
+
+ baseClient.close();
+}
diff --git a/builder/lib/src/builder.dart b/builder/lib/src/builder.dart
new file mode 100644
index 0000000..46d1d79
--- /dev/null
+++ b/builder/lib/src/builder.dart
@@ -0,0 +1,213 @@
+// Copyright (c) 2019, 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 'package:pool/pool.dart';
+
+import 'commits_cache.dart';
+import 'firestore.dart';
+import 'result.dart';
+import 'reverted_changes.dart';
+import 'tryjob.dart' show Tryjob;
+
+/// A Builder holds information about a CI build, and can
+/// store the changed results from that build, using an injected
+/// [FirestoreService] object.
+/// Tryjob builds are represented by the class [Tryjob] instead.
+class Build {
+ final FirestoreService firestore;
+ final CommitsCache commitsCache;
+ final String commitHash;
+ final Map<String, dynamic> firstResult;
+ String builderName;
+ int buildNumber;
+ int startIndex;
+ int endIndex;
+ Commit endCommit;
+ List<Commit> commits;
+ Map<String, int> tryApprovals = {};
+ List<RevertedChanges> allRevertedChanges = [];
+
+ bool success = true; // Changed to false if any unapproved failure is seen.
+ int countChanges = 0;
+ int commitsFetched;
+ List<String> approvalMessages = [];
+ int countApprovalsCopied = 0;
+
+ Build(this.commitHash, this.firstResult, this.commitsCache, this.firestore)
+ : builderName = firstResult['builder_name'],
+ buildNumber = int.parse(firstResult['build_number']);
+
+ void log(String string) => firestore.log(string);
+
+ Future<void> process(List<Map<String, dynamic>> results) async {
+ log('store build commits info');
+ await storeBuildCommitsInfo();
+ log('update build info');
+ if (!await firestore.updateBuildInfo(builderName, buildNumber, endIndex)) {
+ // This build's results have already been recorded.
+ log('build up-to-date, exiting');
+ // TODO(karlklose): add a flag to overwrite builder results.
+ return;
+ }
+ final configurations =
+ results.map((change) => change['configuration'] as String).toSet();
+ log('updating configurations');
+ await update(configurations);
+ final changes = results.where(isChangedResult);
+ log('storing ${changes.length} change(s)');
+ var count = 0;
+ await Pool(30).forEach(changes, (change) async {
+ await storeChange(change);
+ if (++count % 50 == 0) {
+ log('Processed $count changes...');
+ }
+ }).drain();
+ log('complete builder record');
+ await firestore.completeBuilderRecord(builderName, endIndex, success);
+
+ final report = [
+ 'Processed ${results.length} results from $builderName build $buildNumber',
+ if (countChanges > 0) 'Stored $countChanges changes',
+ if (commitsFetched != null) 'Fetched $commitsFetched new commits',
+ '${firestore.documentsFetched} documents fetched',
+ '${firestore.documentsWritten} documents written',
+ if (!success) 'Found unapproved new failures',
+ if (countApprovalsCopied > 0) ...[
+ '$countApprovalsCopied approvals copied',
+ ...approvalMessages,
+ if (countApprovalsCopied > 10) '...'
+ ]
+ ];
+ log(report.join('\n'));
+ }
+
+ Future<void> update(Iterable<String> configurations) async {
+ await storeConfigurationsInfo(configurations);
+ }
+
+ /// Stores the commit info for the blamelist of result.
+ /// If the info is already there does nothing.
+ /// Saves the commit indices of the start and end of the blamelist.
+ Future<void> storeBuildCommitsInfo() async {
+ // Get indices of change. Range includes startIndex and endIndex.
+ endCommit = await commitsCache.getCommit(commitHash);
+ if (endCommit == null) {
+ throw 'Result received with unknown commit hash $commitHash';
+ }
+ endIndex = endCommit.index;
+ // If this is a new builder, use the current commit as a trivial blamelist.
+ if (firstResult['previous_commit_hash'] == null) {
+ startIndex = endIndex;
+ } else {
+ final startCommit =
+ await commitsCache.getCommit(firstResult['previous_commit_hash']);
+ startIndex = startCommit.index + 1;
+ if (startIndex > endIndex) {
+ throw ArgumentError('Results received with empty blamelist\n'
+ 'previous commit: ${firstResult['previous_commit_hash']}\n'
+ 'built commit: $commitHash');
+ }
+ }
+ }
+
+ /// This async function's implementation runs exactly once.
+ /// Later invocations return the same future returned by the first invocation.
+ Future<void> fetchReviewsAndReverts() async {
+ commits = [
+ for (var index = startIndex; index < endIndex; ++index)
+ await commitsCache.getCommitByIndex(index),
+ endCommit
+ ];
+ for (final commit in commits) {
+ final index = commit.index;
+ final review = commit.review;
+ final reverted = commit.revertOf;
+ if (review != null) {
+ tryApprovals.addAll({
+ for (final result in await firestore.tryApprovals(review))
+ testResult(result): index
+ });
+ }
+ if (reverted != null) {
+ allRevertedChanges
+ .add(await getRevertedChanges(reverted, index, firestore));
+ }
+ }
+ }
+
+ Future<void> storeConfigurationsInfo(Iterable<String> configurations) async {
+ for (final configuration in configurations) {
+ await firestore.updateConfiguration(configuration, builderName);
+ }
+ }
+
+ Future<void> storeChange(Map<String, dynamic> change) async {
+ countChanges++;
+ await fetchReviewsAndReverts();
+ transformChange(change);
+ final failure = isFailure(change);
+ var approved;
+ var result = await firestore.findResult(change, startIndex, endIndex);
+ var activeResults = await firestore.findActiveResults(
+ change['name'], change['configuration']);
+ if (result == null) {
+ final approvingIndex = tryApprovals[testResult(change)] ??
+ allRevertedChanges
+ .firstWhere(
+ (revertedChange) => revertedChange.approveRevert(change),
+ orElse: () => null)
+ ?.revertIndex;
+ approved = approvingIndex != null;
+ final newResult = constructResult(change, startIndex, endIndex,
+ approved: approved,
+ landedReviewIndex: approvingIndex,
+ failure: failure);
+ await firestore.storeResult(newResult);
+ if (approved) {
+ countApprovalsCopied++;
+ if (countApprovalsCopied <= 10) {
+ approvalMessages
+ .add('Copied approval of result ${testResult(change)}');
+ }
+ }
+ } else {
+ approved = await firestore.updateResult(
+ result, change['configuration'], startIndex, endIndex,
+ failure: failure);
+ }
+ if (failure && !approved) success = false;
+
+ for (final activeResult in activeResults) {
+ final resultRecord = ResultRecord(activeResult.fields);
+ // Log error message if any expected invariants are violated
+ if (resultRecord.blamelistEndIndex >= startIndex ||
+ !resultRecord.containsActiveConfiguration(change['configuration'])) {
+ // log('Unexpected active result when processing new change:\n'
+ // 'Active result: ${untagMap(activeResult.fields)}\n\n'
+ // 'Change: $change\n\n'
+ // 'approved: $approved');
+ }
+ // Removes the configuration from the list of active configurations.
+ await firestore.updateActiveResult(activeResult, change['configuration']);
+ }
+ }
+}
+
+Map<String, dynamic> constructResult(
+ Map<String, dynamic> change, int startIndex, int endIndex,
+ {bool approved, int landedReviewIndex, bool failure}) {
+ return {
+ fName: change[fName],
+ fResult: change[fResult],
+ fPreviousResult: change[fPreviousResult],
+ fExpected: change[fExpected],
+ fBlamelistStartIndex: startIndex,
+ fBlamelistEndIndex: endIndex,
+ if (startIndex != endIndex && approved) fPinnedIndex: landedReviewIndex,
+ fConfigurations: <String>[change['configuration']],
+ fApproved: approved,
+ if (failure) fActive: true,
+ if (failure) fActiveConfigurations: <String>[change['configuration']]
+ };
+}
diff --git a/builder/lib/src/commits_cache.dart b/builder/lib/src/commits_cache.dart
new file mode 100644
index 0000000..2f98b31
--- /dev/null
+++ b/builder/lib/src/commits_cache.dart
@@ -0,0 +1,232 @@
+// 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 {
+ return byHash[hash] ??
+ await _fetchByHash(hash) ??
+ await _getNewCommits() ??
+ await _fetchByHash(hash) ??
+ _reportError('getCommit($hash)');
+ }
+
+ Future<Commit> getCommitByIndex(int index) async {
+ return byIndex[index] ??
+ await _fetchByIndex(index) ??
+ await _getNewCommits() ??
+ await _fetchByIndex(index) ??
+ _reportError('getCommitByIndex($index)');
+ }
+
+ Commit _reportError(String message) {
+ final error = 'Failed to fetch commit: $message\n'
+ 'Commit cache holds:\n'
+ ' $startIndex: ${byIndex[startIndex]}\n'
+ ' ...\n'
+ ' $endIndex: ${byIndex[endIndex]}';
+ print(error);
+ throw 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.
+ Future<Null> _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 = '$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 = jsonDecode(protectedJson.substring(prefix.length))['log']
+ as List<dynamic>;
+ if (commits.isEmpty) {
+ print('Found no new commits between $lastHash and $branch');
+ return;
+ }
+ print('Fetched new commits from Gerrit (gitiles): $commits');
+ final first = commits.last as Map<String, dynamic>;
+ 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 (Map<String, dynamic> 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<Null> _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);
diff --git a/builder/lib/src/firestore.dart b/builder/lib/src/firestore.dart
new file mode 100644
index 0000000..40c981b
--- /dev/null
+++ b/builder/lib/src/firestore.dart
@@ -0,0 +1,856 @@
+// Copyright (c) 2019, 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:convert';
+import 'dart:io';
+import 'dart:math' show max, min;
+import 'package:builder/src/result.dart';
+import 'package:googleapis/firestore/v1.dart';
+import 'package:http/http.dart' as http;
+
+class ResultRecord {
+ final Map<String, Value> fields;
+
+ ResultRecord(this.fields);
+
+ bool get approved => fields['approved'].booleanValue;
+
+ @override
+ String toString() => jsonEncode(fields);
+
+ int get blamelistEndIndex {
+ return int.parse(fields['blamelist_end_index'].integerValue);
+ }
+
+ bool containsActiveConfiguration(String configuration) {
+ for (final value in fields['active_configurations'].arrayValue.values) {
+ if (value.stringValue != null && value.stringValue == configuration) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+Map<String, Value> taggedMap(Map<String, dynamic> fields) {
+ return fields.map((key, value) => MapEntry(key, taggedValue(value)));
+}
+
+Value taggedValue(dynamic value) {
+ if (value is int) {
+ return Value()..integerValue = '$value';
+ } else if (value is String) {
+ return Value()..stringValue = value;
+ } else if (value is bool) {
+ return Value()..booleanValue = value;
+ } else if (value is DateTime) {
+ return Value()..timestampValue = value.toUtc().toIso8601String();
+ } else if (value is List) {
+ return Value()
+ ..arrayValue = (ArrayValue()
+ ..values = value.map((element) => taggedValue(element)).toList());
+ } else if (value == null) {
+ return Value()..nullValue = 'NULL_VALUE';
+ } else {
+ throw Exception('unsupported value type ${value.runtimeType}');
+ }
+}
+
+dynamic getValue(Value value) {
+ if (value.integerValue != null) {
+ return int.parse(value.integerValue);
+ } else if (value.stringValue != null) {
+ return value.stringValue;
+ } else if (value.booleanValue != null) {
+ return value.booleanValue;
+ } else if (value.arrayValue != null) {
+ return value.arrayValue.values.map(getValue).toList();
+ } else if (value.timestampValue != null) {
+ return DateTime.parse(value.timestampValue);
+ }
+ throw Exception('unsupported value ${value.toJson()}');
+}
+
+/// Converts a map with normal Dart values to a map where the values are
+/// JSON representation of firestore API values. For example: `{'x': 3}` is
+/// translated to `{'x': {'integerValue': 3}}`.
+Map<String, Object> taggedJsonMap(Map<String, dynamic> fields) {
+ return fields.map((key, value) => MapEntry(key, taggedValue(value).toJson()));
+}
+
+Map<String, dynamic> untagMap(Map<String, Value> map) {
+ return map.map((key, value) => MapEntry(key, getValue(value)));
+}
+
+List<CollectionSelector> inCollection(String name) {
+ return [CollectionSelector()..collectionId = name];
+}
+
+FieldReference field(String name) {
+ return FieldReference()..fieldPath = name;
+}
+
+Order orderBy(String fieldName, bool ascending) {
+ return Order()
+ ..field = field(fieldName)
+ ..direction = ascending ? 'ASCENDING' : 'DESCENDING';
+}
+
+Filter fieldEquals(String fieldName, dynamic value) {
+ return Filter()
+ ..fieldFilter = (FieldFilter()
+ ..field = field(fieldName)
+ ..op = 'EQUAL'
+ ..value = taggedValue(value));
+}
+
+Filter fieldLessThanOrEqual(String fieldName, dynamic value) {
+ return Filter()
+ ..fieldFilter = (FieldFilter()
+ ..field = field(fieldName)
+ ..op = 'LESS_THAN_OR_EQUAL'
+ ..value = taggedValue(value));
+}
+
+Filter fieldGreaterThanOrEqual(String fieldName, dynamic value) {
+ return Filter()
+ ..fieldFilter = (FieldFilter()
+ ..field = field(fieldName)
+ ..op = 'GREATER_THAN_OR_EQUAL'
+ ..value = taggedValue(value));
+}
+
+Filter arrayContains(String fieldName, dynamic value) {
+ return Filter()
+ ..fieldFilter = (FieldFilter()
+ ..field = field(fieldName)
+ ..op = 'ARRAY_CONTAINS'
+ ..value = taggedValue(value));
+}
+
+Filter compositeFilter(List<Filter> filters) {
+ return Filter()
+ ..compositeFilter = (CompositeFilter()
+ ..filters = filters
+ ..op = 'AND');
+}
+
+class DataWrapper {
+ final Map<String, Value> fields;
+ DataWrapper(Document document) : fields = document.fields;
+ DataWrapper.fields(this.fields);
+ int getInt(String name) {
+ final value = fields[name]?.integerValue;
+ if (value == null) {
+ return null;
+ }
+ return int.parse(value);
+ }
+
+ String getString(String name) {
+ return fields[name]?.stringValue;
+ }
+
+ bool getBool(String name) {
+ return fields[name]?.booleanValue;
+ }
+
+ List<dynamic> getList(String name) {
+ return fields[name]?.arrayValue?.values?.map(getValue)?.toList();
+ }
+
+ bool isNull(String name) {
+ return !fields.containsKey(name) ||
+ fields['name'].nullValue == 'NULL_VALUE';
+ }
+}
+
+class Commit {
+ final DataWrapper wrapper;
+ final String hash;
+ Commit(this.hash, Document document) : wrapper = DataWrapper(document);
+ Commit.fromJson(this.hash, Map<String, dynamic> data)
+ : wrapper = DataWrapper.fields(taggedMap(data));
+ int get index => wrapper.getInt('index');
+ String get revertOf => wrapper.getString(fRevertOf);
+ bool get isRevert => wrapper.fields.containsKey(fRevertOf);
+ int get review => wrapper.getInt(fReview);
+
+ Map<String, Object> toJson() => untagMap(wrapper.fields);
+}
+
+class FirestoreService {
+ final String project;
+ final FirestoreApi firestore;
+ final http.Client client;
+ int documentsFetched = 0;
+ int documentsWritten = 0;
+
+ final Stopwatch _stopwatch;
+
+ FirestoreService(this.firestore, this.client,
+ {this.project = 'dart-ci-staging'})
+ : _stopwatch = Stopwatch()..start();
+
+ void log(String string) {
+ final time = _stopwatch.elapsed.toString();
+ final lines = LineSplitter().convert(string);
+ for (final line in lines) {
+ print('$time $line');
+ }
+ }
+
+ Future<List<RunQueryResponse>> query(
+ {String from,
+ Filter where,
+ Order orderBy,
+ int limit,
+ String parent}) async {
+ final query = StructuredQuery();
+ if (from != null) {
+ query.from = inCollection(from);
+ }
+ if (where != null) {
+ query.where = where;
+ }
+ if (orderBy != null) {
+ query.orderBy = [orderBy];
+ }
+ if (limit != null) {
+ query.limit = limit;
+ }
+ final responses = await runQuery(query, parent: parent);
+ // The REST API will respond with a single `null` result in case the query
+ // did not match any documents.
+ if (responses.length == 1 && responses.single.document == null) {
+ return [];
+ }
+ return responses;
+ }
+
+ Future<Document> getDocument(String path,
+ {bool throwOnNotFound = true}) async {
+ try {
+ final document = await firestore.projects.databases.documents.get(path);
+ documentsFetched++;
+ return document;
+ } on DetailedApiRequestError catch (e) {
+ if (!throwOnNotFound && e.status == 404) {
+ return null;
+ } else {
+ log("Failed to get document '$path'");
+ rethrow;
+ }
+ }
+ }
+
+ String get databaseUri {
+ return 'https://firestore.googleapis.com/v1/$database';
+ }
+
+ String get database => 'projects/$project/databases/(default)';
+ String get documents => '$database/documents';
+
+ Future<List<RunQueryResponse>> runQuery(StructuredQuery query,
+ {String parent}) async {
+ // This is a workaround for a bug in googleapis' generated runQuery implementation
+ // (https://github.com/google/googleapis.dart/issues/25).
+ final request = RunQueryRequest()..structuredQuery = query;
+ final parentPath = parent == null ? '' : '/$parent';
+ final queryString = '$databaseUri/documents$parentPath:runQuery';
+ final requestBody = jsonEncode(request.toJson());
+ final response =
+ await client.post(Uri.parse(queryString), body: requestBody);
+ if (response.statusCode == HttpStatus.ok) {
+ final results = jsonDecode(response.body) as List;
+ return results
+ .map((result) => RunQueryResponse.fromJson(result))
+ .toList();
+ }
+ final message = '${response.statusCode}: ${response.body}';
+ throw Exception('Query error:\nurl=$queryString:\nquery=$query\n$message');
+ }
+
+ Future<void> writeDocument(Document document) async {
+ documentsWritten++;
+ final request = CommitRequest()..writes = [Write()..update = document];
+ await firestore.projects.databases.documents.commit(request, database);
+ }
+
+ Future<bool> isStaging() => Future.value(project == 'dart-ci-staging');
+
+ Future<bool> hasPatchset(String review, String patchset) {
+ return documentExists('$documents/$review/patchsets/$patchset');
+ }
+
+ Commit _commit(Document document) {
+ document.fields['hash'] = taggedValue(document.name.split('/').last);
+ return Commit(document.name.split('/').last, document);
+ }
+
+ Future<Commit> getCommit(String hash) async {
+ final document =
+ await getDocument('$documents/commits/$hash', throwOnNotFound: false);
+ return document != null ? Commit(hash, document) : null;
+ }
+
+ Future<Commit> getCommitByIndex(int index) async {
+ final response =
+ await query(from: 'commits', where: fieldEquals('index', index));
+ return _commit(response.first.document);
+ }
+
+ Future<Commit> getLastCommit() async {
+ final lastCommit =
+ await query(from: 'commits', orderBy: orderBy('index', false));
+ return _commit(lastCommit.first.document);
+ }
+
+ Future<void> addCommit(String id, Map<String, dynamic> data) async {
+ try {
+ final document = Document.fromJson({'fields': taggedJsonMap(data)});
+ await firestore.projects.databases.documents
+ .createDocument(document, documents, 'commits', documentId: id);
+ log("Added commit $id -> ${data['index']}");
+ } on DetailedApiRequestError catch (e) {
+ if (e.status != 409) {
+ rethrow;
+ }
+ // The document exists, we can ignore this error as the data is already
+ // correct.
+ }
+ }
+
+ Future<void> updateConfiguration(String configuration, String builder) async {
+ final record = await getDocument('$documents/configurations/$configuration',
+ throwOnNotFound: false);
+ if (record == null) {
+ final newRecord = Document.fromJson({
+ 'fields': taggedJsonMap({'builder': builder})
+ });
+ await firestore.projects.databases.documents.createDocument(
+ newRecord, documents, 'configurations',
+ documentId: configuration);
+ log('Configuration document $configuration -> $builder created');
+ } else {
+ final originalBuilder = DataWrapper(record).getString('builder');
+ if (originalBuilder != builder) {
+ record.fields['builder'].stringValue = builder;
+ await updateFields(record, ['builder']);
+ log('Configuration document changed: $configuration -> $builder '
+ '(was $originalBuilder)');
+ }
+ }
+ }
+
+ /// Ensures that a build record for this build exists.
+ ///
+ /// Returns `true` if and only if there is no completed record
+ /// for this build.
+ Future<bool> updateBuildInfo(
+ String builder, int buildNumber, int index) async {
+ final record = await getDocument('$documents/builds/$builder:$index',
+ throwOnNotFound: false);
+ if (record == null) {
+ final newRecord = Document.fromJson({
+ 'fields': taggedJsonMap(
+ {'builder': builder, 'build_number': buildNumber, 'index': index})
+ });
+ await firestore.projects.databases.documents.createDocument(
+ newRecord, documents, 'builds',
+ documentId: '$builder:$index');
+ return true;
+ } else {
+ final data = DataWrapper(record);
+ final existingIndex = data.getInt('index');
+ if (existingIndex != index) {
+ throw ('Build $buildNumber of $builder had commit index '
+ '$existingIndex, should be $index.');
+ }
+ return data.getBool('completed') != true;
+ }
+ }
+
+ /// Ensures that a build record for this build exists.
+ ///
+ /// Returns `true` if and only if there is no completed record
+ /// for this build.
+ Future<bool> updateTryBuildInfo(String builder, int buildNumber,
+ String buildbucketID, int review, int patchset, bool success) async {
+ final name = '$builder:$review:$patchset';
+ final record = await getDocument('$documents/try_builds/$name',
+ throwOnNotFound: false);
+ if (record == null) {
+ final newRecord = Document.fromJson({
+ 'fields': taggedJsonMap({
+ 'builder': builder,
+ 'build_number': buildNumber,
+ if (buildbucketID != null) 'buildbucket_id': buildbucketID,
+ 'review': review,
+ 'patchset': patchset,
+ })
+ });
+ log('creating try-build record for $builder $buildNumber $review $patchset:'
+ ' $name');
+ await firestore.projects.databases.documents
+ .createDocument(newRecord, documents, 'try_builds', documentId: name);
+ return true;
+ } else {
+ final data = DataWrapper(record);
+ final existingBuildNumber = data.getInt('build_number');
+ if (existingBuildNumber > buildNumber) {
+ throw 'Received chunk from previous build $buildNumber'
+ ' after chunk from a later build ($existingBuildNumber)';
+ }
+ if (existingBuildNumber < buildNumber) {
+ record.fields = taggedMap({
+ 'builder': builder,
+ 'build_number': buildNumber,
+ if (buildbucketID != null) 'buildbucket_id': buildbucketID,
+ 'review': review,
+ 'patchset': patchset,
+ });
+ await _executeWrite([Write()..update = record]);
+ return true;
+ }
+ return data.getBool('completed') != true;
+ }
+ }
+
+ Future<String> findResult(
+ Map<String, dynamic> change, int startIndex, int endIndex) async {
+ final name = change['name'] as String;
+ final result = change['result'] as String;
+ final previousResult = change['previous_result'] as String;
+ final expected = change['expected'] as String;
+ final snapshot = await query(
+ from: 'results',
+ orderBy: orderBy('blamelist_end_index', false),
+ where: compositeFilter([
+ fieldEquals('name', name),
+ fieldEquals('result', result),
+ fieldEquals('previous_result', previousResult),
+ fieldEquals('expected', expected)
+ ]),
+ limit: 5);
+
+ bool blamelistIncludesChange(RunQueryResponse response) {
+ final document = response.document;
+ if (document == null) return false;
+ final groupStart =
+ int.parse(document.fields['blamelist_start_index'].integerValue);
+ final groupEnd =
+ int.parse(document.fields['blamelist_end_index'].integerValue);
+ return startIndex <= groupEnd && endIndex >= groupStart;
+ }
+
+ return snapshot
+ .firstWhere(blamelistIncludesChange, orElse: () => null)
+ ?.document
+ ?.name;
+ }
+
+ Future<void> storeResult(Map<String, dynamic> result) async {
+ final document = Document.fromJson({'fields': taggedJsonMap(result)});
+ final createdDocument = await firestore.projects.databases.documents
+ .createDocument(document, documents, 'results');
+ log('created document ${createdDocument.name}');
+ }
+
+ Future<bool> updateResult(
+ String result, String configuration, int startIndex, int endIndex,
+ {bool failure}) async {
+ var approved;
+ await retryCommit(() async {
+ final document = await getDocument(result);
+ final data = DataWrapper(document);
+ // Allow missing 'approved' field during transition period.
+ approved = data.getBool('approved') ?? false;
+ // Add the new configuration and narrow the blamelist.
+ final newStart = max(startIndex, data.getInt('blamelist_start_index'));
+ final newEnd = min(endIndex, data.getInt('blamelist_end_index'));
+ // TODO(karlklose): check for pinned, and remove the pin if the new range
+ // doesn't include it?
+ final updates = [
+ 'blamelist_start_index',
+ 'blamelist_end_index',
+ if (failure) 'active'
+ ];
+ document.fields['blamelist_start_index'] = taggedValue(newStart);
+ document.fields['blamelist_end_index'] = taggedValue(newEnd);
+ if (failure) {
+ document.fields['active'] = taggedValue(true);
+ }
+ final addConfiguration = ArrayValue()
+ ..values = [taggedValue(configuration)];
+ final write = Write()
+ ..currentDocument = (Precondition()..updateTime = document.updateTime)
+ ..update = document
+ ..updateMask = (DocumentMask()..fieldPaths = updates)
+ ..updateTransforms = [
+ FieldTransform()
+ ..fieldPath = 'configurations'
+ ..appendMissingElements = addConfiguration,
+ if (failure)
+ FieldTransform()
+ ..fieldPath = 'active_configurations'
+ ..appendMissingElements = addConfiguration
+ ];
+
+ return write;
+ });
+ return approved;
+ }
+
+ Future retryCommit(Future<Write> Function() request) async {
+ while (true) {
+ final write = await request();
+ try {
+ // Use commit instead of write to check for the precondition on the
+ // document
+ final request = CommitRequest()..writes = [write];
+ documentsWritten++;
+ await firestore.projects.databases.documents.commit(request, database);
+ return;
+ } catch (e) {
+ log('Error while writing data: $e, retrying...');
+ sleep(Duration(milliseconds: 100));
+ }
+ }
+ }
+
+ /// Returns all results which are either pinned to or have a range that is
+ /// this single index. // TODO: rename this function
+ Future<List<Map<String, Value>>> findRevertedChanges(int index) async {
+ final pinnedResults =
+ await query(from: 'results', where: fieldEquals('pinned_index', index));
+ final results =
+ pinnedResults.map((response) => response.document.fields).toList();
+ final unpinnedResults = await query(
+ from: 'results', where: fieldEquals('blamelist_end_index', index));
+ for (final unpinnedResult in unpinnedResults) {
+ final data = DataWrapper(unpinnedResult.document);
+ if (data.getInt('blamelist_start_index') == index &&
+ data.isNull('pinned_index')) {
+ results.add(unpinnedResult.document.fields);
+ }
+ }
+ return results;
+ }
+
+ Future<bool> storeTryChange(
+ Map<String, dynamic> change, int review, int patchset) async {
+ final name = change['name'] as String;
+ final result = change['result'] as String;
+ final expected = change['expected'] as String;
+ final previousResult = change['previous_result'] as String;
+ final configuration = change['configuration'] as String;
+
+ // Find an existing result record for this test on this patchset.
+ final responses = await query(
+ from: 'try_results',
+ where: compositeFilter([
+ fieldEquals('review', review),
+ fieldEquals('patchset', patchset),
+ fieldEquals('name', name),
+ fieldEquals('result', result),
+ fieldEquals('previous_result', previousResult),
+ fieldEquals('expected', expected)
+ ]),
+ limit: 1);
+ // TODO(karlklose): We could run only this query, and then see if the
+ // patchset is equal or not. We don't need the separate
+ // query with equal patchset.
+ // The test for previous.isNotEmpty below can be replaced
+ // with responses.patchset != patchset. We need to hit
+ // the createDocument both if there was a previous response,
+ // or there is no response at all.
+ if (responses.isEmpty) {
+ // Is the previous result for this test on this review approved?
+ final previous = await query(
+ from: 'try_results',
+ where: compositeFilter([
+ fieldEquals('review', review),
+ fieldEquals('name', name),
+ fieldEquals('result', result),
+ fieldEquals('previous_result', previousResult),
+ fieldEquals('expected', expected),
+ ]),
+ orderBy: orderBy('patchset', false),
+ limit: 1);
+ final approved = previous.isNotEmpty &&
+ DataWrapper(previous.first.document).getBool('approved') == true;
+
+ final document = Document.fromJson({
+ 'fields': taggedJsonMap({
+ 'name': name,
+ 'result': result,
+ 'previous_result': previousResult,
+ 'expected': expected,
+ 'review': review,
+ 'patchset': patchset,
+ 'configurations': <String>[configuration],
+ 'approved': approved
+ })
+ });
+ await firestore.projects.databases.documents.createDocument(
+ document,
+ 'projects/dart-ci-staging/databases/(default)/documents',
+ 'try_results',
+ mask_fieldPaths: [
+ 'name',
+ 'result',
+ 'previous_result',
+ 'expected',
+ 'review',
+ 'patchset',
+ 'configurations',
+ 'approved'
+ ]);
+ return approved;
+ } else {
+ final document = responses.first.document;
+ // Update the TryResult for this test, adding this configuration.
+ final values = ArrayValue()..values = [taggedValue(configuration)];
+ final addConfiguration = FieldTransform()
+ ..fieldPath = 'configurations'
+ ..appendMissingElements = values;
+ await _executeWrite([
+ Write()
+ ..update = document
+ ..updateTransforms = [addConfiguration]
+ ]);
+ return DataWrapper(document).getBool('approved') == true;
+ }
+ }
+
+ Future<void> approveResult(Document document) async {
+ document.fields['approved'] = taggedValue(true);
+ await _executeWrite([
+ Write()
+ ..update = document
+ ..updateMask = (DocumentMask()..fieldPaths = ['approved'])
+ ]);
+ }
+
+ /// Removes [configuration] from the active configurations and marks the
+ /// active result inactive when we remove the last active config.
+ Future<void> updateActiveResult(
+ Document activeResult, String configuration) async {
+ final data = DataWrapper(activeResult);
+ final configurations = data.getList('active_configurations');
+ assert(configurations.contains(configuration));
+ if (configurations.length > 1) {
+ await removeArrayEntry(
+ activeResult, 'active_configurations', taggedValue(configuration));
+ activeResult = await getDocument(activeResult.name);
+ }
+ if (DataWrapper(activeResult).getList('active_configurations').isEmpty) {
+ activeResult.fields.remove('active_configurations');
+ activeResult.fields.remove('active');
+ final write = Write()
+ ..update = activeResult
+ ..updateMask =
+ (DocumentMask()..fieldPaths = ['active', 'active_configurations']);
+ await _executeWrite([write]);
+ }
+ }
+
+ Future<void> removeArrayEntry(
+ Document document, String fieldName, Value entry) async {
+ await _executeWrite([
+ Write()
+ ..transform = (DocumentTransform()
+ ..fieldTransforms = [
+ FieldTransform()
+ ..fieldPath = fieldName
+ ..removeAllFromArray = (ArrayValue()..values = [entry])
+ ])
+ ]);
+ }
+
+ Future<List<Document>> findActiveResults(
+ String name, String configuration) async {
+ final results = await query(
+ from: 'results',
+ where: compositeFilter([
+ arrayContains('active_configurations', configuration),
+ fieldEquals('active', true),
+ fieldEquals('name', name)
+ ]));
+ if (results.length > 1) {
+ log([
+ 'Multiple active results for the same configuration and test',
+ ...results
+ ].join('\n'));
+ }
+ return results.map((RunQueryResponse result) => result.document).toList();
+ }
+
+ Future storeReview(String review, Map<String, dynamic> data) {
+ final fields = {'fields': taggedJsonMap(data)};
+ final document = Document.fromJson(fields);
+ documentsWritten++;
+ return firestore.projects.databases.documents
+ .createDocument(document, documents, 'reviews', documentId: '$review');
+ }
+
+ Future<void> deleteDocument(String name) async {
+ return _executeWrite([Write()..delete = name]);
+ }
+
+ Future<bool> documentExists(String name) async {
+ return (await getDocument(name, throwOnNotFound: false) != null);
+ }
+
+ Future _executeWrite(List<Write> writes) async {
+ const debug = false;
+ final request = BatchWriteRequest()..writes = writes;
+ if (debug) {
+ log('WriteRequest: ${request.toJson()}');
+ }
+ documentsWritten++;
+ return firestore.projects.databases.documents.batchWrite(request, database);
+ }
+
+ Future<void> updateFields(Document document, List<String> fields) async {
+ await _executeWrite([
+ Write()
+ ..update = document
+ ..updateMask = (DocumentMask()..fieldPaths = fields)
+ ]);
+ }
+
+ Future<void> storePatchset(String review, int patchset, String kind,
+ String description, int patchsetGroup, int number) async {
+ final document = Document.fromJson({
+ 'fields': taggedJsonMap({
+ 'kind': kind,
+ 'description': description,
+ 'patchset_group': patchsetGroup,
+ 'number': number
+ })
+ });
+ await firestore.projects.databases.documents.createDocument(
+ document, '$documents/reviews/$review', 'patchsets',
+ documentId: '$patchset');
+ }
+
+ /// Returns true if a review record in the database has a landed_index field,
+ /// or if there is no record for the review in the database. Reviews with no
+ /// test failures have no record, and don't need to be linked when landing.
+ Future<bool> reviewIsLanded(int review) async {
+ final document =
+ await getDocument('$documents/reviews/$review', throwOnNotFound: false);
+ if (document == null) {
+ return true;
+ }
+ return document.fields.containsKey('landed_index');
+ }
+
+ Future<void> linkReviewToCommit(int review, int index) async {
+ final document = await getDocument('$documents/reviews/$review');
+ document.fields['landed_index'] = taggedValue(index);
+ await updateFields(document, ['landed_index']);
+ }
+
+ Future<void> linkCommentsToCommit(int review, int index) async {
+ final comments =
+ await query(from: 'comments', where: fieldEquals('review', review));
+ if (comments.isEmpty) return;
+ final writes = <Write>[];
+ for (final comment in comments) {
+ final document = comment.document;
+ document.fields['blamelist_start_index'] = taggedValue(index);
+ document.fields['blamelist_end_index'] = taggedValue(index);
+ writes.add(Write()
+ ..update = document
+ ..updateMask = (DocumentMask()
+ ..fieldPaths = ['blamelist_start_index', 'blamelist_end_index']));
+ }
+ await _executeWrite(writes);
+ }
+
+ Future<List<Map<String, Value>>> tryApprovals(int review) async {
+ final patchsets = await query(
+ from: 'patchsets',
+ parent: 'reviews/$review',
+ orderBy: orderBy('number', false),
+ limit: 1);
+ if (patchsets.isEmpty) {
+ return [];
+ }
+ final lastPatchsetGroup =
+ DataWrapper(patchsets.first.document).getInt('patchset_group');
+ final approvals = await query(
+ from: 'try_results',
+ where: compositeFilter([
+ fieldEquals('approved', true),
+ fieldEquals('review', review),
+ fieldGreaterThanOrEqual('patchset', lastPatchsetGroup)
+ ]));
+
+ return approvals.map((response) => response.document.fields).toList();
+ }
+
+ Future<List<Map<String, Value>>> tryResults(
+ int review, String configuration) async {
+ final patchsets = await query(
+ from: 'patchsets',
+ parent: 'reviews/$review',
+ orderBy: orderBy('number', false),
+ limit: 1);
+ if (patchsets.isEmpty) {
+ return [];
+ }
+ final lastPatchsetGroup =
+ DataWrapper(patchsets.first.document).getInt('patchset_group');
+ final approvals = await query(
+ from: 'try_results',
+ where: compositeFilter([
+ fieldEquals('review', review),
+ arrayContains('configurations', configuration),
+ fieldGreaterThanOrEqual('patchset', lastPatchsetGroup)
+ ]));
+ return approvals.map((r) => r.document.fields).toList();
+ }
+
+ Future<void> completeBuilderRecord(
+ String builder, int index, bool success) async {
+ final path = '$documents/builds/$builder:$index';
+ final document = await getDocument(path);
+ await _completeBuilderRecord(document, success);
+ }
+
+ Future<void> completeTryBuilderRecord(
+ String builder, int review, int patchset, bool success) async {
+ final path = '$documents/try_builds/$builder:$review:$patchset';
+ final document = await getDocument(path);
+ await _completeBuilderRecord(document, success);
+ }
+
+ Future<void> _completeBuilderRecord(Document document, bool success) async {
+ await retryCommit(() async {
+ final data = DataWrapper(document);
+ // TODO: Legacy support, remove if not needed anymore.
+ document.fields['processed_chunks'] = taggedValue(1);
+ document.fields['num_chunks'] = taggedValue(1);
+ document.fields['success'] =
+ taggedValue((data.getBool('success') ?? true) && success);
+ document.fields['completed'] = taggedValue(true);
+
+ final write = Write()
+ ..update = document
+ ..updateMask = (DocumentMask()
+ ..fieldPaths = [
+ 'processed_chunks',
+ 'num_chunks',
+ 'success',
+ 'completed'
+ ])
+ ..currentDocument = (Precondition()..updateTime = document.updateTime);
+ return write;
+ });
+ }
+}
diff --git a/builder/lib/src/gerrit_change.dart b/builder/lib/src/gerrit_change.dart
new file mode 100644
index 0000000..a9ba26d
--- /dev/null
+++ b/builder/lib/src/gerrit_change.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2019, 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:convert';
+
+import 'package:http/http.dart' as http;
+
+import 'firestore.dart';
+
+class GerritInfo {
+ static const gerritUrl = 'https://dart-review.googlesource.com/changes';
+ static const gerritQuery =
+ 'o=ALL_REVISIONS&o=DETAILED_ACCOUNTS&o=CURRENT_COMMIT';
+ static const trivialKinds = {
+ 'TRIVIAL_REBASE',
+ 'NO_CHANGE',
+ 'NO_CODE_CHANGE',
+ };
+ static const prefix = ")]}'\n";
+
+ http.BaseClient httpClient;
+ FirestoreService firestore;
+ String review;
+ String patchset;
+
+ GerritInfo(int review, int patchset, this.firestore, this.httpClient) {
+ this.review = review.toString();
+ this.patchset = patchset.toString();
+ }
+
+ /// Fetches the owner, changeId, message, and date of a Gerrit change and
+ /// stores them in the databases.
+ Future<void> update() async {
+ if (await firestore.hasPatchset(review, patchset)) return;
+ // Get the Gerrit change's commit from the Gerrit API.
+ final url = '$gerritUrl/$review?$gerritQuery';
+ final response = await httpClient.get(url);
+ final protectedJson = response.body;
+ if (!protectedJson.startsWith(prefix)) {
+ throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
+ }
+ final reviewInfo = jsonDecode(protectedJson.substring(prefix.length))
+ as Map<String, dynamic>;
+ final reverted = revert(reviewInfo);
+ await firestore.storeReview(review, {
+ 'subject': taggedValue(reviewInfo['subject']),
+ if (reverted != null) 'revert_of': taggedValue(reverted)
+ });
+
+ // Add the patchset information to the patchsets subcollection.
+ final revisions = reviewInfo['revisions'].values.toList()
+ ..sort((a, b) => (a['_number'] as int).compareTo(b['_number']));
+ int patchsetGroupFirst;
+ for (Map<String, dynamic> revision in revisions) {
+ int number = revision['_number'];
+ if (!trivialKinds.contains(revision['kind'])) {
+ patchsetGroupFirst = number;
+ }
+ await firestore.storePatchset(review, number, revision['kind'],
+ revision['description'], patchsetGroupFirst, number);
+ }
+ }
+
+ static String revert(Map<String, dynamic> reviewInfo) {
+ final current = reviewInfo['current_revision'];
+ final commit = reviewInfo['revisions'][current]['commit'];
+ final regExp =
+ RegExp('^This reverts commit ([\\da-f]+)\\.\$', multiLine: true);
+ return regExp.firstMatch(commit['message'])?.group(1);
+ }
+}
diff --git a/builder/lib/src/result.dart b/builder/lib/src/result.dart
new file mode 100644
index 0000000..b1271e9
--- /dev/null
+++ b/builder/lib/src/result.dart
@@ -0,0 +1,67 @@
+// 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.
+
+// Field names and helper functions for result documents and
+// commit documents from Firestore.
+
+// Field names of Result document fields
+
+import 'package:googleapis/firestore/v1.dart' show Value;
+
+const fName = 'name';
+const fResult = 'result';
+const fPreviousResult = 'previous_result';
+const fExpected = 'expected';
+const fChanged = 'changed';
+const fMatches = 'matches';
+const fFlaky = 'flaky';
+const fPreviousFlaky = 'previous_flaky';
+const fPinnedIndex = 'pinned_index';
+const fBlamelistStartIndex = 'blamelist_start_index';
+const fBlamelistEndIndex = 'blamelist_end_index';
+const fApproved = 'approved';
+const fActive = 'active';
+const fConfigurations = 'configurations';
+const fActiveConfigurations = 'active_configurations';
+
+bool isChangedResult(Map<String, dynamic> change) =>
+ change[fChanged] && (!change[fFlaky] || !change[fPreviousFlaky]);
+
+/// Whether the change will be marked as an active failure.
+/// New flaky tests will not be marked active, so they will appear in the
+/// results feed "all", but not turn the builder red
+bool isFailure(Map<String, dynamic> change) =>
+ !change[fMatches] && change[fResult] != 'flaky';
+
+void transformChange(Map<String, dynamic> change) {
+ change[fPreviousResult] ??= 'new test';
+ if (change[fPreviousFlaky]) {
+ change[fPreviousResult] = 'flaky';
+ }
+ if (change[fFlaky]) {
+ change[fResult] = 'flaky';
+ change[fMatches] = false;
+ }
+}
+
+String fromStringOrValue(dynamic value) {
+ return value is Value ? value.stringValue : value;
+}
+
+String testResult(Map<String, dynamic> change) => [
+ fromStringOrValue(change[fName]),
+ fromStringOrValue(change[fResult]),
+ fromStringOrValue(change[fPreviousResult]),
+ fromStringOrValue(change[fExpected])
+ ].join(' ');
+
+// Field names of commit document fields
+const fHash = 'hash';
+const fIndex = 'index';
+const fAuthor = 'author';
+const fCreated = 'created';
+const fTitle = 'title';
+const fReview = 'review';
+const fRevertOf = 'revert_of';
+const fRelandOf = 'reland_of';
diff --git a/builder/lib/src/reverted_changes.dart b/builder/lib/src/reverted_changes.dart
new file mode 100644
index 0000000..d7bf314
--- /dev/null
+++ b/builder/lib/src/reverted_changes.dart
@@ -0,0 +1,39 @@
+// 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 'package:collection/collection.dart';
+import 'package:googleapis/firestore/v1.dart';
+
+import 'firestore.dart';
+import 'result.dart';
+
+Future<RevertedChanges> getRevertedChanges(
+ String reverted, int revertIndex, FirestoreService firestore) async {
+ final revertedCommit = await firestore.getCommit(reverted);
+ if (revertedCommit == null) {
+ throw 'Cannot find commit for reverted commit hash $reverted';
+ }
+ final index = revertedCommit.index;
+ final changes = await firestore.findRevertedChanges(index);
+ return RevertedChanges(index, revertIndex, changes,
+ groupBy(changes, (change) => getValue(change[fName])));
+}
+
+class RevertedChanges {
+ final int index;
+ final int revertIndex;
+ final List<Map<String, Value>> changes;
+ final Map<String, List<Map<String, Value>>> changesForTest;
+
+ RevertedChanges(
+ this.index, this.revertIndex, this.changes, this.changesForTest);
+
+ bool approveRevert(Map<String, dynamic> revert) {
+ final reverted = changesForTest[revert[fName]];
+ return isFailure(revert) &&
+ reverted != null &&
+ reverted.any(
+ (change) => revert[fResult] == getValue(change[fPreviousResult]));
+ }
+}
diff --git a/builder/lib/src/tryjob.dart b/builder/lib/src/tryjob.dart
new file mode 100644
index 0000000..f9907a7
--- /dev/null
+++ b/builder/lib/src/tryjob.dart
@@ -0,0 +1,141 @@
+// Copyright (c) 2019, 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 'package:collection/collection.dart';
+import 'package:googleapis/firestore/v1.dart';
+import 'package:http/http.dart' as http show BaseClient;
+import 'package:pool/pool.dart';
+
+import 'commits_cache.dart';
+import 'firestore.dart';
+import 'gerrit_change.dart';
+import 'result.dart';
+
+class Tryjob {
+ static final changeRefRegExp = RegExp(r'refs/changes/(\d*)/(\d*)');
+ final http.BaseClient httpClient;
+ final FirestoreService firestore;
+ final CommitsCache commits;
+ String builderName;
+ String baseRevision;
+ String baseResultsHash;
+ int buildNumber;
+ int review;
+ int patchset;
+ bool success = true;
+ List<Map<String, Value>> landedResults;
+ Map<String, Map<String, Value>> lastLandedResultByName = {};
+ final String buildbucketID;
+ int countChanges = 0;
+ int countUnapproved = 0;
+ int countNewFlakes = 0;
+
+ Tryjob(String changeRef, this.buildbucketID, this.baseRevision, this.commits,
+ this.firestore, this.httpClient) {
+ final match = changeRefRegExp.matchAsPrefix(changeRef);
+ review = int.parse(match[1]);
+ patchset = int.parse(match[2]);
+ }
+
+ void log(String string) => firestore.log(string);
+
+ Future<void> update() async {
+ await GerritInfo(review, patchset, firestore, httpClient).update();
+ }
+
+ bool isNotLandedResult(Map<String, dynamic> change) =>
+ !lastLandedResultByName.containsKey(change[fName]) ||
+ change[fResult] != lastLandedResultByName[change[fName]][fResult];
+
+ Future<void> process(List<Map<String, dynamic>> results) async {
+ await update();
+ builderName = results.first['builder_name'];
+ buildNumber = int.parse(results.first['build_number']);
+
+ if (!await firestore.updateTryBuildInfo(
+ builderName, buildNumber, buildbucketID, review, patchset, success)) {
+ // This build's results have already been recorded.
+ log('build up-to-date, exiting');
+ return;
+ }
+
+ baseResultsHash = results.first['previous_commit_hash'];
+ final resultsByConfiguration = groupBy<Map<String, dynamic>, String>(
+ results.where(isChangedResult), (result) => result['configuration']);
+
+ for (final configuration in resultsByConfiguration.keys) {
+ log('Fetching landed results for configuration $configuration');
+ if (baseRevision != null && baseResultsHash != null) {
+ landedResults = await fetchLandedResults(configuration);
+ // Map will contain the last result with each name.
+ lastLandedResultByName = {
+ for (final result in landedResults)
+ getValue(result[fName]) as String: result
+ };
+ }
+ log('Processing results');
+ await Pool(30)
+ .forEach(
+ resultsByConfiguration[configuration].where(isNotLandedResult),
+ storeChange)
+ .drain();
+ }
+
+ log('complete builder record');
+ await firestore.completeTryBuilderRecord(
+ builderName, review, patchset, success);
+
+ final report = [
+ 'Processed ${results.length} results from $builderName build $buildNumber',
+ 'Tryjob on CL $review patchset $patchset',
+ if (countChanges > 0) 'Stored $countChanges changes',
+ if (!success) 'Found unapproved new failures',
+ if (countUnapproved > 0) '$countUnapproved unapproved tests found',
+ if (countNewFlakes > 0) '$countNewFlakes new flaky tests found',
+ '${firestore.documentsFetched} documents fetched',
+ '${firestore.documentsWritten} documents written',
+ ];
+ log(report.join('\n'));
+ }
+
+ Future<void> storeChange(Map<String, dynamic> change) async {
+ countChanges++;
+ transformChange(change);
+ final approved = await firestore.storeTryChange(change, review, patchset);
+ if (!approved && isFailure(change)) {
+ countUnapproved++;
+ success = false;
+ }
+ if (change[fFlaky] && !change[fPreviousFlaky]) {
+ if (++countNewFlakes >= 10) {
+ success = false;
+ }
+ }
+ }
+
+ Future<List<Map<String, Value>>> fetchLandedResults(
+ String configuration) async {
+ final resultsBase = await commits.getCommit(baseResultsHash);
+ final rebaseBase = await commits.getCommit(baseRevision);
+ final results = <Map<String, Value>>[];
+ if (resultsBase.index > rebaseBase.index) {
+ print('Try build is rebased on $baseRevision, which is before '
+ 'the commit $baseResultsHash with CI comparison results');
+ return results;
+ }
+ final reviews = <int>[];
+ for (var index = resultsBase.index + 1;
+ index <= rebaseBase.index;
+ ++index) {
+ final commit = await commits.getCommitByIndex(index);
+ if (commit.review != null) {
+ reviews.add(commit.review);
+ }
+ }
+ for (final landedReview in reviews) {
+ results.addAll(await firestore.tryResults(landedReview, configuration));
+ }
+ return results;
+ }
+}
diff --git a/builder/pubspec.lock b/builder/pubspec.lock
new file mode 100644
index 0000000..26f1a3c
--- /dev/null
+++ b/builder/pubspec.lock
@@ -0,0 +1,586 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ _discoveryapis_commons:
+ dependency: transitive
+ description:
+ name: _discoveryapis_commons
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.1"
+ analyzer:
+ dependency: transitive
+ description:
+ name: analyzer
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.38.5"
+ args:
+ dependency: "direct main"
+ description:
+ name: args
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.6.0"
+ async:
+ dependency: transitive
+ description:
+ name: async
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.4.2"
+ bazel_worker:
+ dependency: transitive
+ description:
+ name: bazel_worker
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.1.25"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ build:
+ dependency: transitive
+ description:
+ name: build
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.2"
+ build_config:
+ dependency: transitive
+ description:
+ name: build_config
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.4.2"
+ build_daemon:
+ dependency: transitive
+ description:
+ name: build_daemon
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.10"
+ build_modules:
+ dependency: transitive
+ description:
+ name: build_modules
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.9.0"
+ build_node_compilers:
+ dependency: "direct dev"
+ description:
+ name: build_node_compilers
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.4"
+ build_resolvers:
+ dependency: transitive
+ description:
+ name: build_resolvers
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.1"
+ build_runner:
+ dependency: "direct dev"
+ description:
+ name: build_runner
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.9.0"
+ build_runner_core:
+ dependency: transitive
+ description:
+ name: build_runner_core
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "5.1.0"
+ built_collection:
+ dependency: transitive
+ description:
+ name: built_collection
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.3.2"
+ built_value:
+ dependency: transitive
+ description:
+ name: built_value
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "7.1.0"
+ charcode:
+ dependency: transitive
+ description:
+ name: charcode
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.3"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.4"
+ code_builder:
+ dependency: transitive
+ description:
+ name: code_builder
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.7.0"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.14.13"
+ convert:
+ dependency: transitive
+ description:
+ name: convert
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.1"
+ coverage:
+ dependency: transitive
+ description:
+ name: coverage
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.14.2"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.5"
+ csslib:
+ dependency: transitive
+ description:
+ name: csslib
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.16.2"
+ dart_style:
+ dependency: transitive
+ description:
+ name: dart_style
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.3.3"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "5.2.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.10.11"
+ front_end:
+ dependency: transitive
+ description:
+ name: front_end
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.1.27"
+ glob:
+ dependency: transitive
+ description:
+ name: glob
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ googleapis:
+ dependency: "direct main"
+ description:
+ name: googleapis
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.0"
+ googleapis_auth:
+ dependency: "direct main"
+ description:
+ name: googleapis_auth
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.12+1"
+ graphs:
+ dependency: transitive
+ description:
+ name: graphs
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.0"
+ html:
+ dependency: transitive
+ description:
+ name: html
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.14.0+4"
+ http:
+ dependency: "direct main"
+ description:
+ name: http
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.12.2"
+ http_multi_server:
+ dependency: transitive
+ description:
+ name: http_multi_server
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.2.0"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.1.4"
+ intl:
+ dependency: transitive
+ description:
+ name: intl
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.16.1"
+ io:
+ dependency: transitive
+ description:
+ name: io
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.5"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.6.3"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.1.1"
+ kernel:
+ dependency: transitive
+ description:
+ name: kernel
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.27"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.11.4"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.12.6"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.4"
+ mime:
+ dependency: transitive
+ description:
+ name: mime
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.9.7"
+ mockito:
+ dependency: "direct dev"
+ description:
+ name: mockito
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.1.1+1"
+ multi_server_socket:
+ dependency: transitive
+ description:
+ name: multi_server_socket
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.2"
+ node_interop:
+ dependency: transitive
+ description:
+ name: node_interop
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.1"
+ node_io:
+ dependency: transitive
+ description:
+ name: node_io
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ node_preamble:
+ dependency: transitive
+ description:
+ name: node_preamble
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.4.13"
+ package_config:
+ dependency: transitive
+ description:
+ name: package_config
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.9.3"
+ package_resolver:
+ dependency: transitive
+ description:
+ name: package_resolver
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.10"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.7.0"
+ pedantic:
+ dependency: "direct dev"
+ description:
+ name: pedantic
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.11.0"
+ pool:
+ dependency: "direct main"
+ description:
+ name: pool
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.4.0"
+ protobuf:
+ dependency: transitive
+ description:
+ name: protobuf
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.3"
+ pub_semver:
+ dependency: transitive
+ description:
+ name: pub_semver
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.4.4"
+ pubspec_parse:
+ dependency: transitive
+ description:
+ name: pubspec_parse
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.1.8"
+ quiver:
+ dependency: transitive
+ description:
+ name: quiver
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.5"
+ retry:
+ dependency: "direct main"
+ description:
+ name: retry
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.1.0"
+ scratch_space:
+ dependency: transitive
+ description:
+ name: scratch_space
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.0.4+3"
+ shelf:
+ dependency: transitive
+ description:
+ name: shelf
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.7.9"
+ shelf_packages_handler:
+ dependency: transitive
+ description:
+ name: shelf_packages_handler
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.1"
+ shelf_static:
+ dependency: transitive
+ description:
+ name: shelf_static
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.9+2"
+ shelf_web_socket:
+ dependency: transitive
+ description:
+ name: shelf_web_socket
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.4+1"
+ source_map_stack_trace:
+ dependency: transitive
+ description:
+ name: source_map_stack_trace
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ source_maps:
+ dependency: transitive
+ description:
+ name: source_maps
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.10.9"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.7.0"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.9.6"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ stream_transform:
+ dependency: transitive
+ description:
+ name: stream_transform
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.5"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.0"
+ test:
+ dependency: "direct dev"
+ description:
+ name: test
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.14.7"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.16"
+ test_core:
+ dependency: transitive
+ description:
+ name: test_core
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.7"
+ timing:
+ dependency: transitive
+ description:
+ name: timing
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.1.1+3"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.2.0"
+ watcher:
+ dependency: transitive
+ description:
+ name: watcher
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.9.7+15"
+ web_socket_channel:
+ dependency: transitive
+ description:
+ name: web_socket_channel
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.2.0"
+ webkit_inspection_protocol:
+ dependency: transitive
+ description:
+ name: webkit_inspection_protocol
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.7.5"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.2.1"
+sdks:
+ dart: ">=2.12.0-0 <3.0.0"
diff --git a/builder/pubspec.yaml b/builder/pubspec.yaml
new file mode 100644
index 0000000..eddd48c
--- /dev/null
+++ b/builder/pubspec.yaml
@@ -0,0 +1,22 @@
+name: builder
+description: Scripts run on Dart CQ/CI builders
+author: "Dart Team <misc@dartlang.org>"
+version: 0.1.0
+
+environment:
+ sdk: '^2.10.0'
+
+dependencies:
+ args: 1.6.0
+ googleapis_auth: 0.2.12+1
+ googleapis: 1.0.0
+ http: 0.12.2
+ pool: 1.4.0
+ retry: 3.1.0
+
+dev_dependencies:
+ pedantic: ^1.10.0
+ build_runner: ^1.7.1
+ build_node_compilers: ^0.2.3
+ mockito: ^4.1.0
+ test: ^1.9.4
diff --git a/builder/test/fakes.dart b/builder/test/fakes.dart
new file mode 100644
index 0000000..c81e8c6
--- /dev/null
+++ b/builder/test/fakes.dart
@@ -0,0 +1,208 @@
+// 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:math';
+
+import 'package:googleapis/firestore/v1.dart';
+import 'package:mockito/mockito.dart';
+import 'package:http/http.dart';
+
+import 'package:builder/src/builder.dart';
+import 'package:builder/src/commits_cache.dart';
+import 'package:builder/src/firestore.dart';
+import 'package:builder/src/result.dart';
+import 'test_data.dart';
+
+class BuilderTest {
+ final client = HttpClientMock();
+ final firestore = FirestoreServiceFake();
+ CommitsCache commitsCache;
+ Build builder;
+ String commitHash;
+ Map<String, dynamic> firstChange;
+
+ BuilderTest(this.commitHash, this.firstChange) {
+ commitsCache = CommitsCache(firestore, client);
+ builder = Build(commitHash, firstChange, commitsCache, firestore);
+ }
+
+ Future<void> update() async {
+ await storeBuildCommitsInfo();
+ }
+
+ Future<void> storeBuildCommitsInfo() async {
+ await builder.storeBuildCommitsInfo();
+ // Test expectations
+ }
+
+ Future<void> storeChange(Map<String, dynamic> change) async {
+ return builder.storeChange(change);
+ }
+}
+
+class FirestoreServiceFake extends Fake implements FirestoreService {
+ Map<String, Map<String, dynamic>> commits = Map.from(fakeFirestoreCommits);
+ Map<String, Map<String, dynamic>> results = Map.from(fakeFirestoreResults);
+ List<Map<String, dynamic>> fakeTryResults =
+ List.from(fakeFirestoreTryResults);
+ int addedResultIdCounter = 1;
+
+ @override
+ Future<bool> isStaging() async => false;
+
+ @override
+ Future<Commit> getCommit(String hash) async {
+ final commit = commits[hash];
+ if (commit == null) {
+ return null;
+ }
+ return Commit.fromJson(hash, commits[hash]);
+ }
+
+ @override
+ Future<Commit> getCommitByIndex(int index) {
+ for (final entry in commits.entries) {
+ if (entry.value[fIndex] == index) {
+ return Future.value(Commit.fromJson(entry.key, entry.value));
+ }
+ }
+ throw 'No commit found with index $index';
+ }
+
+ @override
+ Future<Commit> getLastCommit() => getCommitByIndex(
+ commits.values.map<int>((commit) => commit[fIndex]).reduce(max));
+
+ @override
+ Future<void> addCommit(String id, Map<String, dynamic> data) async {
+ commits[id] = data..[fHash] = id;
+ }
+
+ @override
+ Future<String> findResult(
+ Map<String, dynamic> change, int startIndex, int endIndex) {
+ var resultId;
+ var resultEndIndex;
+ for (final entry in results.entries) {
+ final result = entry.value;
+ if (result[fName] == change[fName] &&
+ result[fResult] == change[fResult] &&
+ result[fExpected] == change[fExpected] &&
+ result[fPreviousResult] == change[fPreviousResult] &&
+ result[fBlamelistEndIndex] >= startIndex &&
+ result[fBlamelistStartIndex] <= endIndex) {
+ if (resultEndIndex == null ||
+ resultEndIndex < result[fBlamelistEndIndex]) {
+ resultId = entry.key;
+ resultEndIndex = result[fBlamelistEndIndex];
+ }
+ }
+ }
+ return Future.value(resultId);
+ }
+
+ @override
+ Future<List<Document>> findActiveResults(
+ String name, String configuration) async {
+ return [
+ for (final id in results.keys)
+ if (results[id][fName] == name &&
+ results[id][fActiveConfigurations] != null &&
+ results[id][fActiveConfigurations].contains(configuration))
+ Document.fromJson({'fields': taggedJsonMap(Map.from(results[id]))})
+ ..name = id
+ ];
+ }
+
+ @override
+ Future<void> storeResult(Map<String, dynamic> result) async {
+ final id = 'resultDocumentID$addedResultIdCounter';
+ addedResultIdCounter++;
+ results[id] = result;
+ }
+
+ @override
+ Future<bool> updateResult(
+ String resultId, String configuration, int startIndex, int endIndex,
+ {bool failure}) {
+ final result = Map<String, dynamic>.from(results[resultId]);
+
+ result[fBlamelistStartIndex] =
+ max<int>(startIndex, result[fBlamelistStartIndex]);
+
+ result[fBlamelistEndIndex] = min<int>(endIndex, result[fBlamelistEndIndex]);
+ if (!result[fConfigurations].contains(configuration)) {
+ result[fConfigurations] = List<String>.from(result[fConfigurations])
+ ..add(configuration)
+ ..sort();
+ }
+ if (failure) {
+ result[fActive] = true;
+ if (!result[fActiveConfigurations].contains(configuration)) {
+ result[fActiveConfigurations] =
+ List<String>.from(result[fActiveConfigurations])
+ ..add(configuration)
+ ..sort();
+ }
+ }
+ results[resultId] = result;
+ return Future.value(result[fApproved] ?? false);
+ }
+
+ @override
+ Future<void> updateActiveResult(
+ Document activeResult, String configuration) async {
+ final result = Map<String, dynamic>.from(results[activeResult.name]);
+ result[fActiveConfigurations] = List.from(result[fActiveConfigurations])
+ ..remove(configuration);
+ if (result[fActiveConfigurations].isEmpty) {
+ result.remove(fActiveConfigurations);
+ result.remove(fActive);
+ }
+ results[activeResult.name] = result;
+ }
+
+ @override
+ Future<List<Map<String, Value>>> findRevertedChanges(int index) async {
+ return results.values
+ .where((change) =>
+ change[fPinnedIndex] == index ||
+ (change[fBlamelistStartIndex] == index &&
+ change[fBlamelistEndIndex] == index))
+ .map(taggedMap)
+ .toList();
+ }
+
+ @override
+ Future<List<Map<String, Value>>> tryApprovals(int review) async {
+ return fakeTryResults
+ .where(
+ (result) => result[fReview] == review && result[fApproved] == true)
+ .map(taggedMap)
+ .toList();
+ }
+
+ @override
+ Future<List<Map<String, Value>>> tryResults(
+ int review, String configuration) async {
+ return fakeTryResults
+ .where((result) =>
+ result[fReview] == review &&
+ result[fConfigurations].contains(configuration))
+ .map(taggedMap)
+ .toList();
+ }
+
+ @override
+ Future<bool> reviewIsLanded(int review) =>
+ Future.value(commits.values.any((commit) => commit[fReview] == review));
+}
+
+class HttpClientMock extends Mock implements BaseClient {}
+
+class ResponseFake extends Fake implements Response {
+ @override
+ String body;
+ ResponseFake(this.body);
+}
diff --git a/builder/test/firestore_test.dart b/builder/test/firestore_test.dart
new file mode 100644
index 0000000..e500310
--- /dev/null
+++ b/builder/test/firestore_test.dart
@@ -0,0 +1,139 @@
+// Copyright (c) 2019, 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 'package:firebase_admin_interop/firebase_admin_interop.dart';
+// import 'package:firebase_functions_interop/firebase_functions_interop.dart';
+import 'package:builder/src/firestore.dart';
+import 'package:test/test.dart';
+import 'package:builder/src/tryjob.dart';
+import 'package:googleapis_auth/auth_io.dart';
+import 'package:http/http.dart' as http;
+import 'package:googleapis/firestore/v1.dart';
+
+import 'test_data.dart';
+
+// These tests read and write data from the Firestore database, and
+// should only be run locally against the dart-ci-staging project.
+// Requires the environment variable GOOGLE_APPLICATION_CREDENTIALS
+// to point to a json key to a service account
+// with write access to dart_ci_staging datastore.
+// Set the database with 'firebase use --add dart-ci-staging'
+// The test must be compiled with nodejs, and run using the 'node' command.
+
+void main() async {
+ final baseClient = http.Client();
+ final client = await clientViaApplicationDefaultCredentials(
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
+ baseClient: baseClient);
+ final api = FirestoreApi(client);
+ final firestore = FirestoreService(api, client);
+ if (!await firestore.isStaging()) {
+ throw (TestFailure(
+ 'Error: firestore_test_nodejs.dart is being run on production'));
+ }
+
+ const testReviewDocument =
+ 'projects/dart-ci-staging/databases/(default)/documents/reviews/$testReview';
+
+ tearDownAll(() => baseClient.close());
+
+ group('Try results', () {
+ setUp(() async {
+ if (await firestore.documentExists(testReviewDocument)) {
+ await firestore.deleteDocument(testReviewDocument);
+ }
+ });
+
+ tearDown(() async {
+ // Delete database records created by the tests.
+ var snapshot = await firestore.query(
+ from: 'try_builds', where: fieldEquals('review', testReview));
+ for (final doc in snapshot) {
+ if (doc.document != null) {
+ await firestore.deleteDocument(doc.document.name);
+ }
+ }
+ snapshot =
+ // await firestore.collection('reviews/$testReview/patchsets').get();
+ await firestore.query(
+ from: 'patchsets', parent: 'reviews/$testReview/');
+ for (final doc in snapshot) {
+ if (doc.document != null) {
+ await firestore.deleteDocument(doc.document.name);
+ }
+ }
+ await firestore.deleteDocument(testReviewDocument);
+ });
+
+ test('approved try result fetching', () async {
+ await firestore.storeReview(testReview.toString(), {
+ 'subject': 'test review: approved try result fetching',
+ });
+ await firestore.storePatchset(
+ testReview.toString(),
+ 1,
+ 'REWORK',
+ 'Initial upload',
+ 1,
+ 1,
+ );
+ await firestore.storePatchset(
+ testReview.toString(),
+ 2,
+ 'REWORK',
+ 'change',
+ 2,
+ 2,
+ );
+ await firestore.storePatchset(
+ testReview.toString(),
+ 3,
+ 'NO_CODE_CHANGE',
+ 'Edit commit message',
+ 2,
+ 3,
+ );
+ final tryResult = {
+ 'review': testReview,
+ 'configuration': 'test_configuration',
+ 'name': 'test_suite/test_name',
+ 'patchset': 1,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'previous_result': 'Pass',
+ };
+ await firestore.storeTryChange(tryResult, testReview, 1);
+ final tryResult2 = Map<String, dynamic>.from(tryResult);
+ tryResult2['patchset'] = 2;
+ tryResult2['name'] = 'test_suite/test_name_2';
+ await firestore.storeTryChange(tryResult2, testReview, 2);
+ tryResult['patchset'] = 3;
+ tryResult['name'] = 'test_suite/test_name';
+ tryResult['expected'] = 'CompileTimeError';
+ await firestore.storeTryChange(tryResult, testReview, 3);
+ // Set the results on patchsets 1 and 2 to approved.
+ final snapshot = await firestore.query(
+ from: 'try_results',
+ where: compositeFilter([
+ fieldEquals('approved', false),
+ fieldEquals('review', testReview),
+ fieldLessThanOrEqual('patchset', 2)
+ ]));
+ for (final response in snapshot) {
+ await firestore.approveResult(response.document);
+ //await firestore.updateDocument(response.document.name, {'approved': taggedValue(true)});
+ }
+
+ // Should return only the approved change on patchset 2,
+ // not the one on patchset 1 or the unapproved change on patchset 3.
+ final approvals = await firestore.tryApprovals(testReview);
+ tryResult2['configurations'] = [tryResult2['configuration']];
+ tryResult2['approved'] = true;
+ tryResult2.remove('configuration');
+ expect(1, approvals.length);
+ final approval = untagMap(approvals.single);
+ expect(approval, tryResult2);
+ });
+ });
+}
diff --git a/builder/test/gerrit_review_json.dart b/builder/test/gerrit_review_json.dart
new file mode 100644
index 0000000..2da5c32
--- /dev/null
+++ b/builder/test/gerrit_review_json.dart
@@ -0,0 +1,109 @@
+// 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.
+
+// This is the JSON log for an actual review that is a revert,
+// with emails and names removed.
+String revertReviewGerritLog = r'''
+{
+ "id": "sdk~master~Ie212fae88bc1977e34e4d791c644b77783a8deb1",
+ "project": "sdk",
+ "branch": "master",
+ "hashtags": [],
+ "change_id": "Ie212fae88bc1977e34e4d791c644b77783a8deb1",
+ "subject": "Revert \"[SDK] Adds IndirectGoto implementation of sync-yield.\"",
+ "status": "MERGED",
+ "created": "2020-02-17 12:17:05.000000000",
+ "updated": "2020-02-17 12:17:25.000000000",
+ "submitted": "2020-02-17 12:17:25.000000000",
+ "submitter": {
+ "_account_id": 5260,
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org"
+ },
+ "insertions": 61,
+ "deletions": 155,
+ "total_comment_count": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "revert_of": 133586,
+ "submission_id": "136125",
+ "_number": 136125,
+ "owner": {
+ },
+ "current_revision": "82f3f81fc82d06c575b0137ddbe71408826d8b46",
+ "revisions": {
+ "82f3f81fc82d06c575b0137ddbe71408826d8b46": {
+ "kind": "REWORK",
+ "_number": 2,
+ "created": "2020-02-17 12:17:25.000000000",
+ "uploader": {
+ "_account_id": 5260,
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org"
+ },
+ "ref": "refs/changes/25/136125/2",
+ "fetch": {
+ "rpc": {
+ "url": "rpc://dart/sdk",
+ "ref": "refs/changes/25/136125/2"
+ },
+ "http": {
+ "url": "https://dart.googlesource.com/sdk",
+ "ref": "refs/changes/25/136125/2"
+ },
+ "sso": {
+ "url": "sso://dart/sdk",
+ "ref": "refs/changes/25/136125/2"
+ }
+ },
+ "commit": {
+ "parents": [
+ {
+ "commit": "d2d00ff357bd64a002697b3c96c92a0fec83328c",
+ "subject": "[cfe] Allow unassigned late local variables"
+ }
+ ],
+ "author": {
+ "name": "gerrit_user",
+ "email": "gerrit_user@example.com",
+ "date": "2020-02-17 12:17:25.000000000",
+ "tz": 0
+ },
+ "committer": {
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org",
+ "date": "2020-02-17 12:17:25.000000000",
+ "tz": 0
+ },
+ "subject": "Revert \"[SDK] Adds IndirectGoto implementation of sync-yield.\"",
+ "message": "Revert \"[SDK] Adds IndirectGoto implementation of sync-yield.\"\n\nThis reverts commit 7ed1690b4ed6b56bc818173dff41a7a2530991a2.\n\nReason for revert: Crashes precomp.\n\nOriginal change\u0027s description:\n\u003e [SDK] Adds IndirectGoto implementation of sync-yield.\n\u003e \n\u003e Sets a threshold of five continuations determining if the old\n\u003e if-else or the new igoto-based implementation will be used.\n\u003e Informal benchmarking on x64 and arm_x64 point towards the overhead\n\u003e of the igoto-based impl. dropping off around this point.\n\u003e \n\u003e Benchmarks of this CL (threshold\u003d5) show drastic improvement in\n\u003e Calls.IterableManualIterablePolymorphicManyYields of about ~35-65%\n\u003e across {dart,dart-aot}-{ia32,x64,armv7hf,armv8}.\n\u003e \n\u003e Bug: https://github.com/dart-lang/sdk/issues/37754\n\u003e Change-Id: I6e113f1f98e9ab0f994cf93004227d616e9e4d07\n\u003e Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/133586\n\u003e Commit-Queue: XXXXX \u003cxxxx@example.com\u003e\n\u003e Reviewed-by: xxxx \u003cxxxxxx@example.com\u003e\n\nChange-Id: Ie212fae88bc1977e34e4d791c644b77783a8deb1\nNo-Presubmit: true\nNo-Tree-Checks: true\nNo-Try: true\nBug: https://github.com/dart-lang/sdk/issues/37754\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/136125\nReviewed-by: XXX\nCommit-Queue: XXXXXX\n"
+ },
+ "description": "Rebase"
+ },
+ "8bae95c4001a0815e89ebc4c89dc5ad42337a01b": {
+ "kind": "REWORK",
+ "_number": 1,
+ "created": "2020-02-17 12:17:05.000000000",
+ "uploader": {
+ },
+ "ref": "refs/changes/25/136125/1",
+ "fetch": {
+ "rpc": {
+ "url": "rpc://dart/sdk",
+ "ref": "refs/changes/25/136125/1"
+ },
+ "http": {
+ "url": "https://dart.googlesource.com/sdk",
+ "ref": "refs/changes/25/136125/1"
+ },
+ "sso": {
+ "url": "sso://dart/sdk",
+ "ref": "refs/changes/25/136125/1"
+ }
+ }
+ }
+ },
+ "requirements": []
+}
+''';
diff --git a/builder/test/test.dart b/builder/test/test.dart
new file mode 100644
index 0000000..3acd649
--- /dev/null
+++ b/builder/test/test.dart
@@ -0,0 +1,94 @@
+// Copyright (c) 2019, 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 'package:builder/src/firestore.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'package:builder/src/result.dart';
+import 'fakes.dart';
+import 'test_data.dart';
+
+void main() async {
+ test('Base builder test', () async {
+ final builderTest = BuilderTest(landedCommitHash, landedCommitChange);
+ await builderTest.update();
+ });
+
+ test('Get info for already saved commit', () async {
+ final builderTest = BuilderTest(existingCommitHash, existingCommitChange);
+ await builderTest.storeBuildCommitsInfo();
+ expect(builderTest.builder.endIndex, existingCommitIndex);
+ expect(builderTest.builder.startIndex, previousCommitIndex + 1);
+ });
+
+ test('Link landed commit to review', () async {
+ final builderTest = BuilderTest(landedCommitHash, landedCommitChange);
+ builderTest.firestore.commits
+ .removeWhere((key, value) => value[fIndex] > existingCommitIndex);
+ when(builderTest.client.get(any))
+ .thenAnswer((_) => Future(() => ResponseFake(gitilesLog)));
+ await builderTest.storeBuildCommitsInfo();
+ await builderTest.builder.fetchReviewsAndReverts();
+ expect(builderTest.builder.endIndex, landedCommitIndex);
+ expect(builderTest.builder.startIndex, existingCommitIndex + 1);
+ expect(builderTest.builder.tryApprovals,
+ {testResult(review44445Result): 54, testResult(review77779Result): 53});
+ expect((await builderTest.firestore.getCommit(commit53Hash)).toJson(),
+ commit53);
+ expect((await builderTest.firestore.getCommit(landedCommitHash)).toJson(),
+ landedCommit);
+ });
+
+ test('update previous active result', () async {
+ final builderTest = BuilderTest(landedCommitHash, landedCommitChange);
+ await builderTest.storeBuildCommitsInfo();
+ await builderTest.storeChange(landedCommitChange);
+ expect(builderTest.builder.success, true);
+ expect(
+ builderTest.firestore.results['activeResultID'],
+ Map.from(activeResult)
+ ..[fActiveConfigurations] = ['another configuration']);
+
+ final changeAnotherConfiguration =
+ Map<String, dynamic>.from(landedCommitChange)
+ ..['configuration'] = 'another configuration';
+ await builderTest.storeChange(changeAnotherConfiguration);
+ expect(builderTest.builder.success, true);
+ expect(builderTest.firestore.results['activeResultID'],
+ Map.from(activeResult)..remove(fActiveConfigurations)..remove(fActive));
+ expect(builderTest.builder.countApprovalsCopied, 1);
+ expect(builderTest.builder.countChanges, 2);
+ expect(
+ builderTest.firestore.results[await builderTest.firestore.findResult(
+ landedCommitChange, landedCommitIndex, landedCommitIndex)],
+ landedResult);
+ final result = (await builderTest.firestore.findActiveResults(
+ landedCommitChange['name'], landedCommitChange['configuration']))
+ .single;
+ expect(untagMap(result.fields), landedResult);
+ });
+
+ test('mark active result flaky', () async {
+ final builderTest = BuilderTest(landedCommitHash, landedCommitChange);
+ await builderTest.storeBuildCommitsInfo();
+ final flakyChange = Map<String, dynamic>.from(landedCommitChange)
+ ..[fPreviousResult] = 'RuntimeError'
+ ..[fFlaky] = true;
+ expect(flakyChange[fResult], 'RuntimeError');
+ await builderTest.storeChange(flakyChange);
+ expect(flakyChange[fResult], 'flaky');
+ expect(builderTest.builder.success, true);
+ expect(
+ builderTest.firestore.results['activeResultID'],
+ Map.from(activeResult)
+ ..[fActiveConfigurations] = ['another configuration']);
+
+ expect(builderTest.builder.countChanges, 1);
+ expect(
+ builderTest.firestore.results[await builderTest.firestore
+ .findResult(flakyChange, landedCommitIndex, landedCommitIndex)],
+ flakyResult);
+ });
+}
diff --git a/builder/test/test_data.dart b/builder/test/test_data.dart
new file mode 100644
index 0000000..545861f
--- /dev/null
+++ b/builder/test/test_data.dart
@@ -0,0 +1,341 @@
+// Copyright (c) 2019, 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:core';
+
+Map<String, dynamic> fakeFirestoreCommits = Map.unmodifiable({
+ previousCommitHash: previousCommit, // 51
+ existingCommitHash: existingCommit, // 52
+ commit53Hash: commit53, // 53
+ landedCommitHash: landedCommit, // 54
+});
+const fakeFirestoreCommitsFirstIndex = previousCommitIndex;
+const fakeFirestoreCommitsLastIndex = landedCommitIndex;
+
+Map<String, Map<String, dynamic>> fakeFirestoreResults = Map.unmodifiable({
+ 'activeFailureResultID': activeFailureResult,
+ 'activeResultID': activeResult,
+});
+
+const List<Map<String, dynamic>> fakeFirestoreTryResults = [
+ review44445Result,
+ review77779Result,
+];
+
+// Test commits. These are test commit documents from Firestore.
+// When they are returned from the FirestoreService API, their hash
+// is added to the map with key 'hash'.
+const String previousCommitHash = 'a previous existing commit hash';
+const int previousCommitIndex = 51;
+Map<String, dynamic> previousCommit = Map.unmodifiable({
+ 'author': 'previous_user@example.com',
+ 'created': DateTime.parse('2019-11-24 11:18:00Z'),
+ 'index': previousCommitIndex,
+ 'title': 'A commit used for testing, with index 51',
+ 'hash': previousCommitHash,
+});
+
+const String existingCommitHash = 'an already existing commit hash';
+const int existingCommitIndex = 52;
+Map<String, dynamic> existingCommit = Map.unmodifiable({
+ 'author': 'test_user@example.com',
+ 'created': DateTime.parse('2019-11-22 22:19:00Z'),
+ 'index': existingCommitIndex,
+ 'title': 'A commit used for testing, with index 52',
+ 'hash': existingCommitHash,
+});
+
+const String commit53Hash = 'commit 53 landing CL 77779 hash';
+const int commit53Index = 53;
+Map<String, dynamic> commit53 = Map.unmodifiable({
+ 'author': 'user@example.com',
+ 'created': DateTime.parse('2019-11-28 12:07:55 +0000'),
+ 'index': commit53Index,
+ 'title': 'A commit on the git log',
+ 'hash': commit53Hash,
+ 'review': 77779,
+});
+
+const String landedCommitHash = 'a commit landing a CL hash';
+const int landedCommitIndex = 54;
+Map<String, dynamic> landedCommit = Map.unmodifiable({
+ 'author': 'gerrit_user@example.com',
+ 'created': DateTime.parse('2019-11-29 15:15:00Z'),
+ 'index': landedCommitIndex,
+ 'title': 'A commit used for testing tryjob approvals, with index 54',
+ 'hash': landedCommitHash,
+ 'review': 44445
+});
+
+/// Changes
+/// These are single lines from a result.json, representing the output
+/// of a single test on a single configuration on a single build.
+const String sampleTestName = 'local_function_signatures_strong_test/none';
+const String sampleTestSuite = 'dart2js_extra';
+const String sampleTest = '$sampleTestSuite/$sampleTestName';
+
+const Map<String, dynamic> existingCommitChange = {
+ 'name': sampleTest,
+ 'configuration': 'dart2js-new-rti-linux-x64-d8',
+ 'suite': sampleTestSuite,
+ 'test_name': sampleTestName,
+ 'time_ms': 2384,
+ 'result': 'Pass',
+ 'expected': 'Pass',
+ 'matches': true,
+ 'bot_name': 'luci-dart-try-xenial-70-8fkh',
+ 'commit_hash': existingCommitHash,
+ 'commit_time': 1563576771,
+ 'build_number': '307',
+ 'builder_name': 'dart2js-rti-linux-x64-d8',
+ 'flaky': false,
+ 'previous_flaky': false,
+ 'previous_result': 'RuntimeError',
+ 'previous_commit_hash': previousCommitHash,
+ 'previous_commit_time': 1563576211,
+ 'previous_build_number': '306',
+ 'changed': true
+};
+
+const Map<String, dynamic> landedCommitChange = {
+ 'name': sampleTest,
+ 'configuration': 'dart2js-new-rti-linux-x64-d8',
+ 'suite': sampleTestSuite,
+ 'test_name': sampleTestName,
+ 'time_ms': 2384,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'matches': false,
+ 'bot_name': 'luci-dart-try-xenial-70-8fkh',
+ 'commit_hash': landedCommitHash,
+ 'commit_time': 1563576771,
+ 'build_number': '308',
+ 'builder_name': 'dart2js-rti-linux-x64-d8',
+ 'flaky': false,
+ 'previous_flaky': false,
+ 'previous_result': 'Pass',
+ 'previous_commit_hash': existingCommitHash,
+ 'previous_commit_time': 1563576211,
+ 'previous_build_number': '306',
+ 'changed': true
+};
+
+/// Results
+/// These are test Result documents, as stored in Firestore.
+const Map<String, dynamic> activeFailureResult = {
+ 'name': 'test_suite/active_failing_test',
+ 'configurations': [testConfiguration, 'configuration 2', 'configuration 3'],
+ 'active': true,
+ 'active_configurations': [testConfiguration, 'configuration 2'],
+ 'approved': false,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'previous_result': 'Pass',
+ 'blamelist_start_index': 67195,
+ 'blamelist_end_index': 67195
+};
+
+// A result on existingCommit that is overridden by the new result in
+// landedCommitChange.
+Map<String, dynamic> activeResult = {
+ 'name': sampleTest,
+ 'blamelist_start_index': existingCommitIndex,
+ 'blamelist_end_index': existingCommitIndex,
+ 'configurations': [
+ landedCommitChange['configuration'],
+ 'another configuration'
+ ]..sort(),
+ 'active_configurations': [
+ landedCommitChange['configuration'],
+ 'another configuration'
+ ]..sort(),
+ 'active': true,
+ 'approved': false,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'previous_result': 'Pass',
+};
+
+Map<String, dynamic> landedResult = {
+ 'name': sampleTest,
+ 'blamelist_start_index': existingCommitIndex + 1,
+ 'blamelist_end_index': landedCommitIndex,
+ 'pinned_index': landedCommitIndex,
+ 'configurations': [
+ 'another configuration',
+ landedCommitChange['configuration'],
+ ]..sort(),
+ 'active_configurations': [
+ landedCommitChange['configuration'],
+ 'another configuration'
+ ]..sort(),
+ 'active': true,
+ 'approved': true,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'previous_result': 'Pass',
+};
+
+Map<String, dynamic> flakyResult = {
+ 'name': sampleTest,
+ 'blamelist_start_index': existingCommitIndex + 1,
+ 'blamelist_end_index': landedCommitIndex,
+ 'configurations': [landedCommitChange['configuration']],
+ 'approved': false,
+ 'result': 'flaky',
+ 'expected': 'Pass',
+ 'previous_result': 'RuntimeError',
+};
+
+/// Try results
+/// These are documents from the try_results table in Firestore.
+const Map<String, dynamic> review44445Result = {
+ 'review': 44445,
+ 'configurations': [
+ 'dart2js-new-rti-linux-x64-d8',
+ 'dartk-reload-rollback-linux-debug-x64',
+ 'dartk-reload-linux-debug-x64'
+ ],
+ 'name': sampleTest,
+ 'patchset': 1,
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'previous_result': 'Pass',
+ 'approved': true
+};
+const Map<String, dynamic> review77779Result = {
+ 'review': 77779,
+ 'configurations': ['test_configuration'],
+ 'name': 'test_suite/test_name',
+ 'patchset': 5,
+ 'result': 'RuntimeError',
+ 'expected': 'CompileTimeError',
+ 'previous_result': 'CompileTimeError',
+ 'approved': true
+};
+
+const testBuilder = 'test_builder';
+const testBuildNumber = '308';
+const tryjob2BuildNumber = '309';
+const tryjob3BuildNumber = '310';
+const testConfiguration = 'test_configuration';
+const testReview = 123;
+const testPatchset = 3;
+const testPreviousPatchset = 1;
+const testReviewPath = 'refs/changes/$testReview/$testPatchset';
+const testPreviousPatchsetPath =
+ 'refs/changes/$testReview/$testPreviousPatchset';
+const Map<String, dynamic> tryjobFailingChange = {
+ 'name': 'test_suite/failing_test',
+ 'configuration': 'test_configuration',
+ 'suite': 'test_suite',
+ 'test_name': 'failing_test',
+ 'time_ms': 2384,
+ 'result': 'CompileTimeError',
+ 'expected': 'Pass',
+ 'matches': false,
+ 'bot_name': 'test_bot',
+ 'commit_hash': testReviewPath,
+ 'commit_time': 1563576771,
+ 'build_number': testBuildNumber,
+ 'builder_name': testBuilder,
+ 'flaky': false,
+ 'previous_flaky': false,
+ 'previous_result': 'Pass',
+ 'previous_commit_hash': existingCommitHash,
+ 'previous_commit_time': 1563576211,
+ 'previous_build_number': '1234',
+ 'changed': true
+};
+
+final Map<String, dynamic> tryjob2OtherFailingChange =
+ Map<String, dynamic>.from(tryjobFailingChange)
+ ..addAll({
+ 'name': 'test_suite/other_failing_test',
+ 'test_name': 'other_failing_test',
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'matches': false,
+ 'previous_result': 'Pass',
+ 'changed': true,
+ 'build_number': tryjob2BuildNumber,
+ });
+
+final Map<String, dynamic> tryjobExistingFailure =
+ Map<String, dynamic>.from(tryjobFailingChange)
+ ..addAll({
+ 'name': 'test_suite/existing_failure_test',
+ 'test_name': 'passing_test',
+ 'result': 'RuntimeError',
+ 'expected': 'Pass',
+ 'matches': false,
+ 'previous_result': 'RuntimeError',
+ 'changed': false
+ });
+
+final Map<String, dynamic> tryjob2ExistingFailure =
+ Map<String, dynamic>.from(tryjobExistingFailure)
+ ..addAll({
+ 'build_number': tryjob2BuildNumber,
+ });
+
+final Map<String, dynamic> tryjob2FailingChange =
+ Map<String, dynamic>.from(tryjobFailingChange)
+ ..addAll({
+ 'build_number': tryjob2BuildNumber,
+ });
+
+final Map<String, dynamic> tryjobPassingChange =
+ Map<String, dynamic>.from(tryjobFailingChange)
+ ..addAll({
+ 'name': 'test_suite/passing_test',
+ 'test_name': 'passing_test',
+ 'result': 'Pass',
+ 'expected': 'Pass',
+ 'matches': true,
+ 'previous_result': 'RuntimeError',
+ 'changed': true
+ });
+
+final Map<String, dynamic> tryjob2PassingChange =
+ Map<String, dynamic>.from(tryjobPassingChange)
+ ..addAll({
+ 'build_number': tryjob2BuildNumber,
+ });
+
+final Map<String, dynamic> tryjob3PassingChange =
+ Map<String, dynamic>.from(tryjobPassingChange)
+ ..addAll({
+ 'build_number': tryjob3BuildNumber,
+ });
+
+String gitilesLog = '''
+)]}'
+{
+ "log": [
+ {
+ "commit": "$landedCommitHash",
+ "parents": ["$commit53Hash"],
+ "author": {
+ "email": "gerrit_user@example.com"
+ },
+ "committer": {
+ "time": "Fri Nov 29 15:15:00 2019 +0000"
+ },
+ "message": "A commit used for testing tryjob approvals, with index 54\\n\\nDescription of the landed commit\\nin multiple lines.\\n\\u003e Change-Id: I8dcc08b7571137e869a16ceea8cc73539eb02a5a\\n\\u003e Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/33337\\n\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/44445\\n\\n"
+ },
+ {
+ "commit": "$commit53Hash",
+ "parents": ["$existingCommitHash"],
+ "author": {
+ "email": "user@example.com"
+ },
+ "committer": {
+ "time": "Thu Nov 28 12:07:55 2019 +0000"
+ },
+ "message": "A commit on the git log\\n\\nThis commit does not have results from the CQ\\n\\nChange-Id: I481b2cb8b666885b5c2b9c53fff1177accd01830\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/77779\\nCommit-Queue: A user \\u003cuser9@example.com\\u003e\\nReviewed-by: Another user \\u003cuser10@example.com\\u003e\\n"
+ }
+ ]
+}
+''';
diff --git a/builder/test/test_gerrit.dart b/builder/test/test_gerrit.dart
new file mode 100644
index 0000000..9aaf399
--- /dev/null
+++ b/builder/test/test_gerrit.dart
@@ -0,0 +1,17 @@
+// 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:convert';
+
+import 'package:test/test.dart';
+
+import 'package:builder/src/gerrit_change.dart';
+import 'gerrit_review_json.dart';
+
+void main() async {
+ test('get revert information from Gerrit log api output', () {
+ expect(GerritInfo.revert(json.decode(revertReviewGerritLog)),
+ '7ed1690b4ed6b56bc818173dff41a7a2530991a2');
+ });
+}
diff --git a/builder/test/test_revert.dart b/builder/test/test_revert.dart
new file mode 100644
index 0000000..7090714
--- /dev/null
+++ b/builder/test/test_revert.dart
@@ -0,0 +1,323 @@
+// 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.
+
+// Tests that check automatic approval of failures on a revert on the CI
+
+import 'package:builder/src/firestore.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'package:builder/src/result.dart';
+import 'fakes.dart';
+import 'test_data.dart';
+
+void main() async {
+ test('fetch commit that is a revert', () async {
+ final builderTest = BuilderTest(revertCommitHash, revertUnchangedChange);
+ builderTest.firestore.commits[revertedCommitHash] = revertedCommit;
+ when(builderTest.client.get(any))
+ .thenAnswer((_) => Future(() => ResponseFake(revertGitilesLog)));
+ await builderTest.storeBuildCommitsInfo();
+ expect(builderTest.builder.endIndex, revertCommit['index']);
+ expect(builderTest.builder.startIndex, landedCommit['index'] + 1);
+ expect(
+ (await builderTest.builder.firestore.getCommit(revertCommitHash))
+ .toJson(),
+ revertCommit);
+ });
+
+ test('fetch commit that is a reland (as a reland)', () async {
+ final builderTest = BuilderTest(relandCommitHash, relandUnchangedChange);
+ builderTest.firestore.commits[revertedCommitHash] = revertedCommit;
+ when(builderTest.client.get(any)).thenAnswer(
+ (_) => Future(() => ResponseFake(revertAndRelandGitilesLog)));
+ await builderTest.storeBuildCommitsInfo();
+ expect(builderTest.builder.endIndex, relandCommit['index']);
+ expect(builderTest.builder.startIndex, revertCommit['index'] + 1);
+ expect(
+ (await builderTest.builder.firestore.getCommit(revertCommitHash))
+ .toJson(),
+ revertCommit);
+ expect(
+ (await builderTest.builder.firestore.getCommit(commit56Hash)).toJson(),
+ commit56);
+ expect(
+ (await builderTest.builder.firestore.getCommit(relandCommitHash))
+ .toJson(),
+ relandCommit);
+ });
+
+ test('fetch commit that is a reland (as a revert)', () async {
+ final builderTest =
+ RevertBuilderTest(relandCommitHash, relandUnchangedChange);
+ when(builderTest.client.get(any))
+ .thenAnswer((_) => Future(() => ResponseFake(relandGitilesLog)));
+ await builderTest.storeBuildCommitsInfo();
+ expect(builderTest.builder.endIndex, relandCommit['index']);
+ expect(builderTest.builder.startIndex, revertCommit['index'] + 1);
+ expect(
+ (await builderTest.builder.firestore.getCommit(relandCommitHash))
+ .toJson(),
+ relandCommit);
+ });
+
+ test('Automatically approve expected failure on revert', () async {
+ final builderTest = RevertBuilderTest(revertCommitHash, revertChange);
+ await builderTest.update();
+ await builderTest.storeChange(revertChange);
+ expect(
+ builderTest.firestore.results.values
+ .where((result) => result[fBlamelistEndIndex] == 55)
+ .single,
+ revertResult);
+ });
+
+ test('Revert in blamelist, doesn\'t match new failure', () async {
+ final builderTest =
+ RevertBuilderTest(commit56Hash, commit56UnmatchingChange);
+ await builderTest.update();
+ await builderTest.storeChange(commit56UnmatchingChange);
+ await builderTest.storeChange(commit56DifferentNameChange);
+ await builderTest.storeChange(commit56Change);
+
+ Future<bool> findApproval(Map<String, dynamic> change) async {
+ final result = await builderTest.firestore
+ .findActiveResults(change['name'], change['configuration']);
+ return ResultRecord(result.single.fields).approved;
+ }
+
+ expect(await findApproval(commit56UnmatchingChange), false);
+ expect(await findApproval(commit56DifferentNameChange), false);
+ expect(await findApproval(commit56Change), true);
+ });
+}
+
+class RevertBuilderTest extends BuilderTest {
+ RevertBuilderTest(String commitHash, Map<String, dynamic> firstChange)
+ : super(commitHash, firstChange) {
+ expect(revertedCommit[fIndex] + 1, fakeFirestoreCommitsFirstIndex);
+ expect(revertCommit[fIndex] - 1, fakeFirestoreCommitsLastIndex);
+ firestore.commits
+ ..[revertedCommitHash] = revertedCommit
+ ..[revertCommitHash] = revertCommit
+ ..[commit56Hash] = commit56;
+ firestore.results['revertedResult id'] = revertedResult;
+ }
+}
+
+// Commits
+const String revertedCommitHash = '50abcd55abcd';
+const int revertedReview = 3926;
+const int revertedIndex = 50;
+Map<String, dynamic> revertedCommit = Map.unmodifiable({
+ 'author': 'gerrit_reverted_user@example.com',
+ 'created': DateTime.parse('2019-11-22 02:01:00Z'),
+ 'index': revertedIndex,
+ 'title': 'A commit reverted by commit 55, with index 50',
+ 'review': revertedReview,
+ 'hash': revertedCommitHash,
+});
+
+const String revertCommitHash = '55ffffdddd';
+const int revertReview = 3426;
+const int revertIndex = 55;
+Map<String, dynamic> revertCommit = Map.unmodifiable({
+ 'author': 'gerrit_revert_user@example.com',
+ 'created': DateTime.parse('2019-11-29 16:15:00Z'),
+ 'index': revertIndex,
+ 'title': 'Revert "${revertedCommit[fTitle]}"',
+ 'hash': revertCommitHash,
+ 'review': revertReview,
+ 'revert_of': revertedCommitHash,
+});
+
+const String commit56Hash = '56ffeeddccbbaa00';
+Map<String, dynamic> commit56 = Map.unmodifiable({
+ 'author': 'gerrit_revert_user@example.com',
+ 'created': DateTime.parse('2019-11-29 17:15:00Z'),
+ 'index': revertIndex + 1,
+ 'title': 'A commit with index 56',
+ 'hash': commit56Hash,
+});
+
+const String relandCommitHash = '57eeddccff7733';
+const int relandReview = 98999;
+Map<String, dynamic> relandCommit = Map.unmodifiable({
+ 'author': 'gerrit_reland_user@example.com',
+ 'created': DateTime.parse('2020-01-13 06:16:00Z'),
+ 'index': revertIndex + 2,
+ 'title': 'Reland "${revertedCommit[fTitle]}"',
+ 'hash': relandCommitHash,
+ 'review': relandReview,
+ 'reland_of': revertedCommitHash,
+});
+
+// Changes
+// This change is an unchanged passing result, used as the first result in
+// a chunk with no changed results.
+const Map<String, dynamic> revertUnchangedChange = {
+ "name": "dart2js_extra/local_function_signatures_strong_test/none",
+ "configuration": "dart2js-new-rti-linux-x64-d8",
+ "suite": "dart2js_extra",
+ "test_name": "local_function_signatures_strong_test/none",
+ "time_ms": 2384,
+ "result": "Pass",
+ "expected": "Pass",
+ "matches": false,
+ "bot_name": "luci-dart-try-xenial-70-8fkh",
+ "commit_hash": revertCommitHash,
+ "previous_commit_hash": landedCommitHash,
+ "commit_time": 1563576771,
+ "build_number": "401",
+ "previous_build_number": "400",
+ "changed": false,
+};
+
+Map<String, dynamic> relandUnchangedChange = Map.from(revertUnchangedChange)
+ ..["commit_hash"] = relandCommitHash
+ ..["previous_commit_hash"] = revertCommitHash;
+
+const Map<String, dynamic> revertChange = {
+ "name": "test_suite/fixed_broken_test",
+ "configuration": "a_different_configuration",
+ "suite": "test_suite",
+ "test_name": "fixed_broken_test",
+ "time_ms": 2384,
+ "result": "RuntimeError",
+ "expected": "Pass",
+ "matches": false,
+ "bot_name": "a_ci_bot",
+ "commit_hash": revertCommitHash,
+ "commit_time": 1563576771,
+ "build_number": "314",
+ "builder_name": "dart2js-rti-linux-x64-d8",
+ "flaky": false,
+ "previous_flaky": false,
+ "previous_result": "Pass",
+ "previous_commit_hash": existingCommitHash,
+ "previous_commit_time": 1563576211,
+ "previous_build_number": "313",
+ "changed": true,
+};
+
+const Map<String, dynamic> revertedChange = {
+ "name": "test_suite/fixed_broken_test",
+ "configuration": "a_configuration",
+ "suite": "test_suite",
+ "test_name": "fixed_broken_test",
+ "time_ms": 2384,
+ "result": "Pass",
+ "expected": "Pass",
+ "matches": true,
+ "bot_name": "a_ci_bot",
+ "commit_hash": revertedCommitHash,
+ "commit_time": 1563576771,
+ "build_number": "308",
+ "builder_name": "dart2js-rti-linux-x64-d8",
+ "flaky": false,
+ "previous_flaky": false,
+ "previous_result": "RuntimeError",
+ "previous_commit_hash": "a nonexistent hash",
+ "previous_commit_time": 1563576211,
+ "previous_build_number": "306",
+ "changed": true
+};
+
+Map<String, dynamic> commit56Change = Map.from(revertChange)
+ ..['commit_hash'] = commit56Hash;
+Map<String, dynamic> commit56UnmatchingChange = Map.from(commit56Change)
+ ..['configuration'] = 'a_configuration'
+ ..['commit_hash'] = commit56Hash
+ ..['result'] = 'CompileTimeError';
+Map<String, dynamic> commit56DifferentNameChange = Map.from(commit56Change)
+ ..['commit_hash'] = commit56Hash
+ ..['name'] = 'test_suite/broken_test'
+ ..['test_name'] = 'broken_test';
+
+// Results
+const Map<String, dynamic> revertResult = {
+ "configurations": ["a_different_configuration"],
+ "active": true,
+ "active_configurations": ["a_different_configuration"],
+ "name": "test_suite/fixed_broken_test",
+ "result": "RuntimeError",
+ "expected": "Pass",
+ "previous_result": "Pass",
+ "blamelist_start_index": commit53Index,
+ "blamelist_end_index": revertIndex,
+ "pinned_index": revertIndex,
+ "approved": true,
+};
+
+const Map<String, dynamic> revertedResult = {
+ "configurations": ["a_configuration"],
+ "name": "test_suite/fixed_broken_test",
+ "result": "Pass",
+ "expected": "Pass",
+ "previous_result": "RuntimeError",
+ "blamelist_start_index": revertedIndex,
+ "blamelist_end_index": revertedIndex,
+};
+
+// Git logs
+String escape(s) => s.replaceAll('"', '\\"');
+String revertGitilesLog = gitilesLog([revertCommitJson]);
+String relandGitilesLog = gitilesLog([relandCommitJson(relandAsRevert)]);
+String revertAndRelandGitilesLog = gitilesLog(
+ [relandCommitJson(relandAsReland), commit56Json, revertCommitJson]);
+
+String gitilesLog(List<String> commitLogs) => '''
+)]}'
+{
+ "log": [
+ ${commitLogs.join(",\n")}
+ ]
+}
+''';
+
+String revertCommitJson = '''
+ {
+ "commit": "$revertCommitHash",
+ "parents": ["$landedCommitHash"],
+ "author": {
+ "email": "${revertCommit[fAuthor]}"
+ },
+ "committer": {
+ "time": "Fri Nov 29 16:15:00 2019 +0000"
+ },
+ "message": "${escape(revertCommit[fTitle])}\\n\\nThis reverts commit $revertedCommitHash.\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/$revertReview\\n\\n"
+ }
+''';
+
+String commit56Json = '''
+ {
+ "commit": "$commit56Hash",
+ "parents": ["$revertCommitHash"],
+ "author": {
+ "email": "${commit56[fAuthor]}"
+ },
+ "committer": {
+ "time": "Fri Nov 29 17:15:00 2019 +0000"
+ },
+ "message": "${escape(commit56[fTitle])}\\n\\nNo line like: This reverts commit $revertedCommitHash.\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nNo review line either\\n\\n"
+ }
+''';
+
+String relandCommitJson(String relandLine) => '''
+ {
+ "commit": "$relandCommitHash",
+ "parents": ["$commit56Hash"],
+ "author": {
+ "email": "${relandCommit[fAuthor]}"
+ },
+ "committer": {
+ "time": "Mon Jan 13 06:16:00 2020 +0000"
+ },
+ "message": "${escape(relandCommit[fTitle])}\\n\\n$relandLine\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/$relandReview\\n\\n"
+ }
+''';
+
+String relandAsRevert = "This reverts commit $revertCommitHash.";
+
+String relandAsReland = "This is a reland of $revertedCommitHash";
diff --git a/builder/test/tryjob_test.dart b/builder/test/tryjob_test.dart
new file mode 100644
index 0000000..a83b071
--- /dev/null
+++ b/builder/test/tryjob_test.dart
@@ -0,0 +1,377 @@
+// 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 'package:builder/src/firestore.dart';
+import 'package:googleapis/firestore/v1.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'package:builder/src/firestore.dart' as fs;
+import 'package:builder/src/commits_cache.dart';
+import 'package:builder/src/tryjob.dart';
+import 'fakes.dart';
+import 'package:googleapis_auth/auth_io.dart';
+import 'package:http/http.dart' as http;
+
+// These tests read and write data from the staging Firestore database.
+// They create a fake review, and fake try builds against that review.
+// They attempt to remove these records in the cleanup, even if tests fail.
+// Requires the environment variable GOOGLE_APPLICATION_CREDENTIALS
+// to point to a json key to a service account.
+// To run against the staging database, use a service account.
+// with write access to dart_ci_staging datastore.
+// The test must be compiled with nodejs, and run using the 'node' command.
+
+const fakeReview = 123;
+const buildBaseCommit = 69191;
+const buildBaseCommitHash = 'b681bfd8d275b84b51f37919f0edc0d8563a870f';
+const buildBuildbucketId = 'a fake buildbucket ID';
+const ciResultsCommitIndex = 69188;
+const ciResultsCommitHash = '7b6adc6083c9918c826f5b82d25fdf6da9d90499';
+const reviewForCommit69190 = 138293;
+const patchsetForCommit69190 = 2;
+const tryBuildForCommit69190 = '99998';
+const reviewForCommit69191 = 138500;
+const patchsetForCommit69191 = 7;
+const tryBuildForCommit69191 = '99999';
+const earlierTryBuildsBaseCommit = '8ae984c54ab36a05422af6f250dbaa7da70fc461';
+const earlierTryBuildsResultsCommit = earlierTryBuildsBaseCommit;
+
+const fakeSuite = 'fake_test_suite';
+const fakeTestName = 'subdir/a_dart_test';
+const otherFakeTestName = 'another_dart_test';
+const fakeTest = '$fakeSuite/$fakeTestName';
+const otherFakeTest = '$fakeSuite/$otherFakeTestName';
+const fakeConfiguration = 'a configuration';
+const otherConfiguration = 'another configuration';
+const fakeBuilderName = 'fake_builder-try';
+
+Set<String> fakeBuilders = {fakeBuilderName};
+Set<String> fakeTests = {fakeTest, otherFakeTest};
+
+void registerChangeForDeletion(Map<String, dynamic> change) {
+ fakeBuilders.add(change['builder_name']);
+ fakeTests.add(change['name']);
+}
+
+fs.FirestoreService firestore;
+http.Client client;
+CommitsCache commitsCache;
+
+void main() async {
+ final baseClient = http.Client();
+ final mockClient = HttpClientMock();
+ addFakeReplies(mockClient);
+ client = await clientViaApplicationDefaultCredentials(
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
+ baseClient: baseClient);
+ final api = FirestoreApi(client);
+ firestore = FirestoreService(api, client);
+ if (!await firestore.isStaging()) {
+ throw (TestFailure('Error: test is being run on production'));
+ }
+
+ if (!await firestore.isStaging()) {
+ throw 'Test cannot be run on production database';
+ }
+ commitsCache = CommitsCache(firestore, mockClient);
+ setUpAll(addFakeResultsToLandedReviews);
+ tearDownAll(() async {
+ await deleteFakeReviewAndResults();
+ baseClient.close();
+ });
+
+ test('create fake review', () async {
+ registerChangeForDeletion(unchangedChange);
+ final tryjob = Tryjob('refs/changes/$fakeReview/2', buildBuildbucketId,
+ buildBaseCommitHash, commitsCache, firestore, mockClient);
+ await tryjob.process([unchangedChange]);
+ expect(tryjob.success, true);
+ });
+
+ test('failure coming from a landed CL not in base', () async {
+ registerChangeForDeletion(changeMatchingLandedCL);
+ final tryjob = Tryjob('refs/changes/$fakeReview/2', buildBuildbucketId,
+ buildBaseCommitHash, commitsCache, firestore, mockClient);
+ await tryjob.process([changeMatchingLandedCL]);
+ expect(tryjob.success, true);
+ });
+
+ // TODO(karlklose): These tests do not apply to the new model of processing a
+ // build in one step.
+
+ // test('process result on different configuration', () async {
+ // final changeOnDifferentConfiguration = Map<String, dynamic>.from(baseChange)
+ // ..['configuration'] = otherConfiguration;
+
+ // registerChangeForDeletion(changeOnDifferentConfiguration);
+ // final tryjob = Tryjob('refs/changes/$fakeReview/2', buildBuildbucketId,
+ // buildBaseCommitHash, commitsCache, firestore, mockClient);
+ // await tryjob.process([changeOnDifferentConfiguration]);
+ // expect(tryjob.success, false);
+ // });
+
+ // test('process result where different result landed last', () async {
+ // final otherNameChange = Map<String, dynamic>.from(baseChange)
+ // ..['name'] = otherFakeTest;
+ // registerChangeForDeletion(otherNameChange);
+ // final tryjob = Tryjob('refs/changes/$fakeReview/2', buildBuildbucketId,
+ // buildBaseCommitHash, commitsCache, firestore, mockClient);
+ // await tryjob.process([otherNameChange]);
+ // expect(tryjob.success, false);
+ // });
+
+ test('test becomes flaky', () async {
+ final flakyTest = <String, dynamic>{
+ 'name': 'flaky_test',
+ 'result': 'RuntimeError',
+ 'flaky': true,
+ 'matches': false,
+ 'changed': true,
+ 'build_number': '99995',
+ 'builder_name': 'flaky_test_builder-try',
+ };
+
+ final flakyChange = Map<String, dynamic>.from(baseChange)
+ ..addAll(flakyTest);
+ final flakyTestBuildbucketId = 'flaky_buildbucket_ID';
+ registerChangeForDeletion(flakyChange);
+ final tryjob = Tryjob('refs/changes/$fakeReview/2', flakyTestBuildbucketId,
+ buildBaseCommitHash, commitsCache, firestore, mockClient);
+ await tryjob.process([flakyChange]);
+ expect(tryjob.success, true);
+ expect(tryjob.countNewFlakes, 1);
+ expect(tryjob.countUnapproved, 0);
+ });
+
+ test('new failure', () async {
+ final failingTest = <String, dynamic>{
+ 'name': 'failing_test',
+ 'result': 'RuntimeError',
+ 'matches': false,
+ 'changed': true,
+ 'build_number': '99994',
+ 'builder_name': 'failing_test_builder-try',
+ };
+
+ final failingChange = Map<String, dynamic>.from(baseChange)
+ ..addAll(failingTest);
+ final failingTestBuildbucketId = 'failing_buildbucket_ID';
+ registerChangeForDeletion(failingChange);
+ final tryjob = Tryjob(
+ 'refs/changes/$fakeReview/2',
+ failingTestBuildbucketId,
+ buildBaseCommitHash,
+ commitsCache,
+ firestore,
+ mockClient);
+ await tryjob.process([failingChange]);
+ expect(tryjob.success, false);
+ expect(tryjob.countNewFlakes, 0);
+ expect(tryjob.countUnapproved, 1);
+ });
+}
+
+void addFakeReplies(HttpClientMock client) {
+ when(client.get(any))
+ .thenAnswer((_) => Future(() => ResponseFake(FakeReviewGerritLog)));
+}
+
+Future<void> addFakeResultsToLandedReviews() async {
+ var tryjob = Tryjob(matchingLandedChange['commit_hash'], buildBuildbucketId,
+ earlierTryBuildsBaseCommit, commitsCache, firestore, client);
+ await tryjob.process([matchingLandedChange, overriddenMatchingLandedChange]);
+ expect(tryjob.success, false);
+ tryjob = Tryjob(
+ overridingUnmatchingLandedChange['commit_hash'],
+ buildBuildbucketId,
+ earlierTryBuildsBaseCommit,
+ commitsCache,
+ firestore,
+ client);
+ await tryjob.process([overridingUnmatchingLandedChange]);
+ expect(tryjob.success, false);
+}
+
+Future<void> deleteFakeReviewAndResults() async {
+ Future<void> deleteDocuments(List<RunQueryResponse> response) async {
+ for (final document in response.map((r) => r.document)) {
+ await firestore.deleteDocument(document.name);
+ }
+ }
+
+ for (final test in fakeTests) {
+ await deleteDocuments(await firestore.query(
+ from: 'try_results', where: fieldEquals('name', test)));
+ }
+ for (final builder in fakeBuilders) {
+ await deleteDocuments(await firestore.query(
+ from: 'try_builds', where: fieldEquals('builder', builder)));
+ }
+
+ await firestore.deleteDocument(firestore.documents + '/reviews/$fakeReview');
+}
+
+Map<String, dynamic> baseChange = {
+ 'name': fakeTest,
+ 'configuration': fakeConfiguration,
+ 'suite': fakeSuite,
+ 'test_name': fakeTestName,
+ 'time_ms': 2384,
+ 'result': 'RuntimeError',
+ 'previous_result': 'Pass',
+ 'expected': 'Pass',
+ 'matches': false,
+ 'changed': true,
+ 'commit_hash': 'refs/changes/$fakeReview/2',
+ 'commit_time': 1583906489,
+ 'build_number': '99997',
+ 'builder_name': fakeBuilderName,
+ 'flaky': false,
+ 'previous_flaky': false,
+ 'previous_commit_hash': ciResultsCommitHash,
+ 'previous_commit_time': 1583906489,
+ 'bot_name': 'luci-dart-try-xenial-70-8fkh',
+ 'previous_build_number': '306',
+};
+
+Map<String, dynamic> changeMatchingLandedCL = Map.from(baseChange);
+
+Map<String, dynamic> unchangedChange = Map.from(baseChange)
+ ..addAll({
+ 'name': otherFakeTest,
+ 'test_name': otherFakeTestName,
+ 'result': 'Pass',
+ 'matches': true,
+ 'changed': false,
+ 'configuration': otherConfiguration,
+ 'build_number': '99997'
+ });
+
+Map<String, dynamic> matchingLandedChange = Map.from(baseChange)
+ ..addAll({
+ 'commit_hash': 'refs/changes/$reviewForCommit69190/$patchsetForCommit69190',
+ 'build_number': tryBuildForCommit69190,
+ 'previous_commit_hash': earlierTryBuildsResultsCommit,
+ });
+
+Map<String, dynamic> overriddenMatchingLandedChange =
+ Map.from(matchingLandedChange)
+ ..addAll({
+ 'name': otherFakeTest,
+ 'test_name': otherFakeTestName,
+ });
+
+Map<String, dynamic> overridingUnmatchingLandedChange =
+ Map.from(overriddenMatchingLandedChange)
+ ..addAll({
+ 'commit_hash':
+ 'refs/changes/$reviewForCommit69191/$patchsetForCommit69191',
+ 'result': 'CompileTimeError',
+ 'build_number': tryBuildForCommit69191,
+ });
+
+String FakeReviewGerritLog = '''
+)]}'
+{
+ "id": "sdk~master~Ie212fae88bc1977e34e4d791c644b77783a8deb1",
+ "project": "sdk",
+ "branch": "master",
+ "hashtags": [],
+ "change_id": "Ie212fae88bc1977e34e4d791c644b77783a8deb1",
+ "subject": "A fake review",
+ "status": "MERGED",
+ "created": "2020-03-17 12:17:05.000000000",
+ "updated": "2020-03-17 12:17:25.000000000",
+ "submitted": "2020-03-17 12:17:25.000000000",
+ "submitter": {
+ "_account_id": 5260,
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org"
+ },
+ "insertions": 61,
+ "deletions": 155,
+ "total_comment_count": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "submission_id": "$fakeReview",
+ "_number": $fakeReview,
+ "owner": {
+ },
+ "current_revision": "82f3f81fc82d06c575b0137ddbe71408826d8b46",
+ "revisions": {
+ "82f3f81fc82d06c575b0137ddbe71408826d8b46": {
+ "kind": "REWORK",
+ "_number": 2,
+ "created": "2020-02-17 12:17:25.000000000",
+ "uploader": {
+ "_account_id": 5260,
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org"
+ },
+ "ref": "refs/changes/23/$fakeReview/2",
+ "fetch": {
+ "rpc": {
+ "url": "rpc://dart/sdk",
+ "ref": "refs/changes/23/$fakeReview/2"
+ },
+ "http": {
+ "url": "https://dart.googlesource.com/sdk",
+ "ref": "refs/changes/23/$fakeReview/2"
+ },
+ "sso": {
+ "url": "sso://dart/sdk",
+ "ref": "refs/changes/23/$fakeReview/2"
+ }
+ },
+ "commit": {
+ "parents": [
+ {
+ "commit": "d2d00ff357bd64a002697b3c96c92a0fec83328c",
+ "subject": "[cfe] Allow unassigned late local variables"
+ }
+ ],
+ "author": {
+ "name": "gerrit_user",
+ "email": "gerrit_user@example.com",
+ "date": "2020-02-17 12:17:25.000000000",
+ "tz": 0
+ },
+ "committer": {
+ "name": "commit-bot@chromium.org",
+ "email": "commit-bot@chromium.org",
+ "date": "2020-02-17 12:17:25.000000000",
+ "tz": 0
+ },
+ "subject": "A fake review",
+ "message": "A fake review\\n\\nReviewed-by: XXX\\nCommit-Queue: XXXXXX\\n"
+ },
+ "description": "Rebase"
+ },
+ "8bae95c4001a0815e89ebc4c89dc5ad42337a01b": {
+ "kind": "REWORK",
+ "_number": 1,
+ "created": "2020-02-17 12:17:05.000000000",
+ "uploader": {
+ },
+ "ref": "refs/changes/23/$fakeReview/1",
+ "fetch": {
+ "rpc": {
+ "url": "rpc://dart/sdk",
+ "ref": "refs/changes/23/$fakeReview/1"
+ },
+ "http": {
+ "url": "https://dart.googlesource.com/sdk",
+ "ref": "refs/changes/23/$fakeReview/1"
+ },
+ "sso": {
+ "url": "sso://dart/sdk",
+ "ref": "refs/changes/23/$fakeReview/1"
+ }
+ }
+ }
+ },
+ "requirements": []
+}
+''';