#!/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. /// List tests whose results are different from the previously approved results, /// and ask whether to update the currently approved results, turning the bots /// green. import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:args/args.dart'; import 'package:glob/glob.dart'; import 'bots/results.dart'; /// Returns whether two decoded JSON objects are identical. bool isIdenticalJson(dynamic a, dynamic b) { if (a is Map && b is Map) { 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 && b is List) { 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> a, Map> 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 /// compact and readable output. String simplifyNamedConfiguration(String bot, String namedConfiguration) { final botComponents = new Set.from(bot.split("-")); return namedConfiguration .split("-") .where((component) => !botComponents.contains(component)) .join("-"); } /// 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 Map baselineData; final Map resultData; final Map approvedResultData; final Map flakinessData; Test(this.bot, this.baselineData, this.resultData, this.approvedResultData, this.flakinessData); int compareTo(Object other) { if (other is Test) { if (bot.compareTo(other.bot) < 0) return -1; if (other.bot.compareTo(bot) < 0) return 1; if (configuration.compareTo(other.configuration) < 0) return -1; if (other.configuration.compareTo(configuration) < 0) return 1; if (name.compareTo(other.name) < 0) return -1; if (other.name.compareTo(name) < 0) return 1; } return 0; } Map 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 => _sharedData["matches"]; String get baselineResult => (baselineData ?? const {})["result"]; String get approvedResult => (approvedResultData ?? const {})["result"]; bool get isDifferent => result != null && result != baselineResult; bool get isApproved => result == null || result == approvedResult; List get flakyModes => flakinessData != null ? flakinessData["outcomes"].cast() : null; bool get isFlake => flakinessData != null && flakyModes.contains(result); } /// Loads the results file as as a map if the file exists, otherwise returns the /// empty map. Future>> loadResultsMapIfExists( String path) async => await new File(path).exists() ? loadResultsMap(path) : >{}; /// Exception for when the results for a builder can't be found. class NoResultsException implements Exception { final String message; final String buildUrl; NoResultsException(this.message, this.buildUrl); String toString() => message; } /// Loads a log from logdog. Future loadLog(String id, String step) async { final buildUrl = "https://ci.chromium.org/b/$id"; final logUrl = Uri.parse("https://logs.chromium.org/" "logs/dart/buildbucket/cr-buildbucket.appspot.com/" "$id/+/steps/$step?format=raw"); final client = new HttpClient(); try { final request = await client.getUrl(logUrl).timeout(const Duration(seconds: 60)); final response = await request.close().timeout(const Duration(seconds: 60)); if (response.statusCode == HttpStatus.notFound) { await response.drain(); throw new NoResultsException( "The log at $logUrl doesn't exist: ${response.statusCode}", buildUrl); } if (response.statusCode != HttpStatus.ok) { await response.drain(); throw new Exception("Failed to download $logUrl: ${response.statusCode}"); } final contents = (await response .cast>() .transform(new Utf8Decoder()) .timeout(const Duration(seconds: 60)) .toList()) .join(""); return contents; } finally { client.close(); } } /// 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. Future todoFallbackLoadLog( String id, String primary, String secondary) async { try { return await loadLog(id, primary); } catch (e) { if (e.toString().startsWith("Exception: The log at ") && e.toString().endsWith(" doesn't exist")) { return await loadLog(id, secondary); } rethrow; } } /// Loads the results from the bot. Future> loadResultsFromBot(String bot, ArgResults options, String changeId, Map changelistBuild) async { if (options["verbose"]) { print("Loading $bot..."); } // gsutil cp -r requires a destination directory, use a temporary one. final tmpdir = await Directory.systemTemp.createTemp("approve_results."); try { // The 'latest' file contains the name of the latest build that we // should download. When preapproving a changelist, we instead find out // which build the commit queue was rebased on. /// 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 = (changeId != null ? await todoFallbackLoadLog( changelistBuild["id"], "download_previous_results/0/steps/gsutil_find_latest_build/0/logs/" "raw_io.output_text_latest_/0", "gsutil_find_latest_build/0/logs/raw_io.output_text_latest_/0") : await readFile(bot, "latest")) .trim(); // Asynchronously download the latest build and the current approved // results. Download try results from trybot try runs if preapproving. final tryResults = >{}; await Future.wait([ cpRecursiveGsutil(buildCloudPath(bot, build), tmpdir.path), cpRecursiveGsutil( "$approvedResultsStoragePath/$bot/approved_results.json", "${tmpdir.path}/approved_results.json"), new Future(() async { if (changeId != null) { tryResults.addAll(parseResultsMap(await loadLog( changelistBuild["id"], "test_results/0/logs/results.json/0"))); } }), ]); // Check the build was properly downloaded. final buildPath = "${tmpdir.path}/$build"; final buildDirectory = new Directory(buildPath); if (!await buildDirectory.exists()) { print("$bot: Build directory didn't exist"); return []; } // Load the run.json to find the named configuration. final resultsFile = new File("$buildPath/results.json"); if (!await resultsFile.exists()) { print("$bot: No results.json exists"); return []; } // Load the current results, the approved resutls, and the flakiness // information. final results = await loadResultsMapIfExists("$buildPath/results.json"); final flaky = await loadResultsMapIfExists("$buildPath/flaky.json"); final approvedResults = await loadResultsMapIfExists("${tmpdir.path}/approved_results.json"); // TODO: Remove 2019-04-08: Discard any invalid pre-approvals made with a // version of approve_results between 065910f0 and a13ac1b4. Pre-approving // a new test could add pre-approvals with null configuration and null name. approvedResults.remove("null:null"); // Construct an object for every test containing its current result, // what the last approved result was, and whether it's flaky. final tests = []; 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]; final test = new Test(bot, baselineResult, testResult, approvedResult, flakiness); tests.add(test); } // 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 test = new Test(bot, baselineResult, null, approvedResult, flakiness); tests.add(test); } if (options["verbose"]) { print("Loaded $bot (${tests.length} tests)."); } return tests; } finally { // Always clean up the temporary directory when we don't need it. await tmpdir.delete(recursive: true); } } Future> 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 .cast>() .transform(utf8.decoder) .join() .timeout(const Duration(seconds: 30)); return jsonDecode(text.substring(5 /* ")]}'\n" */)); } finally { client.close(); } } Future> 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 args) async { final parser = new ArgParser(); parser.addFlag("automated-approver", help: "Record the approval as done by an automated process.", negatable: false); parser.addMultiOption("bot", abbr: "b", help: "Select the bots matching the glob pattern [option is repeatable]", splitCommas: false); parser.addFlag("help", help: "Show the program usage.", negatable: false); parser.addFlag("failures-only", help: "Approve failures only.", negatable: false); parser.addFlag("list", abbr: "l", help: "List the available bots.", negatable: false); parser.addFlag("no", abbr: "n", help: "Show changed results but don't approve.", negatable: false); parser.addOption("preapprove", abbr: "p", help: "Preapprove the new failures in a gerrit CL."); parser.addFlag("successes-only", help: "Approve successes only.", negatable: false); parser.addFlag("verbose", abbr: "v", help: "Describe asynchronous operations.", negatable: false); parser.addFlag("yes", abbr: "y", help: "Approve the results.", negatable: false); parser.addOption("table", abbr: "T", help: "Select table format.", allowed: ["markdown", "indent"], defaultsTo: "markdown"); final options = parser.parse(args); if ((options["preapprove"] == null && options["bot"].isEmpty && !options["list"]) || options["help"]) { print(""" Usage: approve_results.dart [OPTION]... List tests whose results are different from the previously approved results, and ask whether to update the currently approved results, turning the bots green. See the documentation at https://goto.google.com/dart-status-file-free-workflow The options are as follows: ${parser.usage}"""); return; } if (options["no"] && options["yes"]) { stderr.writeln("The --no and --yes options are mutually incompatible"); exitCode = 1; return; } if (options.rest.isNotEmpty) { stderr.writeln("Unexpected extra argument: ${options.rest.first}"); exitCode = 1; return; } // Locate gsutil.py. gsutilPy = Platform.script.resolve("../third_party/gsutil/gsutil.py").toFilePath(); // Load the list of bots according to the test matrix. final testMatrixPath = Platform.script.resolve("bots/test_matrix.json").toFilePath(); final testMatrix = jsonDecode(await new File(testMatrixPath).readAsString()); final builderConfigurations = testMatrix["builder_configurations"]; final testMatrixBots = []; for (final builderConfiguration in builderConfigurations) { final steps = builderConfiguration["steps"]; // Only consider bots that use tools/test.py or custom test runners. if (!steps.any((step) => step["script"] == null || step["script"] == "tools/test.py" || step["testRunner"] == true)) { continue; } final builders = builderConfiguration["builders"].cast(); testMatrixBots.addAll(builders); } // Load the list of bots that have data in cloud storage. if (options["verbose"]) { print("Loading list of bots..."); } final botsWithData = (await listBots()) .where((bot) => !bot.endsWith("-try")) .where((bot) => !bot.endsWith("-dev")) .where((bot) => !bot.endsWith("-stable")); if (options["verbose"]) { print("Loaded list of bots."); } // The currently active bots are the bots both mentioned in the test matrix // and that have results in cloud storage. final allBots = new Set.from(testMatrixBots) .intersection(new Set.from(botsWithData)) .toList() ..sort(); // List the currently active bots if requested. if (options["list"]) { for (final bot in allBots) { print(bot); } return; } // Determine which builders have run for the changelist. final changelistBuilds = >{}; final isPreapproval = options["preapprove"] != null; String changeId; if (isPreapproval) { if (options["verbose"]) { print("Loading changelist details..."); } final gerritHost = "dart-review.googlesource.com"; final gerritProject = "sdk"; final prefix = "https://$gerritHost/c/$gerritProject/+/"; final gerrit = options["preapprove"]; if (!gerrit.startsWith(prefix)) { stderr.writeln("error: $gerrit doesn't start with $prefix"); exitCode = 1; return; } final components = gerrit.substring(prefix.length).split("/"); 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 or $prefix/"); exitCode = 1; return; } final changelist = int.parse(components[0]); 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"; Future> searchBuilds(String cursor) async { final url = Uri.parse( "https://cr-buildbucket.appspot.com/_ah/api/buildbucket/v1/search" "?bucket=luci.dart.try" "&tag=${Uri.encodeComponent(buildset)}" "&fields=builds(id%2Ctags%2Cstatus%2Cstarted_ts),next_cursor" "&start_cursor=$cursor"); final client = new HttpClient(); final request = await client.getUrl(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 try runs for $gerrit"); } final Map object = await response .cast>() .transform(new Utf8Decoder()) .transform(new JsonDecoder()) .first .timeout(const Duration(seconds: 30)); client.close(); return object; } var cursor = ""; final builds = []; do { final object = await searchBuilds(cursor); if (cursor.isEmpty && object["builds"] == null) { stderr.writeln( "error: $prefix$changelist has no try runs for patchset $patchset"); exitCode = 1; return; } builds.addAll(object["builds"]); cursor = object["next_cursor"]; } while (cursor != null); // Prefer the newest completed build. Map preferredBuild( Map a, Map b) { if (a != null && b == null) return a; if (a == null && b != null) return b; if (a != null && b != null) { if (a["status"] == "COMPLETED" && b["status"] != "COMPLETED") return a; if (a["status"] != "COMPLETED" && b["status"] == "COMPLETED") return b; if (a["started_ts"] == null && b["started_ts"] != null) return a; if (a["started_ts"] != null && b["started_ts"] == null) return b; if (a["started_ts"] != null && b["started_ts"] != null) { if (int.parse(a["started_ts"]) > int.parse(b["started_ts"])) return a; if (int.parse(a["started_ts"]) < int.parse(b["started_ts"])) return b; } } return b; } for (final build in builds) { final tags = (build["tags"] as List).cast(); final builder = tags .firstWhere((tag) => tag.startsWith("builder:")) .substring("builder:".length); final ciBuilder = builder.replaceFirst(new RegExp("-try\$"), ""); if (!allBots.contains(ciBuilder)) { continue; } changelistBuilds[ciBuilder] = preferredBuild(changelistBuilds[ciBuilder], build); } if (options["verbose"]) { print("Loaded list of try runs."); } } final changelistBuilders = new Set.from(changelistBuilds.keys); // Select all the bots matching the glob patterns, final finalBotList = options["preapprove"] != null ? changelistBuilders : allBots; final botPatterns = options["preapprove"] != null && options["bot"].isEmpty ? ["*"] : options["bot"]; final bots = new Set(); for (final botPattern in botPatterns) { final glob = new Glob(botPattern); bool any = false; for (final bot in finalBotList) { if (glob.matches(bot)) { bots.add(bot); any = true; } } if (!any) { stderr.writeln("error: No bots matched pattern: $botPattern"); stderr.writeln("Try --list to get the list of bots, or --help for help"); exitCode = 1; return; } } for (final bot in bots) { print("Selected bot: $bot"); } // Error out if any of the requested try runs are incomplete. bool anyIncomplete = false; for (final bot in bots) { if (options["preapprove"] != null && changelistBuilds[bot]["status"] != "COMPLETED") { stderr.writeln("error: The try run for $bot isn't complete yet: " + changelistBuilds[bot]["status"]); anyIncomplete = true; } } if (anyIncomplete) { exitCode = 1; return; } // Load all the latest results for the selected bots, as well as flakiness // data, and the set of currently approved results. Each bot's latest build // is downloaded in parallel to make this phase faster. final testListFutures = >>[]; final noResultsBuilds = new SplayTreeMap(); for (final String bot in bots) { testListFutures.add(new Future(() async { try { return await loadResultsFromBot( bot, options, changeId, changelistBuilds[bot]); } on NoResultsException catch (e) { print( "Error: Failed to find results for $bot build <${e.buildUrl}>: $e"); noResultsBuilds[bot] = e.buildUrl; return []; } })); } // Collect all the tests from the synchronous downloads. final tests = []; for (final testList in await Future.wait(testListFutures)) { tests.addAll(testList); } tests.sort(); print(""); // Compute statistics and the set of interesting tests. 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 configurationsForBots = >{}; for (final test in tests) { var configurationSet = configurationsForBots[test.bot]; if (configurationSet == null) { configurationsForBots[test.bot] = configurationSet = new Set(); } configurationSet.add(test.configuration); } // Compute a nice displayed name for the bot and configuration. If the bot // only has a single configuration, then only mention the bot. Otherwise, // remove the redundant parts from configuration and present it compactly. // This is needed to avoid the tables becoming way too large. String getBotDisplayName(String bot, String configuration) { if (configurationsForBots[bot].length == 1) { return bot; } else { final simpleConfig = simplifyNamedConfiguration(bot, configuration); return "$bot/$simpleConfig"; } } // Compute the width of the fields in the below tables. final unapprovedBots = new Set(); int longestBot = "BOT/CONFIG".length; int longestTest = "TEST".length; int longestResult = "RESULT".length; int longestExpected = "EXPECTED".length; for (final test in selectedTests) { unapprovedBots.add(test.bot); final botDisplayName = getBotDisplayName(test.bot, test.configuration); longestBot = max(longestBot, botDisplayName.length); longestTest = max(longestTest, test.name.length); longestResult = max(longestResult, test.result.length); longestExpected = max(longestExpected, test.expected.length); } longestTest = min(longestTest, 120); // Some tests names are extremely long. // Table of lists that now succeed. if (fixedTests.isNotEmpty) { print("The following tests are now succeeding:\n"); if (options["table"] == "markdown") { print("| ${'BOT/CONFIG'.padRight(longestBot)} " "| ${'TEST'.padRight(longestTest)} |"); print("| ${'-' * longestBot} " "| ${'-' * longestTest} |"); } else if (options["table"] == "indent") { print("${'BOT/CONFIG'.padRight(longestBot)} " "TEST"); } for (final test in fixedTests) { final botDisplayName = getBotDisplayName(test.bot, test.configuration); if (options["table"] == "markdown") { print("| ${botDisplayName.padRight(longestBot)} " "| ${test.name.padRight(longestTest)} |"); } else if (options["table"] == "indent") { print("${botDisplayName.padRight(longestBot)} " "${test.name}"); } } print(""); } /// Table of lists that now fail. if (brokenTests.isNotEmpty) { print("The following tests are now failing:\n"); if (options["table"] == "markdown") { print("| ${'BOT'.padRight(longestBot)} " "| ${'TEST'.padRight(longestTest)} " "| ${'RESULT'.padRight(longestResult)} " "| ${'EXPECTED'.padRight(longestExpected)} | "); print("| ${'-' * longestBot} " "| ${'-' * longestTest} " "| ${'-' * longestResult} " "| ${'-' * longestExpected} | "); } else if (options["table"] == "indent") { print("${'BOT'.padRight(longestBot)} " "${'TEST'.padRight(longestTest)} " "${'RESULT'.padRight(longestResult)} " "EXPECTED"); } for (final test in brokenTests) { final botDisplayName = getBotDisplayName(test.bot, test.configuration); if (options["table"] == "markdown") { print("| ${botDisplayName.padRight(longestBot)} " "| ${test.name.padRight(longestTest)} " "| ${test.result.padRight(longestResult)} " "| ${test.expected.padRight(longestExpected)} |"); } else if (options["table"] == "indent") { print("${botDisplayName.padRight(longestBot)} " "${test.name.padRight(longestTest)} " "${test.result.padRight(longestResult)} " "${test.expected}"); } } print(""); } // Provide statistics on how well the bots are doing. void statistic(int numerator, int denominator, String what) { double percent = numerator / denominator * 100.0; String percentString = percent.toStringAsFixed(2) + "%"; print("$numerator of $denominator $what ($percentString)"); } statistic(failingTestsCount, tests.length, "tests are failing"); statistic(flakyTestsCount, tests.length, "tests are flaky"); statistic( fixedTests.length, tests.length, "tests were fixed since last approval"); statistic(brokenTests.length, tests.length, "tests were broken since last approval"); // Warn about any builders where results weren't available. if (noResultsBuilds.isNotEmpty) { print(""); noResultsBuilds.forEach((String builder, String buildUrl) { print("Warning: No results were found for $builder: <$buildUrl>"); }); print("Warning: Builders without results are usually due to infrastructure " "issues, please have a closer look at the affected builders and try " "the build again."); } // Stop if there's nothing to do. if (unapprovedBots.isEmpty) { print("\nEvery test result has already been approved."); return; } // Stop if this is a dry run. if (options["no"]) { if (selectedTests.length == 1) { print("1 test has a changed result and needs approval"); } else { print("${selectedTests.length} " "tests have changed results and need approval"); } return; } // Confirm the approval if run interactively. if (!options["yes"]) { print(""); print("Note: It is assumed bugs have been filed about the above failures " "before they are approved here."); if (brokenTests.isNotEmpty) { 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 " "$tryBuilders green on the next $tryCommit."); } while (true) { 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) { print("n"); return; } if (line.toLowerCase() == "n" || line.toLowerCase() == "no") { return; } if (line == "" || line.toLowerCase() == "y" || line.toLowerCase() == "yes") { break; } } } else { print("Note: It is assumed bugs have been filed about the above failures."); } print(""); // Log who approved these results. final username = (options["automated-approver"] ? "automatic-approval" : null) ?? Platform.environment["LOGNAME"] ?? Platform.environment["USER"] ?? Platform.environment["USERNAME"]; if (username == null || username == "") { stderr.writeln("error: Your identity could not be established. " "Please set one of the LOGNAME, USER, USERNAME environment variables."); exitCode = 1; return; } final nowDate = new DateTime.now().toUtc(); final now = nowDate.toIso8601String(); // Deep clones a decoded json object. dynamic deepClone(dynamic object) { if (object is Map) { final result = {}; for (final key in object.keys) { result[key] = deepClone(object[key]); } return result; } else if (object is List) { final result = []; for (final value in object) { result.add(deepClone(value)); } return result; } else { return object; } } // Build the new approval data with the changes in test results applied. final newApprovalsForBuilders = >>{}; 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", () => {}); preapprovals.remove(changeId); final newApprovals = newApprovalsForBuilders.putIfAbsent( test.bot, () => new SplayTreeMap>()); 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>()); final approvalData = newApprovals.putIfAbsent(test.key, () => {}); approvalData["name"] = test.name; approvalData["configuration"] = test.configuration; approvalData["suite"] = test.resultData["suite"]; approvalData["test_name"] = test.resultData["test_name"]; final preapprovals = approvalData.putIfAbsent("preapprovals", () => {}); final preapproval = preapprovals.putIfAbsent(changeId, () => {}); preapproval["from"] = test.approvedResult; preapproval["result"] = test.result; preapproval["matches"] = test.matches; preapproval["expected"] = test.expected; preapproval["preapprover"] = username; preapproval["preapproved_at"] = now; preapproval["expires"] = nowDate.add(const Duration(days: 30)).toIso8601String(); } } 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", () => {}); final newApprovals = newApprovalsForBuilders.putIfAbsent( test.bot, () => new SplayTreeMap>()); newApprovals[test.key] = approvalData; } // Approve the changes in test results. for (final test in selectedTests) { final newApprovals = newApprovalsForBuilders.putIfAbsent( test.bot, () => new SplayTreeMap>()); final approvalData = newApprovals.putIfAbsent(test.key, () => {}); 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", () => {}); } } // Reconstruct the old approvals so we can double check there was no race // condition when uploading. final oldApprovalsForBuilders = >>{}; for (final test in tests) { if (test.approvedResultData == null) continue; final oldApprovals = oldApprovalsForBuilders.putIfAbsent( test.bot, () => new SplayTreeMap>()); oldApprovals[test.key] = test.approvedResultData; } for (final builder in newApprovalsForBuilders.keys) { oldApprovalsForBuilders.putIfAbsent( builder, () => >{}); } // Update approved_results.json for each builder with unapproved changes. final outDirectory = await Directory.systemTemp.createTemp("approved_results."); bool raceCondition = false; try { print("Uploading approved results..."); final futures = []; for (final String builder in newApprovalsForBuilders.keys) { final approvals = newApprovalsForBuilders[builder].values; final localPath = "${outDirectory.path}/$builder.json"; await new File(localPath).writeAsString( approvals.map((approval) => jsonEncode(approval) + "\n").join("")); final remotePath = "$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) { 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"); } } finally { await outDirectory.delete(recursive: true); } }