[builder] Fetch unapproved failures from all builder configurations

Change-Id: Ia36f93572362420fccedb33190cb7e679e8d051b
Bug: b/210111991
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/234640
Reviewed-by: Jonas Termansen <sortie@google.com>
Commit-Queue: William Hesse <whesse@google.com>
diff --git a/builder/bin/update_results_database.dart b/builder/bin/update_results_database.dart
index 4c78b3f..62565c5 100644
--- a/builder/bin/update_results_database.dart
+++ b/builder/bin/update_results_database.dart
@@ -21,7 +21,8 @@
     print('Empty input results.json file');
     exit(1);
   }
-  buildInfo = BuildInfo.fromResult(lines.first);
+  buildInfo = BuildInfo.fromResult(
+      lines.first, {for (final line in lines) line[fConfiguration]});
   return lines.where(isChangedResult).toList();
 }
 
diff --git a/builder/lib/src/builder.dart b/builder/lib/src/builder.dart
index 24812f8..0d110f6 100644
--- a/builder/lib/src/builder.dart
+++ b/builder/lib/src/builder.dart
@@ -52,9 +52,7 @@
       // TODO(karlklose): add a flag to overwrite builder results.
       exit(1);
     }
-    final configurations =
-        changes.map((change) => change['configuration'] as String).toSet();
-    await update(configurations);
+    await update(info.configurations);
     log('storing ${changes.length} change(s)');
     await Pool(30).forEach(changes, guardedStoreChange).drain();
     log('complete builder record');
@@ -62,7 +60,7 @@
     final status = BuildStatus()..success = success;
     try {
       status.unapprovedFailures = {
-        for (final configuration in configurations)
+        for (final configuration in info.configurations)
           configuration: await unapprovedFailuresForConfiguration(configuration)
       }..removeWhere((key, value) => value.isEmpty);
     } catch (e) {
diff --git a/builder/lib/src/result.dart b/builder/lib/src/result.dart
index 6eef883..8b65946 100644
--- a/builder/lib/src/result.dart
+++ b/builder/lib/src/result.dart
@@ -84,20 +84,23 @@
   final int buildNumber;
   final String commitRef;
   final String? previousCommitHash;
+  final Set<String> configurations;
 
-  BuildInfo(Map<String, dynamic> result)
+  BuildInfo(Map<String, dynamic> result, this.configurations)
       : builderName = result[fBuilderName],
         buildNumber = int.parse(result[fBuildNumber]),
         commitRef = result[fCommitHash],
         previousCommitHash = result[fPreviousCommitHash];
 
-  factory BuildInfo.fromResult(Map<String, dynamic> result) {
+  factory BuildInfo.fromResult(
+      Map<String, dynamic> result, Set<String> configurations) {
     final commitRef = result[fCommitHash];
     final match = commitRefRegExp.matchAsPrefix(commitRef);
     if (match == null) {
-      return BuildInfo(result);
+      return BuildInfo(result, configurations);
     } else {
-      return TryBuildInfo(result, int.parse(match[1]!), int.parse(match[2]!));
+      return TryBuildInfo(
+          result, configurations, int.parse(match[1]!), int.parse(match[2]!));
     }
   }
 }
@@ -106,7 +109,9 @@
   final int review;
   final int patchset;
 
-  TryBuildInfo(result, this.review, this.patchset) : super(result);
+  TryBuildInfo(Map<String, dynamic> result, Set<String> configurations,
+      this.review, this.patchset)
+      : super(result, configurations);
 }
 
 class TestNameLock {
diff --git a/builder/test/approvals_test.dart b/builder/test/approvals_test.dart
index 9ac97e8..0989867 100644
--- a/builder/test/approvals_test.dart
+++ b/builder/test/approvals_test.dart
@@ -143,8 +143,14 @@
 
 Tryjob makeTryjob(String name, Map<String, dynamic> firstChange,
         {String? baseCommit}) =>
-    Tryjob(BuildInfo.fromResult(firstChange) as TryBuildInfo, 'bbID_$name',
-        baseCommit ?? commit4, commitsCache, firestore, client);
+    Tryjob(
+        BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
+            as TryBuildInfo,
+        'bbID_$name',
+        baseCommit ?? commit4,
+        commitsCache,
+        firestore,
+        client);
 
 const newFailure = 'Pass/RuntimeError/Pass';
 Map<String, dynamic> makeTryChange(
@@ -192,7 +198,8 @@
 }
 
 Build makeBuild(String commit, Map<String, dynamic> change) {
-  return Build(BuildInfo.fromResult(change), commitsCache, firestore);
+  return Build(BuildInfo.fromResult(change, <String>{change[fConfiguration]}),
+      commitsCache, firestore);
 }
 
 void main() async {
diff --git a/builder/test/builder_test.dart b/builder/test/builder_test.dart
index 428458e..800fc7f 100644
--- a/builder/test/builder_test.dart
+++ b/builder/test/builder_test.dart
@@ -72,8 +72,10 @@
       await commitsCache.getCommitByIndex(previousBuildPreviousIndex);
 }
 
-Build makeBuild(Map<String, dynamic> firstChange) =>
-    Build(BuildInfo.fromResult(firstChange), commitsCache, firestore);
+Build makeBuild(Map<String, dynamic> firstChange) => Build(
+    BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]}),
+    commitsCache,
+    firestore);
 
 Map<String, dynamic> makeChange(String name, String result,
     {bool flaky = false}) {
@@ -120,7 +122,7 @@
       baseClient: baseClient);
   final api = FirestoreApi(client);
   firestore = FirestoreService(api, client);
