blob: 29dbf8c1d501c64e47c4b7c2dba639b5813e0f05 [file] [log] [blame]
#!/usr/bin/env dart
// Copyright (c) 2019, 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.
// Find the success/failure status for a builder that is written to
// Firestore by the cloud functions that process results.json.
// These cloud functions write a success/failure result to the
// builder table based on the approvals in Firestore.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
const numAttempts = 20;
const failuresPerConfiguration = 20;
late bool useStagingDatabase;
Uri get _queryUrl {
final project = useStagingDatabase ? 'dart-ci-staging' : 'dart-ci';
return Uri.https('firestore.googleapis.com',
'/v1/projects/$project/databases/(default)/documents:runQuery');
}
late String builder;
late String builderBase;
late int buildNumber;
late String token;
late http.Client client;
String get buildTable => builder.endsWith('-try') ? 'try_builds' : 'builds';
String get resultsTable => builder.endsWith('-try') ? 'try_results' : 'results';
bool booleanFieldOrFalse(Map<String, dynamic> document, String field) {
final fieldObject = (document['fields'] as Map)[field];
return (fieldObject as Map?)?['booleanValue'] ?? false;
}
void usage(ArgParser parser) {
print('''
Usage: get_builder_status.dart [OPTIONS]
Gets the builder status from the Firestore database.
Polls until it gets a completed builder status, or times out.
The options are as follows:
${parser.usage}''');
exit(1);
}
Future<String> readGcloudAuthToken(String path) async {
final token = await File(path).readAsString();
return token.split('\n').first;
}
void main(List<String> args) async {
final parser = ArgParser();
parser.addFlag('help', help: 'Show the program usage.', negatable: false);
parser.addOption('auth_token',
abbr: 'a', help: 'Authorization token with cloud-platform scope');
parser.addOption('builder', abbr: 'b', help: 'The builder name');
parser.addOption('build_number', abbr: 'n', help: 'The build number');
parser.addFlag('staging',
abbr: 's', help: 'use staging database', defaultsTo: false);
final options = parser.parse(args);
if (options.flag('help')) {
usage(parser);
}
useStagingDatabase = options.flag('staging');
if (options.option('builder') == null) {
print('Option "--builder" is required\n');
usage(parser);
}
builder = options.option('builder')!;
builderBase = builder.replaceFirst(RegExp('-try\$'), '');
if (options.option('build_number') == null) {
print('Option "--build_number" is required\n');
usage(parser);
}
buildNumber = int.parse(options.option('build_number')!);
if (options.option('auth_token') == null) {
print('Option "--auth_token" is required\n');
usage(parser);
}
token = await readGcloudAuthToken(options.option('auth_token')!);
client = http.Client();
final response = await runFirestoreQuery(buildQuery());
if (response.statusCode != HttpStatus.ok) {
print('HTTP status ${response.statusCode} received '
'when fetching build data');
exit(2);
}
final documents = jsonDecode(response.body) as List;
final document = (documents.first as Map)['document'];
if (document == null) {
print('No results received for build $buildNumber of $builder');
exit(2);
}
final success = booleanFieldOrFalse(document, 'success');
print(success
? 'No new unapproved failures'
: 'There are new unapproved failures on this build');
if (builder.endsWith('-try')) exit(success ? 0 : 1);
final configurations = await getConfigurations();
final failures = await fetchActiveFailures(configurations);
if (failures.isNotEmpty) {
print('There are unapproved failures');
printActiveFailures(failures);
exit(1);
} else {
print('There are no unapproved failures');
exit(0);
}
}
Future<List<String>> getConfigurations() async {
final response = await runFirestoreQuery(configurationsQuery());
if (response.statusCode != HttpStatus.ok) {
print('Could not fetch configurations for $builderBase');
return [];
}
final documents = jsonDecode(response.body);
final groups = <String>{
for (Map document in documents)
if (document.containsKey('document'))
((document['document'] as Map)['name'] as String).split('/').last
};
return groups.toList();
}
Map<int, Future<String>> commitHashes = {};
Future<String> commitHash(int index) =>
commitHashes.putIfAbsent(index, () => fetchCommitHash(index));
Future<String> fetchCommitHash(int index) async {
final response = await runFirestoreQuery(commitQuery(index));
if (response.statusCode == HttpStatus.ok) {
final documents = jsonDecode(response.body) as List;
final document = (documents.first as Map)['document'] as Map?;
if (document != null) {
return (document['name'] as String).split('/').last;
}
}
print('Could not fetch commit with index $index');
return 'missing hash for commit $index';
}
Future<Map<String, List<Map<String, dynamic>>>> fetchActiveFailures(
List<String> configurations) async {
final failures = <String, List<Map<String, dynamic>>>{};
for (final configuration in configurations) {
final response =
await runFirestoreQuery(unapprovedFailuresQuery(configuration));
if (response.statusCode == HttpStatus.ok) {
final documents = (jsonDecode(response.body) as List).cast<Map>();
for (final documentItem in documents) {
final document = documentItem['document'] as Map?;
if (document == null) continue;
final fields = document['fields'] as Map;
failures.putIfAbsent(configuration, () => []).add({
'name': (fields['name'] as Map)['stringValue'],
'start_commit': await commitHash(int.parse(
(fields['blamelist_start_index'] as Map)['integerValue'])),
'end_commit': await commitHash(int.parse(
(fields['blamelist_end_index'] as Map)['integerValue'])),
'result': (fields['result'] as Map)['stringValue'],
'expected': (fields['expected'] as Map)['stringValue'],
'previous': (fields['previous_result'] as Map)['stringValue'],
});
}
}
}
return failures;
}
void printActiveFailures(Map<String, List<Map<String, dynamic>>> failures) {
failures.forEach((configuration, failureList) {
print('($configuration):');
for (final failure in failureList) {
print([
' ',
failure['name'],
' (',
failure['previous'],
' -> ',
failure['result'],
', expected ',
failure['expected'],
') at ',
(failure['start_commit'] as String).substring(0, 6),
if (failure['end_commit'] != failure['start_commit']) ...[
'..',
(failure['end_commit'] as String).substring(0, 6)
]
].join(''));
}
});
}
Future<http.Response> runFirestoreQuery(String query) {
final headers = {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
'Content-Type': 'application/json'
};
return client.post(_queryUrl, headers: headers, body: query);
}
String buildQuery() => jsonEncode({
'structuredQuery': {
'from': [
{'collectionId': buildTable}
],
'limit': 1,
'where': {
'compositeFilter': {
'op': 'AND',
'filters': [
{
'fieldFilter': {
'field': {'fieldPath': 'build_number'},
'op': 'EQUAL',
'value': {'integerValue': buildNumber}
}
},
{
'fieldFilter': {
'field': {'fieldPath': 'builder'},
'op': 'EQUAL',
'value': {'stringValue': builder}
}
}
]
}
}
}
});
String configurationsQuery() => jsonEncode({
'structuredQuery': {
'from': [
{'collectionId': 'configurations'}
],
'where': {
'fieldFilter': {
'field': {'fieldPath': 'builder'},
'op': 'EQUAL',
'value': {'stringValue': builderBase}
}
}
}
});
String unapprovedFailuresQuery(String configuration) => jsonEncode({
'structuredQuery': {
'from': [
{'collectionId': resultsTable}
],
'limit': failuresPerConfiguration,
'where': {
'compositeFilter': {
'op': 'AND',
'filters': [
{
'fieldFilter': {
'field': {'fieldPath': 'active_configurations'},
'op': 'ARRAY_CONTAINS',
'value': {'stringValue': configuration}
}
},
{
'fieldFilter': {
'field': {'fieldPath': 'approved'},
'op': 'EQUAL',
'value': {'booleanValue': false}
}
}
]
}
}
}
});
String commitQuery(int index) => jsonEncode({
'structuredQuery': {
'from': [
{'collectionId': 'commits'}
],
'limit': 1,
'where': {
'fieldFilter': {
'field': {'fieldPath': 'index'},
'op': 'EQUAL',
'value': {'integerValue': index}
}
}
}
});