[cloud functions] Copy approvals from a Gerrit review when it lands

Change-Id: I41d88a2cc8d877f70d499561c29c511280f59f87
Bug: https://github.com/dart-lang/dart_ci/issues/48
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/126880
Reviewed-by: Alexander Thomas <athom@google.com>
Reviewed-by: Jonas Termansen <sortie@google.com>
diff --git a/functions/node/builder.dart b/functions/node/builder.dart
index 75e095c..ccb97be 100644
--- a/functions/node/builder.dart
+++ b/functions/node/builder.dart
@@ -57,6 +57,7 @@
   int buildNumber;
   int startIndex;
   int endIndex;
+  Set<String> tryApprovals;
 
   Statistics stats = Statistics();
 
@@ -93,6 +94,12 @@
       }
     }
     endIndex = commit['index'];
+    if (commit.containsKey('review')) {
+      tryApprovals = {
+        for (final result in await firestore.tryApprovals(commit['review']))
+          testResult(result)
+      };
+    }
     // If this is a new builder, use the current commit as a trivial blamelist.
     if (firstResult['previous_commit_hash'] == null) {
       startIndex = endIndex;
@@ -102,8 +109,11 @@
     }
   }
 
+  /// 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(commitHash);
+    final lastCommit = await firestore.getLastCommit();
     final lastHash = lastCommit['id'];
     final lastIndex = lastCommit['index'];
 
@@ -137,16 +147,32 @@
     }
     var index = lastIndex + 1;
     for (Map<String, dynamic> commit in commits.reversed) {
+      final review = _review(commit);
       await firestore.addCommit(commit['commit'], {
         'author': commit['author']['email'],
         'created': parseGitilesDateTime(commit['committer']['time']),
         'index': index,
-        'title': commit['message'].split('\n').first
+        'title': commit['message'].split('\n').first,
+        if (review != null) 'review': review
       });
+      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 {
+    int 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);
+  }
+
   Future<void> storeConfigurationsInfo(Iterable<String> configurations) async {
     for (final configuration in configurations) {
       await firestore.updateConfiguration(configuration, builderName);
@@ -154,11 +180,29 @@
   }
 
   Future<void> storeChange(Map<String, dynamic> change) async {
-    firestore.storeChange(change, startIndex, endIndex);
+    firestore.storeChange(change, startIndex, endIndex,
+        approved: tryApprovals?.contains(testResult(change)) ?? false);
     stats.changes++;
   }
 }
 
+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;
+}
+
+String testResult(Map<String, dynamic> change) => [
+      change['name'],
+      change['result'],
+      change['previous_result'],
+      change['expected']
+    ].join(' ');
+
 class Statistics {
   int results = 0;
   int changes = 0;
diff --git a/functions/node/firestore.dart b/functions/node/firestore.dart
index 36c99fd..8bea531 100644
--- a/functions/node/firestore.dart
+++ b/functions/node/firestore.dart
@@ -11,7 +11,7 @@
 
   Future<Map<String, dynamic>> getCommit(String hash);
 
-  Future<Map<String, dynamic>> getLastCommit(String hash);
+  Future<Map<String, dynamic>> getLastCommit();
 
   Future<void> addCommit(String id, Map<String, dynamic> data);
 
@@ -20,7 +20,8 @@
   Future<void> updateBuildInfo(String builder, int buildNumber, int index);
 
   Future<void> storeChange(
-      Map<String, dynamic> change, int startIndex, int endIndex);
+      Map<String, dynamic> change, int startIndex, int endIndex,
+      {bool approved});
 
   Future<void> storeTryChange(
       Map<String, dynamic> change, int review, int patchset);
@@ -28,4 +29,12 @@
   Future<void> storeReview(String review, Map<String, dynamic> data);
   Future<void> storePatchset(
       String review, String patchset, Map<String, dynamic> data);
+
+  Future<bool> reviewIsLanded(int review);
+
+  Future<void> linkReviewToCommit(int review, int index);
+
+  Future<void> linkCommentsToCommit(int review, int index);
+
+  Future<List<Map<String, dynamic>>> tryApprovals(int review);
 }
