mirror of
https://github.com/dart-lang/sdk
synced 2024-09-05 00:13:50 +00:00
![Jonas Termansen](/assets/img/avatar_default.png)
Change-Id: Ide82c48bebe158934ee1f25724341f72c519d3f7 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/128663 Reviewed-by: Alexander Thomas <athom@google.com>
1034 lines
38 KiB
Dart
Executable file
1034 lines
38 KiB
Dart
Executable file
#!/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';
|
|
|
|
bool isUnapprovable(Test test) {
|
|
String name = test.name;
|
|
return name == "pkg/analyzer/test/generated/parser_fasta_test" ||
|
|
name == "pkg/compiler/tool/generate_kernel_test" ||
|
|
name == "pkg/vm/test/kernel_front_end_test" ||
|
|
["pkg/kernel/test/", "pkg/front_end/", "pkg/analyzer/test/src/fasta/"]
|
|
.any((path) => test.name.startsWith(path));
|
|
}
|
|
|
|
/// 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
|
|
/// compact and readable output.
|
|
String simplifyNamedConfiguration(String bot, String namedConfiguration) {
|
|
final botComponents = new Set<String>.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<String, dynamic> baselineData;
|
|
final Map<String, dynamic> resultData;
|
|
final Map<String, dynamic> approvedResultData;
|
|
final Map<String, dynamic> 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<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 => _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<String> get flakyModes =>
|
|
flakinessData != null ? flakinessData["outcomes"].cast<String>() : null;
|
|
bool get isFlake =>
|
|
resultData != null && resultData["flaky"] ||
|
|
flakinessData != null && flakyModes.contains(result);
|
|
}
|
|
|
|
/// Loads the results file as as a map if the file exists, otherwise returns the
|
|
/// empty map.
|
|
Future<Map<String, Map<String, dynamic>>> loadResultsMapIfExists(
|
|
String path) async =>
|
|
await new File(path).exists()
|
|
? loadResultsMap(path)
|
|
: <String, Map<String, dynamic>>{};
|
|
|
|
/// 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<String> 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<List<int>>()
|
|
.transform(new Utf8Decoder())
|
|
.timeout(const Duration(seconds: 60))
|
|
.toList())
|
|
.join("");
|
|
return contents;
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/// Loads the results from the bot.
|
|
Future<List<Test>> loadResultsFromBot(String bot, ArgResults options,
|
|
String changeId, Map<String, dynamic> 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.
|
|
final build = (changeId != null
|
|
? await loadLog(
|
|
changelistBuild["id"],
|
|
"download_previous_results/0/steps/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 = <String, Map<String, dynamic>>{};
|
|
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 <Test>[];
|
|
}
|
|
|
|
final resultsFile = new File("$buildPath/results.json");
|
|
if (!await resultsFile.exists()) {
|
|
print("$bot: No results.json exists");
|
|
return <Test>[];
|
|
}
|
|
|
|
// 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 = <Test>[];
|
|
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<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
|
|
.cast<List<int>>()
|
|
.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",
|
|
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();
|
|
|
|
// Check if we have become the old workflow.
|
|
if (options["verbose"]) {
|
|
print("Checking for epilogue...");
|
|
}
|
|
final epilogue =
|
|
await catGsutil("gs://dart-test-results-approved-results/epilogue.txt");
|
|
if (epilogue != null) {
|
|
print(epilogue.trim());
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// 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 = <String>[];
|
|
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<String>();
|
|
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<String>.from(testMatrixBots)
|
|
.intersection(new Set<String>.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 = <String, Map<String, dynamic>>{};
|
|
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<changelist> or $prefix<changelist>/<patchset>");
|
|
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<Map<String, dynamic>> 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<String, dynamic> object = await response
|
|
.cast<List<int>>()
|
|
.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;
|
|
}
|
|
if (object["builds"] == null) {
|
|
break;
|
|
}
|
|
builds.addAll(object["builds"]);
|
|
cursor = object["next_cursor"];
|
|
} while (cursor != null);
|
|
|
|
// Prefer the newest completed build.
|
|
Map<String, dynamic> preferredBuild(
|
|
Map<String, dynamic> a, Map<String, dynamic> 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<dynamic>).cast<String>();
|
|
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<String>.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<String>();
|
|
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 = <Future<List<Test>>>[];
|
|
final noResultsBuilds = new SplayTreeMap<String, String>();
|
|
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 <Test>[];
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Collect all the tests from the synchronous downloads.
|
|
final tests = <Test>[];
|
|
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 = [];
|
|
final brokenTests = [];
|
|
final blackListedTests = [];
|
|
for (Test test in selectedTests) {
|
|
if (isUnapprovable(test) && !test.matches) {
|
|
blackListedTests.add(test);
|
|
} else if (test.matches) {
|
|
fixedTests.add(test);
|
|
} else {
|
|
brokenTests.add(test);
|
|
}
|
|
}
|
|
|
|
// Find out which bots have multiple configurations.
|
|
final configurationsForBots = <String, Set<String>>{};
|
|
for (final test in tests) {
|
|
var configurationSet = configurationsForBots[test.bot];
|
|
if (configurationSet == null) {
|
|
configurationsForBots[test.bot] = configurationSet = new Set<String>();
|
|
}
|
|
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<String>();
|
|
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("");
|
|
}
|
|
|
|
if (blackListedTests.isNotEmpty) {
|
|
print("Warning: The following failing test(s) cannot be approved:");
|
|
print("");
|
|
blackListedTests.forEach((test) => print(" ${test.name}"));
|
|
print("");
|
|
print(
|
|
"Try fixing or reverting the change that caused the failure(s) instead.");
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
// 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>{});
|
|
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", () => <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;
|
|
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"] ?? <dynamic>[]).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) {
|
|
if (blackListedTests.contains(test)) continue;
|
|
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>{});
|
|
}
|
|
}
|
|
|
|
// 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 oldApprovals = oldApprovalsForBuilders.putIfAbsent(
|
|
test.bot, () => new SplayTreeMap<String, Map<String, dynamic>>());
|
|
oldApprovals[test.key] = test.approvedResultData;
|
|
}
|
|
for (final builder in newApprovalsForBuilders.keys) {
|
|
oldApprovalsForBuilders.putIfAbsent(
|
|
builder, () => <String, Map<String, dynamic>>{});
|
|
}
|
|
|
|
// 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 = <Future>[];
|
|
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);
|
|
}
|
|
}
|