[infra] Add support for pre-approvals in approved_results.json.

This change adds a 'preapprovals' field to approved_results.json, which is
a map from gerrit Change-Id values to a pre-approval record, containing:

* 'from': What the previous approval was.
* 'result': What the new approval is.
* 'matches': Whether the new approval matches the expectation.
* 'expected': The expectation for the new approval.
* 'preapprover': Who did the preapproval.
* 'preapproved_at': When the preapproval occurred.

tools/approve_results.dart now produces pre-approval records instead of
updating the actual approvals. This ensures pre-approvals don't take
effect immediately and fixes the problem of pre-approving one kind of
failure becoming another kind of failure. The script needed to query the
gerrit API in order to determine the Change-Id of changelists. This query
also lets approve_results know what the latest changelist and lifts the
restriction that the patchset must be supplied when pre-approving.

The innards of approve_results has been refactored so it better can handle
selective modification of approve_results.dart.

approve_results now also checks for race conditions with any other
concurrent approval in interactive mode. This should catch most cases where
people leave approve_results running for long. There still is a small race
condition, so there is no check done in the non-interactive mode as the
results would just have been downloaded anyway.

The new tools/bots/apply_preapprovals.dart script will be used by the
recipe. It locates the outstanding pre-approved changelists and checks
the git history for whether they have landed by searching for their
Change-Id. It then applies their pre-approvals in order, warning about any
merge conflicts. Optionally it uploads the pre-approvals. This will work
for the commit queue where the pre-approvals can be applied temporarily,
and in the continuous integration where the pre-approvals become permanent
approvals.

This change allows empty approval records that only contain preapprovals but
with no actual base approval. tools/bots/compare_results.dart has been
updared to handle that case.

This changelist addresses https://github.com/dart-lang/sdk/issues/36279 by
ensuring only verified fields make it into new approved_results.json records
and adds a temporary workaround for 14 days to remove existing needless
existing fields.

Change-Id: I7b3ff59756dc0d28721c6db480782b4ea983b55d
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/97308
Reviewed-by: William Hesse <whesse@google.com>
diff --git a/tools/approve_results.dart b/tools/approve_results.dart
index 9d11f48..c577d5d 100755
--- a/tools/approve_results.dart
+++ b/tools/approve_results.dart
@@ -18,6 +18,37 @@
 
 import 'bots/results.dart';
 
+/// Returns whether two decoded JSON objects are identical.
+bool isIdenticalJson(dynamic a, dynamic b) {
+  if (a is Map<String, dynamic> && b is Map<String, dynamic>) {
+    if (a.length != b.length) return false;
+    for (final key in a.keys) {
+      if (!b.containsKey(key)) return false;
+      if (!isIdenticalJson(a[key], b[key])) return false;
+    }
+    return true;
+  } else if (a is List<dynamic> && b is List<dynamic>) {
+    if (a.length != b.length) return false;
+    for (int i = 0; i < a.length; i++) {
+      if (!isIdenticalJson(a[i], b[i])) return false;
+    }
+    return true;
+  } else {
+    return a == b;
+  }
+}
+
+/// Returns whether two sets of approvals are identical.
+bool isIdenticalApprovals(
+    Map<String, Map<String, dynamic>> a, Map<String, Map<String, dynamic>> b) {
+  if (a.length != b.length) return false;
+  for (final key in a.keys) {
+    if (!b.containsKey(key)) return false;
+    if (!isIdenticalJson(a[key], b[key])) return false;
+  }
+  return true;
+}
+
 /// The bot names and named configurations are highly redundant if both are
 /// listed. This function returns a simplified named configuration that doesn't
 /// contain any aspects that's part of the bots name. This is used to get a more
@@ -30,16 +61,16 @@
       .join("-");
 }
 
