[cloud functions] Approve expected failures when landing a revert

Change-Id: I3eaf2c34ca0c5633c3753bc86eac401acf675f3f
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/136623
Reviewed-by: Jonas Termansen <sortie@google.com>
diff --git a/functions/node/builder.dart b/functions/node/builder.dart
index ba3204b..da11e17 100644
--- a/functions/node/builder.dart
+++ b/functions/node/builder.dart
@@ -8,11 +8,14 @@
 import 'package:pool/pool.dart';
 
 import 'firestore.dart';
+import 'result.dart';
+import 'reverted_changes.dart';
 
 const prefix = ")]}'\n";
 
-bool isChangedResult(Map<String, dynamic> result) =>
-    result['changed'] && !result['flaky'] && !result['previous_flaky'];
+Iterable<int> range(int start, int end) sync* {
+  for (int i = start; i < end; ++i) yield i;
+}
 
 const months = const {
   'Jan': '01',
@@ -51,7 +54,12 @@
   int buildNumber;
   int startIndex;
   int endIndex;
+  Map<String, dynamic> endCommit;
+  Future<void> _awaitCommits;
+  List<Map<String, dynamic>> 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;
@@ -98,7 +106,7 @@
   /// 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.
-    var endCommit = await firestore.getCommit(commitHash);
+    endCommit = await firestore.getCommit(commitHash);
     if (endCommit == null) {
       await getMissingCommits();
       endCommit = await firestore.getCommit(commitHash);
@@ -106,42 +114,54 @@
         throw 'Result received with unknown commit hash $commitHash';
       }
     }
-    endIndex = endCommit['index'];
+    endIndex = endCommit[fIndex];
     // 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 firestore.getCommit(firstResult['previous_commit_hash']);
-      startIndex = startCommit['index'] + 1;
+      startIndex = startCommit[fIndex] + 1;
       if (startIndex > endIndex) {
         throw ArgumentError("Results received with empty blamelist\n"
             "previous commit: ${firstResult['previous_commit_hash']}\n"
             "built commit: $commitHash");
       }
     }
-    Future<void> fetchApprovals(int review, int index) async {
-      if (review != null) {
-        for (final result in await firestore.tryApprovals(review)) {
-          tryApprovals[testResult(result)] = index;
-        }
-      }
-    }
-
-    await fetchApprovals(endCommit['review'], endIndex);
-    for (var index = startIndex; index < endIndex; ++index) {
-      await fetchApprovals(
-          (await firestore.getCommitByIndex(index))['review'], index);
-    }
   }
 
