| // Copyright (c) 2020, 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. | |
| // This script is used by the bisection mechanism to update the blamelists | |
| // of active, non-approved failures which include the commit of the current | |
| // bisection build. | |
| import 'dart:io'; | |
| import 'package:args/args.dart'; | |
| import 'package:test_runner/bot_results.dart'; | |
| import 'lib/src/firestore.dart'; | |
| const newTest = 'new test'; | |
| const skippedTest = 'skipped'; | |
| const maxAttempts = 20; | |
| late FirestoreDatabase database; | |
| class ResultRecord { | |
| final Map<String, dynamic> data; | |
| ResultRecord(this.data); | |
| Map field(String name) => (data['fields'] as Map)[name]; | |
| int get blamelistStartIndex { | |
| return int.parse(field('blamelist_start_index')['integerValue']); | |
| } | |
| set blamelistStartIndex(int index) { | |
| field('blamelist_start_index')['integerValue'] = '$index'; | |
| } | |
| int get blamelistEndIndex { | |
| return int.parse(field('blamelist_end_index')['integerValue']); | |
| } | |
| String get result => field('result')['stringValue'] /*!*/; | |
| String get previousResult => field('previous_result')['stringValue'] /*!*/; | |
| String get name => field('name')['stringValue'] /*!*/; | |
| String get updateTime => data['updateTime'] /*!*/; | |
| } | |
| Query unapprovedActiveFailuresQuery(String configuration) { | |
| return Query( | |
| 'results', | |
| CompositeFilter('AND', [ | |
| Field('approved').equals(Value.boolean(false)), | |
| // TODO(karlklose): also search for inactive failures? | |
| Field('active_configurations').contains(Value.string(configuration)), | |
| // TODO(karlklose): add index to check for blamelist_start_index < ? | |
| ])); | |
| } | |
| Future<int> getCommitIndex(String commit) async { | |
| try { | |
| var document = | |
| (await database.getDocument('commits', commit)).cast<String, dynamic>(); | |
| var index = (document['fields'] as Map)['index'] as Map; | |
| if (index['integerValue'] == null) { | |
| throw Exception('Expected an integer, but got "$index"'); | |
| } | |
| return int.parse(index['integerValue']); | |
| } catch (exception) { | |
| print('Could not retrieve index for commit "$commit".\n'); | |
| rethrow; | |
| } | |
| } | |
| /// Compute if the record should be updated based on the outcomes in the | |
| /// result record and the new test result. | |
| bool shouldUpdateRecord(ResultRecord resultRecord, Result? testResult) { | |
| if (testResult == null || !testResult.matches) { | |
| return false; | |
| } | |
| var baseline = testResult.expectation.toLowerCase(); | |
| if (resultRecord.previousResult.toLowerCase() != baseline) { | |
| // Currently we only support the case where a bisection run improves the | |
| // accuracy of a "Green" -> "Red" result record. | |
| return false; | |
| } | |
| if (resultRecord.result.toLowerCase() == newTest || | |
| resultRecord.result.toLowerCase() == skippedTest) { | |
| // Skipped tests are often configuration dependent, so it could be wrong | |
| // to generalize their effect for the result record to different | |
| // configurations. | |
| return false; | |
| } | |
| return true; | |
| } | |
| Future<void> updateBlameLists(String configuration, String commit, | |
| Map<String, Map<String, dynamic>> testResults) async { | |
| int commitIndex = await getCommitIndex(commit); | |
| var query = unapprovedActiveFailuresQuery(configuration); | |
| bool needsRetry; | |
| int attempts = 0; | |
| do { | |
| needsRetry = false; | |
| var documentPaths = (await database.runQuery(query)) | |
| .cast<Map>() | |
| .where((result) => result['document'] != null) | |
| .map((result) => (result['document'] as Map)['name'] as String); | |
| for (var documentPath in documentPaths) { | |
| database.beginTransaction(); | |
| var documentName = documentPath.split('/').last; | |
| final docMap = await database.getDocument('results', documentName); | |
| var result = ResultRecord(docMap.cast<String, dynamic>()); | |
| if (commitIndex < result.blamelistStartIndex || | |
| commitIndex >= result.blamelistEndIndex) { | |
| continue; | |
| } | |
| String name = result.name; | |
| var testResultData = testResults['$configuration:$name']; | |
| var testResult = | |
| testResultData != null ? Result.fromMap(testResultData) : null; | |
| if (!shouldUpdateRecord(result, testResult)) { | |
| continue; | |
| } | |
| print('Found result record: $configuration:${result.name}: ' | |
| '${result.previousResult} -> ${result.result} ' | |
| 'in ${result.blamelistStartIndex}..${result.blamelistEndIndex} ' | |
| 'to update with ${testResult?.outcome} at $commitIndex.'); | |
| // We found a result representation for this test and configuration whose | |
| // blamelist includes this results' commit but whose outcome is different | |
| // then the outcome in the provided test results. | |
| // This means that this commit should not be part of the result | |
| // representation and we can update the lower bound of the commit range | |
| // and the previous result. | |
| var newStartIndex = commitIndex + 1; | |
| if (newStartIndex > result.blamelistEndIndex) { | |
| print('internal error: inconsistent results; skipping results entry'); | |
| continue; | |
| } | |
| result.blamelistStartIndex = newStartIndex; | |
| var updateIndex = Update(['blamelist_start_index'], result.data); | |
| if (!await database.commit(writes: [updateIndex])) { | |
| // Committing the change to the database had a conflict, retry. | |
| needsRetry = true; | |
| if (++attempts == maxAttempts) { | |
| throw Exception('Exceeded maximum retry attempts ($maxAttempts).'); | |
| } | |
| print('Transaction failed, trying again!'); | |
| } | |
| } | |
| } while (needsRetry); | |
| } | |
| void main(List<String> arguments) async { | |
| var parser = ArgParser() | |
| ..addOption('auth-token', | |
| abbr: 'a', | |
| help: 'path to a file containing the gcloud auth token (required)') | |
| ..addOption('results', | |
| abbr: 'r', | |
| help: 'path to a file containing the test results (required)') | |
| ..addFlag('staging', abbr: 's', help: 'use staging database'); | |
| var options = parser.parse(arguments); | |
| if (options.rest.isNotEmpty || | |
| options.option('results') == null || | |
| options.option('auth-token') == null) { | |
| print(parser.usage); | |
| exit(1); | |
| } | |
| var results = await loadResultsMap(options.option('results')!); | |
| if (results.isEmpty) { | |
| print("No test results provided, nothing to update."); | |
| return; | |
| } | |
| // Pick an arbitrary result entry to find configuration and commit hash. | |
| var firstResult = Result.fromMap(results.values.first); | |
| var commit = firstResult.commitHash!; | |
| var configuration = firstResult.configuration; | |
| var project = options.flag('staging') ? 'dart-ci-staging' : 'dart-ci'; | |
| database = FirestoreDatabase( | |
| project, await readGcloudAuthToken(options.option('auth-token')!)); | |
| await updateBlameLists(configuration, commit, results); | |
| database.closeClient(); | |
| } |