| // 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. |
| |
| import 'dart:convert'; |
| import 'dart:math' show max, min; |
| |
| import 'package:firebase_functions_interop/firebase_functions_interop.dart'; |
| import 'package:node_http/node_http.dart' as http; |
| |
| void info(Object message) { |
| print("Info: $message"); |
| } |
| |
| void error(Object message) { |
| print("Error: $message"); |
| } |
| |
| void main() { |
| functions['receiveChanges'] = |
| functions.pubsub.topic('results').onPublish(receiveChanges); |
| } |
| |
| // Cloud functions run the cloud function many times in the same isolate. |
| // Use static initializer to run global initialization once. |
| Firestore firestore = createFirestore(); |
| |
| Firestore createFirestore() { |
| final app = FirebaseAdmin.instance.initializeApp(); |
| return app.firestore() |
| ..settings(FirestoreSettings(timestampsInSnapshots: true)); |
| } |
| |
| Future<void> receiveChanges(Message message, EventContext context) async { |
| final results = (message.json as List).cast<Map<String, dynamic>>(); |
| final stats = Statistics(); |
| var buildInfo = await storeBuildInfo(results, stats); |
| await Future.forEach(results.where(isChangedResult), |
| (result) => storeChange(result, buildInfo, stats)); |
| stats.report(); |
| } |
| |
| class Statistics { |
| int results = 0; |
| int changes = 0; |
| int newRecords = 0; |
| int modifiedRecords = 0; |
| int commitsFetched = 0; |
| String builder; |
| int buildNumber; |
| |
| void report() { |
| info("Number of changed results processed: $changes"); |
| info("Number of results processed: $results"); |
| info("Number of firestore records produced: $newRecords"); |
| info("Number of firestore records modified: $modifiedRecords"); |
| info("Number of commits fetched: $commitsFetched"); |
| } |
| } |
| |
| Future<Map<String, int>> storeBuildInfo( |
| List<Map<String, dynamic>> results, Statistics stats) async { |
| stats.results = results.length; |
| // Get indices of change. Range includes startIndex and endIndex. |
| final change = results.first; |
| final hash = change['commit_hash'] as String; |
| if (hash.startsWith('refs/changes')) return {}; // No buildInfo for try jobs. |
| final docRef = await firestore.document('commits/$hash'); |
| var docSnapshot = await docRef.get(); |
| if (!docSnapshot.exists) { |
| await getMissingCommits(hash, stats); |
| docSnapshot = await docRef.get(); |
| if (!docSnapshot.exists) { |
| error('Result received with unknown commit hash $hash'); |
| } |
| } |
| final endIndex = docSnapshot.data.getInt('index'); |
| // If this is a new builder, use the current commit as a trivial blamelist. |
| var startIndex = endIndex; |
| if (change['previous_commit_hash'] != null) { |
| final commit = await firestore |
| .document('commits/${change['previous_commit_hash']}') |
| .get(); |
| startIndex = commit.data.getInt('index') + 1; |
| } |
| |
| final String builder = change['builder_name']; |
| stats.builder = builder; |
| final int buildNumber = int.parse(change['build_number']); |
| stats.buildNumber = buildNumber; |
| final Set<String> configurations = |
| results.map((change) => change['configuration'] as String).toSet(); |
| for (final configuration in configurations) { |
| final record = |
| await firestore.document('configurations/$configuration').get(); |
| if (!record.exists || record.data.getString('builder') != builder) { |
| await firestore |
| .document('configurations/$configuration') |
| .setData(DocumentData.fromMap({'builder': builder})); |
| if (!record.exists) { |
| info('Configuration document $configuration -> $builder created'); |
| } else { |
| info('Configuration document changed: $configuration -> $builder ' |
| '(was ${record.data.getString("builder")}'); |
| } |
| } |
| } |
| |
| final documentRef = firestore.document('builds/$builder:$endIndex'); |
| final record = await documentRef.get(); |
| if (!record.exists) { |
| await documentRef.setData(DocumentData.fromMap( |
| {'builder': builder, 'build_number': buildNumber, 'index': endIndex})); |
| info('Created build record: ' |
| 'builder: $builder, build_number: $buildNumber, index: $endIndex'); |
| } else if (record.data.getInt('index') != endIndex) { |
| error( |
| 'Build $buildNumber of $builder had commit index ${record.data.getInt('index')},' |
| 'should be $endIndex.'); |
| } |
| return {"startIndex": startIndex, "endIndex": endIndex}; |
| } |
| |
| Future<void> storeChange(Map<String, dynamic> change, |
| Map<String, int> buildInfo, Statistics stats) async { |
| stats.changes++; |
| if ((change['commit_hash'] as String).startsWith('refs/changes')) { |
| return storeTryChange(change); |
| } |
| String name = change['name']; |
| String result = change['result']; |
| String previousResult = change['previous_result'] ?? 'new test'; |
| QuerySnapshot snapshot = await firestore |
| .collection('results') |
| .orderBy('blamelist_end_index', descending: true) |
| .where('name', isEqualTo: name) |
| .where('result', isEqualTo: result) |
| .where('previous_result', isEqualTo: previousResult) |
| .where('expected', isEqualTo: change['expected']) |
| .limit(5) // We will pick the right one, probably the latest. |
| .get(); |
| |
| // Find an existing change group with a blamelist that intersects this change. |
| final startIndex = buildInfo['startIndex']; |
| final endIndex = buildInfo['endIndex']; |
| |
| bool blamelistIncludesChange(DocumentSnapshot groupDocument) { |
| var group = groupDocument.data; |
| var groupStart = group.getInt('blamelist_start_index'); |
| var groupEnd = group.getInt('blamelist_end_index'); |
| return startIndex <= groupEnd && endIndex >= groupStart; |
| } |
| |
| DocumentSnapshot group = snapshot.documents |
| .firstWhere(blamelistIncludesChange, orElse: () => null); |
| |
| if (group == null) { |
| info("Adding group for $name"); |
| return firestore.collection('results').add(DocumentData.fromMap({ |
| 'name': name, |
| 'result': result, |
| 'previous_result': previousResult, |
| 'expected': change['expected'], |
| 'blamelist_start_index': startIndex, |
| 'blamelist_end_index': endIndex, |
| 'trivial_blamelist': startIndex == endIndex, |
| 'configurations': <String>[change['configuration']] |
| })); |
| } |
| |
| // Update the change group in a transaction. |
| // Add new configuration and narrow the blamelist. |
| Future<void> updateGroup(Transaction transaction) async { |
| final DocumentSnapshot groupSnapshot = |
| await transaction.get(group.reference); |
| final data = groupSnapshot.data; |
| final newStart = max(startIndex, data.getInt('blamelist_start_index')); |
| final newEnd = min(endIndex, data.getInt('blamelist_end_index')); |
| final updateMap = <String, dynamic>{ |
| 'blamelist_start_index': |
| max(startIndex, data.getInt('blamelist_start_index')), |
| 'blamelist_end_index': min(endIndex, data.getInt('blamelist_end_index')) |
| }; |
| updateMap['trivial_blamelist'] = (updateMap['blamelist_start_index'] == |
| updateMap['blamelist_end_index']); |
| final update = UpdateData.fromMap({ |
| 'blamelist_start_index': newStart, |
| 'blamelist_end_index': newEnd, |
| 'trivial_blamelist': newStart == newEnd |
| }); |
| update.setFieldValue('configurations', |
| Firestore.fieldValues.arrayUnion([change['configuration']])); |
| group.reference.updateData(update); |
| } |
| |
| return firestore.runTransaction(updateGroup); |
| } |
| |
| Future<void> storeTryChange(Map<String, dynamic> change) async { |
| String name = change['name']; |
| String result = change['result']; |
| String expected = change['expected']; |
| String patch = change['commit_hash']; |
| String previousResult = change['previous_result'] ?? 'new test'; |
| QuerySnapshot snapshot = await firestore |
| .collection('try_results') |
| .where('patch', isEqualTo: patch) |
| .where('name', isEqualTo: name) |
| .where('result', isEqualTo: result) |
| .where('previous_result', isEqualTo: previousResult) |
| .where('expected', isEqualTo: expected) |
| .limit(1) |
| .get(); |
| |
| if (snapshot.isEmpty) { |
| info("Adding group for $name"); |
| return firestore.collection('try_results').add(DocumentData.fromMap({ |
| 'name': name, |
| 'result': result, |
| 'previous_result': previousResult, |
| 'expected': expected, |
| 'patch': patch, |
| 'configurations': <String>[change['configuration']] |
| })); |
| } else { |
| final update = UpdateData() |
| ..setFieldValue('configurations', |
| Firestore.fieldValues.arrayUnion([change['configuration']])); |
| snapshot.documents.first.reference.updateData(update); |
| } |
| } |
| |
| bool isChangedResult(Map<String, dynamic> result) => |
| result['changed'] && !result['flaky'] && !result['previous_flaky']; |
| |
| Future<void> getMissingCommits(String hash, Statistics stats) async { |
| final client = http.NodeClient(); |
| final QuerySnapshot lastCommit = await firestore |
| .collection('commits') |
| .orderBy('index', descending: true) |
| .limit(1) |
| .get(); |
| final lastHash = lastCommit.documents.first.documentID; |
| final lastIndex = lastCommit.documents.first.data.getInt('index'); |
| |
| final logUrl = 'https://dart.googlesource.com/sdk/+log/'; |
| final range = '$lastHash..master'; |
| final parameters = ['format=JSON', 'topo-order', 'n=1000']; |
| final url = '$logUrl$range?${parameters.join('&')}'; |
| final response = await client.get(url); |
| final log = response.body; |
| // Expect XSSI protection. |
| if (!log.startsWith(")]}'")) { |
| error('Got response from gerrit API without XSSI protection: ' + log); |
| throw ('Got response from gerrit API without XSSI protection: ' + log); |
| } |
| // Remove first line when XSSI protection is there. |
| final commits = |
| jsonDecode(log.substring(log.indexOf('\n') + 1))['log'] as List<dynamic>; |
| if (commits.isEmpty) { |
| info('Found no new commits between $lastHash and master'); |
| return; |
| } |
| stats.commitsFetched = commits.length; |
| final first = commits.last as Map<String, dynamic>; |
| if (first['parents'].first != lastHash) { |
| error('First new commit ${first['parents'].first} is not' |
| ' a child of last known commit $lastHash when fetching new commits'); |
| throw ('First new commit ${first['parents'].first} is not' |
| ' a child of last known commit $lastHash when fetching new commits'); |
| } |
| if (!commits.any((commit) => commit['commit'] == hash)) { |
| info('Did not find commit $hash when fetching new commits'); |
| return; |
| } |
| var index = lastIndex + 1; |
| for (Map<String, dynamic> commit in commits.reversed) { |
| // Create new commit document for this commit. |
| final docRef = firestore.document('commits/${commit['commit']}'); |
| final data = DocumentData.fromMap({ |
| 'author': commit['author']['email'], |
| 'created': Timestamp.fromDateTime( |
| parseGitilesDateTime(commit['committer']['time'])), |
| 'index': index, |
| 'title': commit['message'].split('\n').first |
| }); |
| await docRef.setData(data); |
| ++index; |
| } |
| } |
| |
| const months = const { |
| 'Jan': '01', |
| 'Feb': '02', |
| 'Mar': '03', |
| 'Apr': '04', |
| 'May': '05', |
| 'Jun': '06', |
| 'Jul': '07', |
| 'Aug': '08', |
| 'Sep': '09', |
| 'Oct': '10', |
| 'Nov': '11', |
| 'Dec': '12' |
| }; |
| |
| DateTime parseGitilesDateTime(String gitiles) { |
| final parts = gitiles.split(' '); |
| final year = parts[4]; |
| final month = months[parts[1]]; |
| final day = parts[2].padLeft(2, '0'); |
| return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}'); |
| } |