+  /// This async function's implementation runs exactly once.
+  /// Later invocations return the same future returned by the first invocation.
+  Future<void> fetchReviewsAndReverts() => _awaitCommits ??= () async {
+        commits = [
+          for (var index = startIndex; index < endIndex; ++index)
+            await firestore.getCommitByIndex(index),
+          endCommit
+        ];
+        for (final commit in commits) {
+          final index = commit[fIndex];
+          final review = commit[fReview];
+          final reverted = commit[fRevertOf];
+          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));
+          }
+        }
+      }();
+
   /// This function is idempotent, so every call of it should write the
   /// same info to new Firestore documents.  It is save to call multiple
   /// times simultaneously.
   Future<void> getMissingCommits() async {
     final lastCommit = await firestore.getLastCommit();
     final lastHash = lastCommit['id'];
-    final lastIndex = lastCommit['index'];
+    final lastIndex = lastCommit[fIndex];
 
     final logUrl = 'https://dart.googlesource.com/sdk/+log/';
     final range = '$lastHash..master';
@@ -169,14 +189,26 @@
     var index = lastIndex + 1;
     for (Map<String, dynamic> commit in commits.reversed) {
       final review = _review(commit);
-      final reverted = _revert(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[fRevertOf] != null) {
+          reverted = null;
+          relanded = revertedCommit[fRevertOf];
+        }
+      }
       await firestore.addCommit(commit['commit'], {
-        'author': commit['author']['email'],
-        'created': parseGitilesDateTime(commit['committer']['time']),
-        'index': index,
-        'title': commit['message'].split('\n').first,
-        if (review != null) 'review': review,
-        if (reverted != null) 'revert_of': reverted
+        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);
@@ -204,18 +236,25 @@
 
   Future<void> storeChange(Map<String, dynamic> change) async {
     countChanges++;
-    final failure = !change['matches'];
+    await fetchReviewsAndReverts();
+    final failure = isFailure(change);
     var approved;
     String result = await firestore.findResult(change, startIndex, endIndex);
     List<Map<String, dynamic>> activeResults =
         await firestore.findActiveResults(change);
     if (result == null) {
-      final approvingReviewLandedIndex = tryApprovals[testResult(change)];
-      approved = approvingReviewLandedIndex != null;
-      await firestore.storeResult(change, startIndex, endIndex,
+      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: approvingReviewLandedIndex,
+          landedReviewIndex: approvingIndex,
           failure: failure);
+      await firestore.storeResult(newResult);
       if (approved) {
         countApprovalsCopied++;
         if (countApprovalsCopied <= 10)
@@ -231,8 +270,8 @@
 
     for (final activeResult in activeResults) {
       // Log error message if any expected invariants are violated
-      if (activeResult['blamelist_end_index'] >= startIndex ||
-          !activeResult['active_configurations']
+      if (activeResult[fBlamelistEndIndex] >= startIndex ||
+          !activeResult[fActiveConfigurations]
               .contains(change['configuration'])) {
         print('Unexpected active result when processing new change:\n'
             'Active result: $activeResult\n\n'
@@ -262,9 +301,25 @@
 String _revert(Map<String, dynamic> commit) =>
     revertRegExp.firstMatch(commit['message'])?.group(1);
 
-String testResult(Map<String, dynamic> change) => [
-      change['name'],
-      change['result'],
-      change['previous_result'] ?? 'new test',
-      change['expected']
-    ].join(' ');
+final relandRegExp =
+    RegExp('^This is a reland of ([\\da-f]+)\\.?\$', multiLine: true);
+
+String _reland(Map<String, dynamic> commit) =>
+    relandRegExp.firstMatch(commit['message'])?.group(1);
+
+Map<String, dynamic> constructResult(
+        Map<String, dynamic> change, int startIndex, int endIndex,
+        {bool approved, int landedReviewIndex, bool failure}) =>
+    {
+      fName: change[fName],
+      fResult: change[fResult],
+      fPreviousResult: change[fPreviousResult] ?? 'new test',
+      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/functions/node/firestore.dart b/functions/node/firestore.dart
index aa047db..adf47d7 100644
--- a/functions/node/firestore.dart
+++ b/functions/node/firestore.dart
@@ -26,14 +26,14 @@
   Future<String> findResult(
       Map<String, dynamic> change, int startIndex, int endIndex);
 
-  Future<void> storeResult(
-      Map<String, dynamic> change, int startIndex, int endIndex,
-      {bool approved, int landedReviewIndex, bool failure});
+  Future<void> storeResult(Map<String, dynamic> result);
 
   Future<bool> updateResult(
       String result, String configuration, int startIndex, int endIndex,
       {bool failure});
 
+  Future<List<Map<String, dynamic>>> findRevertedChanges(int index);
+
   Future<bool> storeTryChange(
       Map<String, dynamic> change, int review, int patchset);
 
diff --git a/functions/node/firestore_impl.dart b/functions/node/firestore_impl.dart
index 37d100b..64336f8 100644
--- a/functions/node/firestore_impl.dart
+++ b/functions/node/firestore_impl.dart
@@ -126,24 +126,8 @@
         ?.documentID;
   }
 
-  Future<void> storeResult(
-          Map<String, dynamic> change, int startIndex, int endIndex,
-          {bool approved, int landedReviewIndex, bool failure}) =>
-      firestore.collection('results').add(DocumentData.fromMap({
-            'name': change['name'],
-            'result': change['result'],
-            'previous_result': change['previous_result'] ?? 'new test',
-            'expected': change['expected'],
-            'blamelist_start_index': startIndex,
-            'blamelist_end_index': endIndex,
-            if (startIndex != endIndex && approved)
-              'pinned_index': landedReviewIndex,
-            'configurations': <String>[change['configuration']],
-            'approved': approved,
-            if (failure) 'active': true,
-            if (failure)
-              'active_configurations': <String>[change['configuration']]
-          }));
+  Future<void> storeResult(Map<String, dynamic> result) =>
+      firestore.collection('results').add(DocumentData.fromMap(result));
 
   Future<bool> updateResult(
       String result, String configuration, int startIndex, int endIndex,
@@ -178,6 +162,24 @@
     return firestore.runTransaction(updateGroup);
   }
 
+  Future<List<Map<String, dynamic>>> findRevertedChanges(int index) async {
+    QuerySnapshot pinned = await firestore
+        .collection('results')
+        .where('pinned_index', isEqualTo: index)
+        .get();
+    QuerySnapshot unpinned = await firestore
+        .collection('results')
+        .where('blamelist_end_index', isEqualTo: index)
+        .get();
+    return [
+      for (final document in pinned.documents) document.data.toMap(),
+      for (final document in unpinned.documents)
+        if (document.data.getInt('blamelist_start_index') == index &&
+            document.data.getInt('pinned_index') == null)
+          document.data.toMap(),
+    ];
+  }
+
   Future<bool> storeTryChange(
       Map<String, dynamic> change, int review, int patchset) async {
     String name = change['name'];
diff --git a/functions/node/result.dart b/functions/node/result.dart
new file mode 100644
index 0000000..31b6a83
--- /dev/null
+++ b/functions/node/result.dart
@@ -0,0 +1,43 @@
+// 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.
+
+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];
+
+bool isFailure(Map<String, dynamic> change) => !change[fMatches];
+
+String testResult(Map<String, dynamic> change) => [
+      change[fName],
+      change[fResult],
+      change[fPreviousResult] ?? 'new test',
+      change[fExpected]
+    ].join(' ');
+
+// Fields for commit document
+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/functions/node/reverted_changes.dart b/functions/node/reverted_changes.dart
new file mode 100644
index 0000000..68d7c94
--- /dev/null
+++ b/functions/node/reverted_changes.dart
@@ -0,0 +1,37 @@
+// 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 '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) => change[fName]));
+}
+
+class RevertedChanges {
+  final int index;
+  final int revertIndex;
+  final List<Map<String, dynamic>> changes;
+  final Map<String, List<Map<String, dynamic>>> 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] == change[fPreviousResult]);
+  }
+}
diff --git a/functions/node/test/test.dart b/functions/node/test/test.dart
index e9f51e7..7815bd2 100644
--- a/functions/node/test/test.dart
+++ b/functions/node/test/test.dart
@@ -8,6 +8,7 @@
 
 import '../builder.dart';
 import '../firestore.dart';
+import '../result.dart';
 import 'test_data.dart';
 
 class FirestoreServiceMock extends Mock implements FirestoreService {}
@@ -118,13 +119,13 @@
   test('fetch commit that is a revert', () async {
     final client = HttpClientMock();
     final firestore = FirestoreServiceMock();
-    when(firestore.getCommit(existingCommitHash))
-        .thenAnswer((_) => Future.value(existingCommit));
+    when(firestore.getCommit(landedCommitHash))
+        .thenAnswer((_) => Future.value(landedCommit));
     final revertResponses = [null, revertCommit];
     when(firestore.getCommit(revertCommitHash))
         .thenAnswer((_) => Future(() => revertResponses.removeAt(0)));
     when(firestore.getLastCommit()).thenAnswer(
-        (_) => Future(() => {...existingCommit, 'id': existingCommitHash}));
+        (_) => Future(() => {...landedCommit, 'id': landedCommitHash}));
 
     when(firestore.tryApprovals(any)).thenAnswer((_) => Future(() => []));
     when(firestore.reviewIsLanded(any)).thenAnswer((_) => Future.value(true));
@@ -133,10 +134,10 @@
         .thenAnswer((_) => Future(() => ResponseFake(revertGitilesLog)));
 
     final builder =
-        Build(revertCommitHash, revertCommitChange, null, firestore, client);
+        Build(revertCommitHash, revertUnchangedChange, null, firestore, client);
     await builder.storeBuildCommitsInfo();
     expect(builder.endIndex, revertCommit['index']);
-    expect(builder.startIndex, existingCommit['index'] + 1);
+    expect(builder.startIndex, landedCommit['index'] + 1);
     expect(builder.tryApprovals, {});
     verifyInOrder([
       verify(firestore.getCommit(revertCommitHash)).called(2),
@@ -145,7 +146,7 @@
       verify(firestore.reviewIsLanded(revertReview)),
       // We want to verify firestore.getCommit(revertCommitHash) here,
       // but it is already matched by the earlier check for the same call.
-      verify(firestore.getCommit(existingCommitHash)),
+      verify(firestore.getCommit(landedCommitHash)),
       verify(firestore.tryApprovals(revertReview))
     ]);
     verifyNoMoreInteractions(firestore);
@@ -181,10 +182,7 @@
       firestore.updateConfiguration(
           "dart2js-new-rti-linux-x64-d8", "dart2js-rti-linux-x64-d8"),
       firestore.findResult(any, 53, 54),
-      firestore.storeResult(any, 53, 54,
-          approved: argThat(isTrue, named: 'approved'),
-          landedReviewIndex: argThat(equals(54), named: 'landedReviewIndex'),
-          failure: argThat(isTrue, named: 'failure'))
+      firestore.storeResult(any),
     ]);
   });
 
@@ -222,9 +220,7 @@
     verifyInOrder([
       firestore.findResult(any, 53, 54),
       firestore.findActiveResults(landedCommitChange),
-      firestore.storeResult(any, 53, 54,
-          approved: argThat(isFalse, named: 'approved'),
-          failure: argThat(isTrue, named: 'failure')),
+      firestore.storeResult(any),
       firestore.updateActiveResult(
           activeResult, landedCommitChange['configuration'])
     ]);
diff --git a/functions/node/test/test_data.dart b/functions/node/test/test_data.dart
index 4286fb1..abdbab8 100644
--- a/functions/node/test/test_data.dart
+++ b/functions/node/test/test_data.dart
@@ -4,27 +4,11 @@
 
 import 'dart:core';
 
-const Map<String, dynamic> existingCommitChange = {
-  "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": 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
+Map<String, dynamic> fakeFirestoreCommits = {
+  existingCommitHash: existingCommit,
+  previousCommitHash: previousCommit,
+  commit53Hash: commit53,
+  landedCommitHash: landedCommit
 };
 
 const String existingCommitHash = 'an already existing commit hash';
@@ -54,6 +38,9 @@
   'review': 77779
 };
 
+const String newCommitHash = landedCommitHash;
+Map<String, dynamic> newCommitChange = landedCommitChange;
+
 const String landedCommitHash = 'a commit landing a CL hash';
 const int landedCommitIndex = commit53Index + 1;
 
@@ -65,6 +52,52 @@
   'review': 44445
 };
 
+const String revertCommitHash = 'commit reverting commit 48 hash';
+const int revertReview = 3426;
+const int revertIndex = landedCommitIndex + 1;
+Map<String, dynamic> revertCommit = {
+  'author': 'gerrit_revert_user@example.com',
+  'created': DateTime.parse('2019-11-29 16:15:00Z'),
+  'index': revertIndex,
+  'title': 'A commit reverting commit 48, with index 55',
+  'review': revertReview,
+  'revert_of': revertedCommitHash,
+};
+
+const String revertedCommitHash = '48abcd55abcd';
+const int revertedReview = 3926;
+const int revertedIndex = 48;
+Map<String, dynamic> revertedCommit = {
+  '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 48',
+  'review': revertedReview,
+};
+
+const Map<String, dynamic> existingCommitChange = {
+  "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": 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": "dart2js_extra/local_function_signatures_strong_test/none",
   "configuration": "dart2js-new-rti-linux-x64-d8",
@@ -88,22 +121,9 @@
   "changed": true
 };
 
-const String revertCommitHash = '23456ababa23456ababa';
-const String revertedHash = '012345abcde012345abcde';
-const int revertReview = 85937;
-const int revertCommitIndex = existingCommitIndex + 1;
-Map<String, dynamic> revertCommit = {
-  'author': 'gerrit_user@example.com',
-  'created': DateTime.parse('2019-11-29 15:15:00 +0000'),
-  'index': revertCommitIndex,
-  'title': 'A commit that reverts another commit',
-  'review': revertReview,
-  'revert_of': revertedHash,
-};
-
 // This change is an unchanged passing result, used as the first result in
 // a chunk with no changed results.
-const Map<String, dynamic> revertCommitChange = {
+const Map<String, dynamic> revertUnchangedChange = {
   "name": "dart2js_extra/local_function_signatures_strong_test/none",
   "configuration": "dart2js-new-rti-linux-x64-d8",
   "suite": "dart2js_extra",
@@ -116,14 +136,82 @@
   "commit_hash": revertCommitHash,
   "commit_time": 1563576771,
   "build_number": "401",
+  "previous_build_number": "400",
+  "changed": false,
+};
+
+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": "400",
-  "changed": false
+  "previous_build_number": "313",
+  "changed": true,
+};
+
+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": 53,
+  "blamelist_end_index": 55,
+  "pinned_index": 55,
+  "approved": 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": revertCommitHash,
+  "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
+};
+
+const Map<String, dynamic> revertedResult = {
+  "configurations": ["a_configuration"],
+  "name": "test_suite/fixed_broken_test",
+  "result": "Pass",
+  "expected": "Pass",
+  "previous_result": "RuntimeError",
+  "blamelist_start_index": 48,
+  "blamelist_end_index": 48,
+};
+
+Map<String, Map<String, dynamic>> fakeFirestoreResults = {
+  'activeFailureResultID': activeFailureResult
 };
 
 const Map<String, dynamic> activeFailureResult = {
@@ -132,7 +220,6 @@
   "active": true,
   "active_configurations": [testConfiguration, 'configuration 2'],
   "approved": false,
-  "suite": "test_suite",
   "result": "RuntimeError",
   "expected": "Pass",
   "previous_result": "Pass",
@@ -141,31 +228,33 @@
 };
 
 const List<Map<String, dynamic>> tryjobResults = [
-  {
-    "review": 44445,
-    "configurations": [
-      "dart2js-new-rti-linux-x64-d8",
-      "dartk-reload-rollback-linux-debug-x64",
-      "dartk-reload-linux-debug-x64"
-    ],
-    "name": "dart2js_extra/local_function_signatures_strong_test/none",
-    "patchset": 1,
-    "result": "RuntimeError",
-    "expected": "Pass",
-    "previous_result": "Pass",
-    "approved": true
-  },
-  {
-    "review": 77779,
-    "configurations": ["test_configuration"],
-    "name": "test_suite/test_name",
-    "patchset": 5,
-    "result": "RuntimeError",
-    "expected": "CompileTimeError",
-    "previous_result": "CompileTimeError",
-    "approved": true
-  },
+  review44445Result,
+  review77779Result
 ];