diff --git a/functions/node/firestore_impl.dart b/functions/node/firestore_impl.dart
index 53806e5..4d1ae51 100644
--- a/functions/node/firestore_impl.dart
+++ b/functions/node/firestore_impl.dart
@@ -36,7 +36,7 @@
       .get()
       .then((s) => s.exists ? s.data.toMap() : null);
 
-  Future<Map<String, dynamic>> getLastCommit(String hash) async {
+  Future<Map<String, dynamic>> getLastCommit() async {
     QuerySnapshot lastCommit = await firestore
         .collection('commits')
         .orderBy('index', descending: true)
@@ -86,7 +86,8 @@
   }
 
   Future<void> storeChange(
-      Map<String, dynamic> change, int startIndex, int endIndex) async {
+      Map<String, dynamic> change, int startIndex, int endIndex,
+      {bool approved = false}) async {
     String name = change['name'];
     String result = change['result'];
     String previousResult = change['previous_result'] ?? 'new test';
@@ -120,7 +121,8 @@
             'expected': change['expected'],
             'blamelist_start_index': startIndex,
             'blamelist_end_index': endIndex,
-            'configurations': <String>[change['configuration']]
+            'configurations': <String>[change['configuration']],
+            if (approved) 'approved': true
           }));
     }
 
@@ -188,4 +190,40 @@
       firestore
           .document('reviews/$review/patchsets/$patchset')
           .setData(DocumentData.fromMap(data));
+
+  /// 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) =>
+      firestore.document('reviews/$review').get().then((document) =>
+          !document.exists || document.data.getInt('landed_index') != null);
+
+  Future<void> linkReviewToCommit(int review, int index) => firestore
+      .document('reviews/$review')
+      .updateData(UpdateData.fromMap({'landed_index': index}));
+
+  Future<void> linkCommentsToCommit(int review, int index) async {
+    QuerySnapshot comments = await firestore
+        .collection('comments')
+        .where('review', isEqualTo: review)
+        .get();
+    if (comments.isEmpty) return;
+    final batch = firestore.batch();
+    for (final comment in comments.documents) {
+      batch.updateData(
+          comment.reference,
+          UpdateData.fromMap(
+              {'blamelist_start_index': index, 'blamelist_end_index': index}));
+    }
+    await batch.commit();
+  }
+
+  Future<List<Map<String, dynamic>>> tryApprovals(int review) async {
+    QuerySnapshot approvals = await firestore
+        .collection('try_results')
+        .where('approved', isEqualTo: true)
+        .where('review', isEqualTo: review)
+        .get();
+    return [for (final document in approvals.documents) document.data.toMap()];
+  }
 }
diff --git a/functions/node/test/test.dart b/functions/node/test/test.dart
index 85a83fe..dcbe90a 100644
--- a/functions/node/test/test.dart
+++ b/functions/node/test/test.dart
@@ -2,8 +2,6 @@
 // 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:mockito/mockito.dart';
 import 'package:test/test.dart';
 
@@ -15,7 +13,6 @@
   test("Store already existing commit", () async {
     final firestore = FirestoreServiceMock();
     final change = alreadyExistingCommitChange;
-    final hash = change['commit_hash'];
 
     final builder = Build(existingCommitHash, change, firestore);
     await builder.storeBuildCommitsInfo();
diff --git a/results_feed/doc/results_feed.proto b/results_feed/doc/results_feed.proto
index fd30aac..4579018 100644
--- a/results_feed/doc/results_feed.proto
+++ b/results_feed/doc/results_feed.proto
@@ -102,6 +102,7 @@
   string author = 3; // The author of the commit, as an email address.
   google.protobuf.Timestamp created = 4;
   string title = 5; // The first line of the commit message.
+  int32 review = 6; // If the commit is a landed Gerrit review, its number.
 }
 
 // A map from configuration names to builder names.
@@ -130,7 +131,8 @@
     int32 patchset_group = 5;
   }
 
-  repeated Patchset patchsets
+  repeated Patchset patchsets = 3;
+  int32 landed_index = 4; // When a review lands, this is the index of the landed commit.
 }
 
 message Comment {