-/// Represents a test on a bot with the current result, the current approved
-/// result, and flakiness data.
+/// Represents a test on a bot with the baseline results (if tryrun), the
+/// current result, the current approved result, and flakiness data.
 class Test implements Comparable {
   final String bot;
-  final String name;
+  final Map<String, dynamic> baselineData;
   final Map<String, dynamic> resultData;
   final Map<String, dynamic> approvedResultData;
   final Map<String, dynamic> flakinessData;
 
-  Test(this.bot, this.name, this.resultData, this.approvedResultData,
+  Test(this.bot, this.baselineData, this.resultData, this.approvedResultData,
       this.flakinessData);
 
   int compareTo(Object other) {
@@ -54,12 +85,17 @@
     return 0;
   }
 
-  String get configuration => resultData["configuration"];
-  String get result => resultData["result"];
-  String get expected => resultData["expected"];
+  Map<String, dynamic> get _sharedData =>
+      resultData ?? baselineData ?? approvedResultData;
+  String get name => _sharedData["name"];
+  String get configuration => _sharedData["configuration"];
+  String get key => "$configuration:$name";
+  String get expected => _sharedData["expected"];
+  String get result => (resultData ?? const {})["result"];
   bool get matches => resultData["matches"];
-  String get approvedResult =>
-      approvedResultData != null ? approvedResultData["result"] : null;
+  String get baselineResult => (baselineData ?? const {})["result"];
+  String get approvedResult => (approvedResultData ?? const {})["result"];
+  bool get isDifferent => result != null && result != baselineResult;
   bool get isApproved => result == approvedResult;
   List<String> get flakyModes =>
       flakinessData != null ? flakinessData["outcomes"].cast<String>() : null;
@@ -133,7 +169,7 @@
 
 /// Loads the results from the bot.
 Future<List<Test>> loadResultsFromBot(String bot, ArgResults options,
-    Map<String, dynamic> changelistBuild) async {
+    String changeId, Map<String, dynamic> changelistBuild) async {
   if (options["verbose"]) {
     print("Loading $bot...");
   }
@@ -146,7 +182,7 @@
     /// TODO(https://github.com/dart-lang/sdk/issues/36015): The step name
     /// changed incompatibly, allow both temporarily to reduce the user
     /// breakage. Remove this 2019-03-25.
-    final build = (changelistBuild != null
+    final build = (changeId != null
             ? await todoFallbackLoadLog(
                 changelistBuild["id"],
                 "download_previous_results/0/steps/gsutil_find_latest_build/0/logs/"
@@ -164,7 +200,7 @@
           "$approvedResultsStoragePath/$bot/approved_results.json",
           "${tmpdir.path}/approved_results.json"),
       new Future(() async {
-        if (changelistBuild != null) {
+        if (changeId != null) {
           tryResults.addAll(parseResultsMap(await loadLog(
               changelistBuild["id"], "test_results/0/logs/results.json/0")));
         }
@@ -196,68 +232,24 @@
     // Construct an object for every test containing its current result,
     // what the last approved result was, and whether it's flaky.
     final tests = <Test>[];
-    for (final key in results.keys) {
-      final result = results[key];
+    final testResults = changeId != null ? tryResults : results;
+    for (final key in testResults.keys) {
+      final baselineResult = changeId != null ? results[key] : null;
+      final testResult = testResults[key];
       final approvedResult = approvedResults[key];
       final flakiness = flaky[key];
-      // If preapproving results, allow new non-matching results that are
-      // different from the baseline. The approved results will be the current
-      // approved results, plus the difference between the tryrun's baseline and
-      // the tryrun's results.
-      if (tryResults.containsKey(key)) {
-        final tryResult = tryResults[key];
-        final wasFlake = flakiness != null &&
-            (flakiness["outcomes"] as List<dynamic>)
-                .contains(tryResult["result"]);
-        // Pick the try run result if the try result was not a flake and it's a
-        // non-matching result that's different than the approved result. If
-        // there is no approved result yet, use the latest result from the
-        // builder instead.
-        final baseResult = approvedResult ?? result;
-        if (!wasFlake &&
-            !tryResult["matches"] &&
-            tryResult["result"] != result["result"]) {
-          // The approved_results.json format currently does not natively
-          // support preapproval, so preapproving turning one failure into
-          // another will turn the builder in question red until the CL lands.
-          if (!baseResult["matches"] &&
-              tryResult["result"] != baseResult["result"]) {
-            print("Warning: Preapproving changed failure modes will turn the "
-                "CI red until the CL is submitted: $bot: $key: "
-                "${baseResult["result"]} -> ${tryResult["result"]}");
-          }
-          result.clear();
-          result.addAll(tryResult);
-        } else {
-          if (approvedResult != null) {
-            result.clear();
-            result.addAll(approvedResult);
-          }
-        }
-      } else if (tryResults.isNotEmpty && approvedResult != null) {
-        result.clear();
-        result.addAll(approvedResult);
-      }
-      final name = result["name"];
-      final test = new Test(bot, name, result, approvedResult, flakiness);
-      final dropApproval =
-          test.matches ? options["failures-only"] : options["successes-only"];
-      if (dropApproval && !test.isApproved) {
-        if (approvedResult == null) continue;
-        result.clear();
-        result.addAll(approvedResult);
-      }
+      final test =
+          new Test(bot, baselineResult, testResult, approvedResult, flakiness);
       tests.add(test);
     }
-    // If preapproving and the CL has introduced new tests, add the new tests
-    // as well to the approved data.
-    final newTestKeys = new Set<String>.from(tryResults.keys)
-        .difference(new Set<String>.from(results.keys));
-    for (final key in newTestKeys) {
-      final result = tryResults[key];
+    // Add in approvals whose test was no longer in the results.
+    for (final key in approvedResults.keys) {
+      if (testResults.containsKey(key)) continue;
+      final baselineResult = changeId != null ? results[key] : null;
+      final approvedResult = approvedResults[key];
       final flakiness = flaky[key];
-      final name = result["name"];
-      final test = new Test(bot, name, result, null, flakiness);
+      final test =
+          new Test(bot, baselineResult, null, approvedResult, flakiness);
       tests.add(test);
     }
     if (options["verbose"]) {
@@ -270,6 +262,33 @@
   }
 }
 
+Future<Map<String, dynamic>> loadJsonPrefixedAPI(String url) async {
+  final client = new HttpClient();
+  try {
+    final request = await client
+        .getUrl(Uri.parse(url))
+        .timeout(const Duration(seconds: 30));
+    final response = await request.close().timeout(const Duration(seconds: 30));
+    if (response.statusCode != HttpStatus.ok) {
+      throw new Exception("Failed to request $url: ${response.statusCode}");
+    }
+    final text = await response
+        .transform(utf8.decoder)
+        .join()
+        .timeout(const Duration(seconds: 30));
+    return jsonDecode(text.substring(5 /* ")]}'\n" */));
+  } finally {
+    client.close();
+  }
+}
+
+Future<Map<String, dynamic>> loadChangelistDetails(
+    String gerritHost, String changeId) async {
+  // ?O=516714 requests the revisions field.
+  final url = "https://$gerritHost/changes/$changeId/detail?O=516714";
+  return await loadJsonPrefixedAPI(url);
+}
+
 main(List<String> args) async {
   final parser = new ArgParser();
   parser.addFlag("automated-approver",
@@ -382,9 +401,11 @@
 
   // Determine which builders have run for the changelist.
   final changelistBuilds = <String, Map<String, dynamic>>{};
-  if (options["preapprove"] != null) {
+  final isPreapproval = options["preapprove"] != null;
+  String changeId;
+  if (isPreapproval) {
     if (options["verbose"]) {
-      print("Loading list of try runs...");
+      print("Loading changelist details...");
     }
     final gerritHost = "dart-review.googlesource.com";
     final gerritProject = "sdk";
@@ -396,16 +417,30 @@
       return;
     }
     final components = gerrit.substring(prefix.length).split("/");
-    if (components.length != 2 ||
-        int.tryParse(components[0]) == null ||
-        int.tryParse(components[1]) == null) {
+    if (!((components.length == 1 && int.tryParse(components[0]) != null) ||
+        (components.length == 2 &&
+            int.tryParse(components[0]) != null &&
+            int.tryParse(components[1]) != null))) {
       stderr.writeln("error: $gerrit must be in the form of "
-          "$prefix<changelist>/<patchset>");
+          "$prefix<changelist> or $prefix<changelist>/<patchset>");
       exitCode = 1;
       return;
     }
     final changelist = int.parse(components[0]);
-    final patchset = int.parse(components[1]);
+    final details =
+        await loadChangelistDetails(gerritHost, changelist.toString());
+    changeId = details["change_id"];
+    final patchset = 2 <= components.length
+        ? int.parse(components[1])
+        : details["revisions"][details["current_revision"]]["_number"];
+    if (2 <= components.length) {
+      print("Using Change-Id $changeId patchset $patchset");
+    } else {
+      print("Using Change-Id $changeId with the latest patchset $patchset");
+    }
+    if (options["verbose"]) {
+      print("Loading list of try runs...");
+    }
     final buildset = "buildset:patch/gerrit/$gerritHost/$changelist/$patchset";
     final url = Uri.parse(
         "https://cr-buildbucket.appspot.com/_ah/api/buildbucket/v1/search"
@@ -519,7 +554,8 @@
   for (final String bot in bots) {
     testListFutures.add(new Future(() async {
       try {
-        return await loadResultsFromBot(bot, options, changelistBuilds[bot]);
+        return await loadResultsFromBot(
+            bot, options, changeId, changelistBuilds[bot]);
       } on NoResultsException catch (e) {
         print(
             "Error: Failed to find results for $bot build <${e.buildUrl}>: $e");
@@ -538,19 +574,28 @@
   print("");
 
   // Compute statistics and the set of interesting tests.
-  final flakyTestsCount = tests.where((test) => test.isFlake).length;
-  final failingTestsCount =
-      tests.where((test) => !test.isFlake && !test.matches).length;
-  final unapprovedTests =
-      tests.where((test) => !test.isFlake && !test.isApproved).toList();
-  final fixedTests = unapprovedTests.where((test) => test.matches).toList();
-  final brokenTests = unapprovedTests.where((test) => !test.matches).toList();
+  final flakyTestsCount =
+      tests.where((test) => test.resultData != null && test.isFlake).length;
+  final failingTestsCount = tests
+      .where(
+          (test) => test.resultData != null && !test.isFlake && !test.matches)
+      .length;
+  final differentTests = tests
+      .where((test) =>
+          (isPreapproval ? test.isDifferent : !test.isApproved) &&
+          !test.isFlake)
+      .toList();
+  final selectedTests = differentTests
+      .where((test) => !(test.matches
+          ? options["failures-only"]
+          : options["successes-only"]))
+      .toList();
+  final fixedTests = selectedTests.where((test) => test.matches).toList();
+  final brokenTests = selectedTests.where((test) => !test.matches).toList();
 
   // Find out which bots have multiple configurations.
-  final outcomes = new Set<String>();
   final configurationsForBots = <String, Set<String>>{};
   for (final test in tests) {
-    outcomes.add(test.result);
     var configurationSet = configurationsForBots[test.bot];
     if (configurationSet == null) {
       configurationsForBots[test.bot] = configurationSet = new Set<String>();
@@ -577,7 +622,7 @@
   int longestTest = "TEST".length;
   int longestResult = "RESULT".length;
   int longestExpected = "EXPECTED".length;
-  for (final test in unapprovedTests) {
+  for (final test in selectedTests) {
     unapprovedBots.add(test.bot);
     final botDisplayName = getBotDisplayName(test.bot, test.configuration);
     longestBot = max(longestBot, botDisplayName.length);
@@ -680,10 +725,10 @@
 
   // Stop if this is a dry run.
   if (options["no"]) {
-    if (unapprovedTests.length == 1) {
+    if (selectedTests.length == 1) {
       print("1 test has a changed result and needs approval");
     } else {
-      print("${unapprovedTests.length} "
+      print("${selectedTests.length} "
           "tests have changed results and need approval");
     }
     return;
@@ -695,16 +740,15 @@
     print("Note: It is assumed bugs have been filed about the above failures "
         "before they are approved here.");
     if (brokenTests.isNotEmpty) {
-      final botPlural = bots.length == 1 ? "bot" : "bots";
+      final builderPlural = bots.length == 1 ? "builder" : "builders";
+      final tryBuilders = isPreapproval ? "try$builderPlural" : builderPlural;
+      final tryCommit = isPreapproval ? "tryrun" : "commit";
       print("Note: Approving the failures will turn the "
-          "$botPlural green on the next commit.");
-    }
-    if (options["preapprove"] != null) {
-      print("Warning: Preapproval is currently not sticky and somebody else "
-          "approving before your CL has landed will undo your preapproval.");
+          "$tryBuilders green on the next $tryCommit.");
     }
     while (true) {
-      stdout.write("Do you want to approve? (yes/no) [yes] ");
+      final approve = isPreapproval ? "pre-approve" : "approve";
+      stdout.write("Do you want to $approve? (yes/no) [yes] ");
       final line = stdin.readLineSync();
       // End of file condition is considered no.
       if (line == null) {
@@ -739,45 +783,194 @@
   }
   final now = new DateTime.now().toUtc().toIso8601String();
 
-  // Update approved_results.json for each bot with unapproved changes.
+  // Deep clones a decoded json object.
+  dynamic deepClone(dynamic object) {
+    if (object is Map<String, dynamic>) {
+      final result = <String, dynamic>{};
+      for (final key in object.keys) {
+        result[key] = deepClone(object[key]);
+      }
+      return result;
+    } else if (object is List<dynamic>) {
+      final result = <dynamic>[];
+      for (final value in object) {
+        result.add(deepClone(value));
+      }
+      return result;
+    } else {
+      return object;
+    }
+  }
+
+  // Reconstruct the old approvals so we can double check there was no race
+  // condition when uploading.
+  final oldApprovalsForBuilders = <String, Map<String, Map<String, dynamic>>>{};
+  for (final test in tests) {
+    if (test.approvedResultData == null) continue;
+    final newApprovals = oldApprovalsForBuilders.putIfAbsent(
+        test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
+    newApprovals[test.key] = test.approvedResultData;
+  }
+
+  // Build the new approval data with the changes in test results applied.
+  final newApprovalsForBuilders = <String, Map<String, Map<String, dynamic>>>{};
+
+  if (isPreapproval) {
+    // Import all the existing approval data, keeping tests that don't exist
+    // anymore.
+    for (final test in tests) {
+      if (test.approvedResultData == null) continue;
+      final approvalData = deepClone(test.approvedResultData);
+      // TODO(https://github.com/dart-lang/sdk/issues/36279): Remove needless
+      // fields that shouldn't be in the approvals data. Remove this 2019-04-03.
+      approvalData.remove("bot_name");
+      approvalData.remove("builder_name");
+      approvalData.remove("build_number");
+      approvalData.remove("changed");
+      approvalData.remove("commit_hash");
+      approvalData.remove("commit_time");
+      approvalData.remove("commit_hash");
+      approvalData.remove("flaky");
+      approvalData.remove("previous_build_number");
+      approvalData.remove("previous_commit_hash");
+      approvalData.remove("previous_commit_time");
+      approvalData.remove("previous_flaky");
+      approvalData.remove("previous_result");
+      approvalData.remove("time_ms");
+      // Discard all the existing pre-approvals for this changelist.
+      final preapprovals =
+          approvalData.putIfAbsent("preapprovals", () => <String, dynamic>{});
+      preapprovals.remove(changeId);
+      final newApprovals = newApprovalsForBuilders.putIfAbsent(
+          test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
+      newApprovals[test.key] = approvalData;
+    }
+
+    // Pre-approve all the regressions (no need to pre-approve fixed tests).
+    for (final test in brokenTests) {
+      final newApprovals = newApprovalsForBuilders.putIfAbsent(
+          test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
+      final approvalData =
+          newApprovals.putIfAbsent(test.key, () => <String, dynamic>{});
+      final preapprovals =
+          approvalData.putIfAbsent("preapprovals", () => <String, dynamic>{});
+      final preapproval =
+          preapprovals.putIfAbsent(changeId, () => <String, dynamic>{});
+      preapproval["from"] = test.approvedResult;
+      preapproval["result"] = test.result;
+      preapproval["matches"] = test.matches;
+      preapproval["expected"] = test.expected;
+      preapproval["preapprover"] = username;
+      preapproval["preapproved_at"] = now;
+    }
+  } else {
+    // Import all the existing approval data for tests, removing tests that
+    // don't exist anymore unless they have pre-approvals.
+    for (final test in tests) {
+      if (test.approvedResultData == null) continue;
+      if (test.result == null &&
+          test.approvedResultData["preapprovals"].isEmpty) continue;
+      final approvalData = deepClone(test.approvedResultData);
+      // TODO(https://github.com/dart-lang/sdk/issues/36279): Remove needless
+      // fields that shouldn't be in the approvals data. Remove this 2019-04-03.
+      approvalData.remove("bot_name");
+      approvalData.remove("builder_name");
+      approvalData.remove("build_number");
+      approvalData.remove("changed");
+      approvalData.remove("commit_hash");
+      approvalData.remove("commit_time");
+      approvalData.remove("commit_hash");
+      approvalData.remove("flaky");
+      approvalData.remove("previous_build_number");
+      approvalData.remove("previous_commit_hash");
+      approvalData.remove("previous_commit_time");
+      approvalData.remove("previous_flaky");
+      approvalData.remove("previous_result");
+      approvalData.remove("time_ms");
+      approvalData.putIfAbsent("preapprovals", () => <String, dynamic>{});
+      final newApprovals = newApprovalsForBuilders.putIfAbsent(
+          test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
+      newApprovals[test.key] = approvalData;
+    }
+
+    // Approve the changes in test results.
+    for (final test in selectedTests) {
+      final newApprovals = newApprovalsForBuilders.putIfAbsent(
+          test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
+      final approvalData =
+          newApprovals.putIfAbsent(test.key, () => <String, dynamic>{});
+      approvalData["name"] = test.name;
+      approvalData["configuration"] = test.configuration;
+      approvalData["suite"] = test.resultData["suite"];
+      approvalData["test_name"] = test.resultData["test_name"];
+      approvalData["result"] = test.result;
+      approvalData["expected"] = test.expected;
+      approvalData["matches"] = test.matches;
+      approvalData["approver"] = username;
+      approvalData["approved_at"] = now;
+      approvalData.putIfAbsent("preapprovals", () => <String, dynamic>{});
+    }
+  }
+
+  for (final builder in newApprovalsForBuilders.keys) {
+    final newApprovals = newApprovalsForBuilders[builder];
+    for (final key in newApprovals.keys) {
+      final approvalData = newApprovals[key];
+      if (!approvalData.containsKey("preapprovals")) {
+        throw new Exception(approvalData.toString());
+      }
+    }
+  }
+
+  // Update approved_results.json for each builder with unapproved changes.
   final outDirectory =
       await Directory.systemTemp.createTemp("approved_results.");
+  bool raceCondition = false;
   try {
-    final testsForBots = <String, List<Test>>{};
-    for (final test in tests) {
-      if (!testsForBots.containsKey(test.bot)) {
-        testsForBots[test.bot] = <Test>[test];
-      } else {
-        testsForBots[test.bot].add(test);
-      }
-    }
     print("Uploading approved results...");
     final futures = <Future>[];
-    for (final String bot in unapprovedBots) {
-      Map<String, dynamic> approveData(Test test) {
-        if (test.isApproved) {
-          return test.approvedResultData;
-        } else {
-          final data = new Map<String, dynamic>.from(test.resultData);
-          data["approver"] = username;
-          data["approved_at"] = now;
-          return data;
-        }
-      }
-
-      final dataList = testsForBots[bot].map(approveData).toList();
-      final localPath = "${outDirectory.path}/$bot.json";
+    for (final String builder in newApprovalsForBuilders.keys) {
+      final approvals = newApprovalsForBuilders[builder].values;
+      final localPath = "${outDirectory.path}/$builder.json";
       await new File(localPath).writeAsString(
-          dataList.map((data) => jsonEncode(data) + "\n").join(""));
+          approvals.map((approval) => jsonEncode(approval) + "\n").join(""));
       final remotePath =
-          "$approvedResultsStoragePath/$bot/approved_results.json";
-      futures.add(cpGsutil(localPath, remotePath)
-          .then((_) => print("Uploaded approved results for $bot")));
+          "$approvedResultsStoragePath/$builder/approved_results.json";
+      futures.add(new Future(() async {
+        if (!options["yes"]) {
+          if (options["verbose"]) {
+            print("Checking for race condition on $builder...");
+          }
+          final oldApprovedResults = oldApprovalsForBuilders[builder];
+          final oldApprovalPath = "${outDirectory.path}/$builder.json.old";
+          await cpGsutil(remotePath, oldApprovalPath);
+          final checkApprovedResults =
+              await loadResultsMapIfExists(oldApprovalPath);
+          if (!isIdenticalApprovals(oldApprovedResults, checkApprovedResults)) {
+            print("error: Race condition: "
+                "$builder approvals have changed, please try again.");
+            raceCondition = true;
+            return;
+          }
+        }
+        if (options["verbose"]) {
+          print("Uploading approved results for $builder...");
+        }
+        await cpGsutil(localPath, remotePath);
+        print("Uploaded approved results for $builder");
+      }));
     }
     await Future.wait(futures);
+    if (raceCondition) {
+      exitCode = 1;
+      print("error: Somebody else has approved, please try again");
+      return;
+    }
     if (brokenTests.isNotEmpty) {
-      print(
-          "Successfully approved results, the next commit will turn bots green");
+      final approved = isPreapproval ? "pre-approved" : "approved";
+      final commit = isPreapproval ? "tryrun" : "commit";
+      print("Successfully $approved results, the next $commit "
+          "will turn builders green");
     } else {
       print("Successfully approved results");
     }
diff --git a/tools/bots/apply_preapprovals.dart b/tools/bots/apply_preapprovals.dart
new file mode 100755
index 0000000..ed15d61
--- /dev/null
+++ b/tools/bots/apply_preapprovals.dart
@@ -0,0 +1,238 @@
+#!/usr/bin/env dart
+// Copyright (c) 2018, 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.
+
+// Applies pending pre-approvals for any changelists that have landed according
+// to the git history of HEAD.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:args/args.dart';
+
+import 'results.dart';
+
+main(List<String> args) async {
+  final parser = new ArgParser();
+  parser.addFlag("dry",
+      abbr: "n",
+      help: "Don't write out the updated approvals.",
+      negatable: false);
+  parser.addFlag("help", help: "Show the program usage.", negatable: false);
+  parser.addOption("upload",
+      abbr: "u",
+      help: "Upload the updated results to this cloud storage location");
+
+  final options = parser.parse(args);
+  if (options["help"]) {
+    print("""
+Usage: apply_preapprovals.dart [OPTION]... APPROVALS
+Applies pending pre-approvals for any changelists that have landed according to
+the git history of HEAD.
+
+The options are as follows:
+
+${parser.usage}""");
+    return;
+  }
+
+  final parameters = options.rest;
+  if (parameters.length != 1) {
+    print("error: Expected one parameter");
+    exitCode = 2;
+    return;
+  }
+
+  // Locate gsutil.py.
+  gsutilPy = Platform.script
+      .resolve("../../third_party/gsutil/gsutil.py")
+      .toFilePath();
+
+  final approvalsPath = parameters[0];
+  final approvals = await loadResultsMap(approvalsPath);
+
+  // Find the changelists with pre-approvals.
+  final allChangelists = <String>{};
+  for (final key in approvals.keys) {
+    final record = approvals[key];
+    final preapprovals =
+        record.putIfAbsent("preapprovals", () => <String, dynamic>{});
+    allChangelists.addAll(preapprovals.keys);
+  }
+  if (allChangelists.isEmpty) {
+    print("No pre-approvals are pending");
+  }
+
+  // Find the order the pre-approved changelists landed in.
+  final joinedChangelistsPattern = allChangelists.join("\\|");
+  final pattern = "^Change-Id: \\($joinedChangelistsPattern\\)\$";
+  final arguments = [
+    "rev-list",
+    "--pretty=medium",
+    "--grep=$pattern",
+    "--reverse",
+    "HEAD"
+  ];
+  final processOutput = await Process.run("git", arguments, runInShell: true);
+  if (processOutput.exitCode != 0) {
+    throw new Exception("Failed to run git $arguments\n"
+        "exitCode: ${processOutput.exitCode}\n"
+        "stdout: ${processOutput.stdout}\n"
+        "stderr: ${processOutput.stderr}");
+  }
+  final landedChangelists = <String>[];
+  final commitOfChangelist = <String, String>{};
+  String currentCommit;
+  for (final line in LineSplitter.split(processOutput.stdout)) {
+    if (line.startsWith("commit ")) {
+      currentCommit = line.substring("commit ".length);
+    } else if (line.startsWith("    Change-Id: ")) {
+      final changeId = line.substring("    Change-Id: ".length);
+      if (allChangelists.contains(changeId)) {
+        landedChangelists.add(changeId);
+        commitOfChangelist[changeId] = currentCommit;
+      }
+    }
+  }
+  if (processOutput.stdout != "") {
+    print(processOutput.stdout);
+  }
+
+  // Report the status of each of the pre-approved changelists.
+  final unlandedChangelists =
+      allChangelists.difference(landedChangelists.toSet());
+  for (final changelist in unlandedChangelists) {
+    final changelistUrl = "https://dart-review.googlesource.com/q/$changelist";
+    print("Pending: Changelist $changelistUrl hasn't landed yet");
+  }
+  if (allChangelists.isNotEmpty && landedChangelists.isEmpty) {
+    print("No pre-approved changelists have landed.");
+  }
+  for (final changelist in landedChangelists) {
+    final changelistUrl = "https://dart-review.googlesource.com/q/$changelist";
+    final commit = commitOfChangelist[changelist];
+    print("Landed: Changelist $changelistUrl landed in commit $commit");
+  }
+
+  // Apply the pre-approvals for landed changes.
+  bool updated = false;
+  final conflictsForKey = <String, List<String>>{};
+  final changelistsWithMergeConflicts = <String>{};
+  int totalNumberOfPreapprovals = 0;
+  int totalNumberOfMergeConflicts = 0;
+  for (final changelist in landedChangelists) {
+    final changelistUrl = "https://dart-review.googlesource.com/q/$changelist";
+    final commit = commitOfChangelist[changelist];
+    print("\nApplying pre-approvals for changelist "
+        "$changelistUrl landed in commit $commit");
+    int numberOfPreapprovals = 0;
+    int numberOfMergeConflicts = 0;
+    for (final key in approvals.keys) {
+      final record = approvals[key];
+      final preapprovals = record["preapprovals"];
+      final preapproval = preapprovals.remove(changelist);
+      if (preapproval == null) continue;
+      updated = true;
+      final conflicts = conflictsForKey.putIfAbsent(key, () => <String>[]);
+      if (record["result"] == preapproval["from"]) {
+        print("$changelist: $key: "
+            "${record["result"]} -> ${preapproval["result"]}");
+        conflicts.add("$changelist/$commit had changed approval from "
+            "${record["result"]} to ${preapproval["result"]}");
+        record["result"] = preapproval["result"];
+        record["matches"] = preapproval["matches"];
+        record["expected"] = preapproval["expected"];
+        record["approver"] = preapproval["preapprover"];
+        record["approved_at"] = preapproval["preapproved_at"];
+        numberOfPreapprovals++;
+        totalNumberOfPreapprovals++;
+      } else {
+        print("$changelist: $key: MERGE CONFLICT:");
+        for (final conflict in conflicts) {
+          print(" * $conflict");
+        }
+        print(" * MERGE CONFLICT: Cannot change approval from "
+            "${preapproval["from"]} to ${preapproval["result"]} "
+            "because it's currently ${record["result"]}");
+        changelistsWithMergeConflicts.add(changelist);
+        numberOfMergeConflicts++;
+        totalNumberOfMergeConflicts++;
+      }
+    }
+    if (0 < numberOfPreapprovals) {
+      print("$numberOfPreapprovals "
+          "pre-approvals applied from $changelistUrl commit $commit");
+    }
+    if (0 < numberOfMergeConflicts) {
+      print("Warning: $numberOfMergeConflicts "
+          "merge conflicts in pre-approvals for $changelistUrl commit $commit");
+    }
+  }
+
+  // Expire old pre-approvals.
+  final now = new DateTime.now().toUtc();
+  final expiredChangelists = <String>{};
+  for (final record in approvals.values) {
+    final preapprovals = record["preapprovals"];
+    final changelists = preapprovals.keys.toList();
+    for (final changelist in changelists) {
+      final preapproval = preapprovals[changelist];
+      final expires = DateTime.parse(preapproval["expires"]);
+      if (expires.isBefore(now)) {
+        updated = true;
+        preapprovals.remove(changelist);
+        expiredChangelists.add(changelist);
+      }
+    }
+  }
+  if (expiredChangelists.isNotEmpty) {
+    print("");
+  }
+  for (final changelist in expiredChangelists) {
+    final changelistUrl = "https://dart-review.googlesource.com/q/$changelist";
+    print("Expired: Pre-approvals for changelist $changelistUrl have expired");
+  }
+
+  // Format a final report.
+  print("");
+  final landedChangelistsCount = landedChangelists.length;
+  if (0 < landedChangelistsCount) {
+    print("$landedChangelistsCount changelists have landed");
+  }
+  final expiredChangelistsCount = expiredChangelists.length;
+  if (0 < expiredChangelistsCount) {
+    print("$expiredChangelistsCount changelists have expired");
+  }
+  final unlandedChangelistsCount =
+      unlandedChangelists.length - expiredChangelistsCount;
+  if (0 < unlandedChangelistsCount) {
+    print("$unlandedChangelistsCount changelists are pending");
+  }
+  if (0 < totalNumberOfPreapprovals) {
+    print("$totalNumberOfPreapprovals pre-approvals applied");
+  }
+  if (0 < totalNumberOfPreapprovals) {
+    print("Warning: $totalNumberOfMergeConflicts "
+        "pre-approvals had merge conflicts");
+  }
+
+  // Save the updated approvals and upload them to cloud storage.
+  print("");
+  if (!updated) {
+    print("Approvals are unchanged");
+    return;
+  }
+  if (options["dry"]) {
+    print("Dry run, not saving the updated approvals");
+    return;
+  }
+  await new File(approvalsPath).writeAsString(
+      approvals.values.map((data) => jsonEncode(data) + "\n").join(""));
+  print("Wrote updated approvals to $approvalsPath");
+  if (options["upload"] != null) {
+    print("Uploading updated approvals to ${options["upload"]}...");
+    await cpGsutil(approvalsPath, options["upload"]);
+    print("Uploaded updated approvals to ${options["upload"]}");
+  }
+}
diff --git a/tools/bots/compare_results.dart b/tools/bots/compare_results.dart
index c3820a0..792a473 100755
--- a/tools/bots/compare_results.dart
+++ b/tools/bots/compare_results.dart
@@ -273,7 +273,7 @@
         ? new Result.fromMap(mapBefore, flakinessData[name])
         : null;
     final resultAfter = new Result.fromMap(mapAfter, flakinessData[name]);
-    final resultApproved = mapApproved != null
+    final resultApproved = mapApproved != null && mapApproved["result"] != null
         ? new Result.fromMap(mapApproved, flakinessData[name])
         : null;
     final event = new Event(resultBefore, resultAfter, resultApproved);