-  if (!await firestore.isStaging()) {
+  if (!await firestore.isStaging) {
     throw (TestFailure('Error: test is being run on production'));
   }
   commitsCache = CommitsCache(firestore, client);
@@ -138,6 +140,9 @@
     registerChangeForDeletion(failingPreviousChange); // Name changed.
     final previousBuild = makeBuild(failingPreviousChange);
     final previousStatus = await previousBuild.process([failingPreviousChange]);
+    expect(previousStatus.success, isFalse);
+    expect(previousStatus.unapprovedFailures.values.first, hasLength(1));
+
     final failingChange = makeChange('failure', 'Pass/RuntimeError/Pass');
     final build = makeBuild(failingChange);
     final status = await build.process([failingChange]);
@@ -158,7 +163,6 @@
         previousBlamelistStartCommit.hash);
     expect(failure.getStringOrNull(fBlamelistEndCommit), commit.hash);
     expect(failure.getStringOrNull(fBlamelistStartCommit), commit.hash);
-
     final message = status.toJson();
     expect(message, matches(r"There are unapproved failures\\n"));
     expect(
@@ -169,8 +173,18 @@
         message,
         matches(
             r"failure_test   \(Pass -> RuntimeError , expected Pass \) at 2368c2"));
-    expect(previousStatus.success, isFalse);
-    expect(previousStatus.unapprovedFailures.values.first, hasLength(1));
+
+    // Check a build with no changed results.
+    // Another build with the same blamelist and configuration is allowed,
+    // although it should not happen in practice. This build has no changes.
+    final unchangedChange =
+        makeChange('failure', 'RuntimeError/RuntimeError/Pass');
+    final unchangedBuild = makeBuild(unchangedChange);
+    final unchangedStatus = await unchangedBuild.process([]);
+    expect(unchangedStatus.success, isTrue);
+    expect(unchangedStatus.unapprovedFailures, isNotEmpty);
+    expect(unchangedStatus.unapprovedFailures['failure_configuration']!,
+        isNotEmpty);
   });
 
   test('existing approved failure', () async {
@@ -182,6 +196,10 @@
     final otherConfigurationBuild = makeBuild(failingOtherConfigurationChange);
     final otherStatus = await otherConfigurationBuild
         .process([failingOtherConfigurationChange]);
+    expect(otherStatus.success, isFalse);
+    expect(otherStatus.unapprovedFailures, isNotEmpty);
+    expect(
+        otherStatus.unapprovedFailures.keys, contains('other_configuration'));
     final result = (await firestore.findActiveResults(
             'approved_failure_test', 'other_configuration'))
         .single;
@@ -195,10 +213,6 @@
     expect(status.success, isTrue);
     expect(status.truncatedResults, isFalse);
     expect(status.unapprovedFailures, isEmpty);
-    expect(otherStatus.success, isFalse);
-    expect(otherStatus.unapprovedFailures, isNotEmpty);
-    expect(
-        otherStatus.unapprovedFailures.keys, contains('other_configuration'));
     final changedResult = (await firestore.findActiveResults(
             'approved_failure_test', 'other_configuration'))
         .single;
diff --git a/builder/test/fakes.dart b/builder/test/fakes.dart
index b0910fd..a1a37e1 100644
--- a/builder/test/fakes.dart
+++ b/builder/test/fakes.dart
@@ -22,7 +22,11 @@
 
   BuilderTest(this.firstChange) {
     commitsCache = CommitsCache(firestore, client);
-    builder = Build(BuildInfo.fromResult(firstChange), commitsCache, firestore);
+    builder = Build(
+        BuildInfo.fromResult(
+            firstChange, <String>{firstChange[fConfiguration]}),
+        commitsCache,
+        firestore);
   }
 
   Future<void> update() async {
diff --git a/builder/test/tryjob_test.dart b/builder/test/tryjob_test.dart
index b54a932..4061a71 100644
--- a/builder/test/tryjob_test.dart
+++ b/builder/test/tryjob_test.dart
@@ -113,7 +113,8 @@
 }
 
 Tryjob makeTryjob(String name, Map<String, dynamic> firstChange) => Tryjob(
-    BuildInfo.fromResult(firstChange) as TryBuildInfo,
+    BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
+        as TryBuildInfo,
     'bbID_$name',
     data['landedCommit']!,
     commitsCache,
@@ -121,8 +122,14 @@
     client);
 
 Tryjob makeLandedTryjob(String name, Map<String, dynamic> firstChange) =>
-    Tryjob(BuildInfo.fromResult(firstChange) as TryBuildInfo, 'bbID_$name',
-        data['baseCommit']!, commitsCache, firestore, client);
+    Tryjob(
+        BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
+            as TryBuildInfo,
+        'bbID_$name',
+        data['baseCommit']!,
+        commitsCache,
+        firestore,
+        client);
 
 Map<String, dynamic> makeChange(String name, String result,
     {bool flaky = false}) {
@@ -246,6 +253,16 @@
     expect(tryjob.counter.unapprovedFailures, 0);
   });
 
+  test('empty', () async {
+    final emptyChange = makeChange('empty', 'Pass/Pass/Pass');
+    final tryjob = makeTryjob('empty', emptyChange);
+    final status = await tryjob.process([]);
+    await checkTryBuild('empty', success: true);
+    expect(status.success, isTrue);
+    expect(tryjob.success, isTrue);
+    expect(tryjob.counter.unapprovedFailures, 0);
+  });
+
   test('truncatedPass', () async {
     final passingChange = makeChange('truncatedPass', 'RuntimeError/Pass/Pass');
     final tryjob = makeTryjob('truncatedPass', passingChange);