[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": []
+}
+''';