+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": "dart2js_extra/local_function_signatures_strong_test/none",
+  "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";
@@ -298,14 +387,14 @@
   "log": [
     {
       "commit": "$revertCommitHash",
-      "parents": ["$existingCommitHash"],
+      "parents": ["$landedCommitHash"],
       "author": {
         "email": "gerrit_user@example.com"
       },
       "committer": {
         "time": "Fri Nov 29 15:15:00 2019 +0000"
       },
-      "message": "A commit that reverts another commit\\n\\nThis reverts commit $revertedHash.\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/$revertReview\\n\\n"
+      "message": "A commit that reverts another commit\\n\\nThis reverts commit $revertedCommitHash.\\nChange-Id: I89b88c3d9f7c743fc340ee73a45c3f57059bcf30\\nReviewed-on: https://dart-review.googlesource.com/c/sdk/+/$revertReview\\n\\n"
     }
   ]
 }
diff --git a/functions/node/test/test_revert.dart b/functions/node/test/test_revert.dart
new file mode 100644
index 0000000..ca767de
--- /dev/null
+++ b/functions/node/test/test_revert.dart
@@ -0,0 +1,122 @@
+// 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:collection/collection.dart';
+import 'package:http/http.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import '../builder.dart';
+import '../firestore.dart';
+import '../result.dart';
+import 'test_data.dart';
+
+Future<void> main() async {
+  test("Base builder test", () async {
+    final builderTest = BuilderTest(newCommitHash, newCommitChange);
+    await builderTest.update();
+  });
+
+  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);
+  });
+}
+
+class BuilderTest {
+  final client = HttpClientMock();
+  final firestore = FirestoreServiceFake();
+  Build builder;
+  final String commitHash;
+  final Map<String, dynamic> firstChange;
+
+  BuilderTest(this.commitHash, this.firstChange) {
+    builder = Build(commitHash, firstChange, null, firestore, client);
+  }
+
+  Future<void> storeBuildCommitsInfo() async {
+    await builder.storeBuildCommitsInfo();
+    // Test expectations
+  }
+
+  Future<void> storeChange(Map<String, dynamic> change) async {
+    return builder.storeChange(change);
+  }
+
+  Future<void> update() async {
+    await storeBuildCommitsInfo();
+  }
+}
+
+class FirestoreServiceFake extends Fake implements FirestoreService {
+  final Map<String, Map<String, dynamic>> commits =
+      Map.from(fakeFirestoreCommits);
+  final Map<String, Map<String, dynamic>> results =
+      Map.from(fakeFirestoreResults);
+  int _addedResultID = 0;
+
+  Future<void> addCommit(String id, Map<String, dynamic> data) async {
+    commits[id] = data;
+  }
+
+  Future<List<Map<String, dynamic>>> findActiveResults(
+          Map<String, dynamic> change) =>
+      Future.value([]);
+
+  Future<String> findResult(
+          Map<String, dynamic> change, int startIndex, int endIndex) =>
+      Future.value(null);
+
+  Future<List<Map<String, dynamic>>> findRevertedChanges(int index) =>
+      Future.value(results.values
+          .where((change) =>
+              change[fPinnedIndex] == index ||
+              (change[fBlamelistStartIndex] == index &&
+                  change[fBlamelistEndIndex] == index))
+          .toList());
+
+  Future<Map<String, dynamic>> getCommit(String hash) =>
+      Future.value(commits[hash]);
+
+  Future<Map<String, dynamic>> getCommitByIndex(int index) =>
+      Future.value(commits.values
+          .firstWhere((commit) => commit[fIndex] == index, orElse: () => null));
+
+  Future<Map<String, dynamic>> getLastCommit() =>
+      Future.value(maxBy<Map<String, dynamic>, int>(
+          commits.values, (commit) => commit[fIndex]));
+
+  Future<void> storeResult(Map<String, dynamic> result) async {
+    final id = 'resultDocumentID$_addedResultID';
+    _addedResultID++;
+    results[id] = result;
+  }
+
+  Future<List<Map<String, dynamic>>> tryApprovals(int review) =>
+      Future.value([]);
+}
+
+class HttpClientMock extends Mock implements BaseClient {}
+
+class ResponseFake extends Fake implements Response {
+  String body;
+  ResponseFake(this.body);
+}
+
+class RevertBuilderTest extends BuilderTest {
+  RevertBuilderTest(String commitHash, Map<String, dynamic> firstChange)
+      : super(commitHash, firstChange) {
+    firestore.commits[revertCommitHash] = revertCommit;
+    firestore.commits[revertedCommitHash] = revertedCommit;
+    firestore.results['revertedResultID'] = revertedResult;
+  }
+}
diff --git a/results_feed/doc/results_feed.proto b/results_feed/doc/results_feed.proto
index 0aab202..fdb82f0 100644
--- a/results_feed/doc/results_feed.proto
+++ b/results_feed/doc/results_feed.proto
@@ -117,6 +117,7 @@
   string title = 5; // The first line of the commit message.
   int32 review = 6; // Optional: the id of the review this commit lands.
   string revert_of = 7; // Optional: the hash of the commit this commit reverts.
+  string reland_of = 8; // Optional: the hash of the relanded commit.
 }
 
 // A map from configuration names to builder names.