| // 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:collection'; |
| |
| import 'package:firebase/firestore.dart' as firestore; |
| |
| import '../services/filter_service.dart'; |
| import 'comment.dart'; |
| |
| class IntRangeIterator implements Iterator<int> { |
| @override |
| int current; |
| int end; |
| |
| IntRangeIterator(int start, this.end) { |
| current = start - 1; |
| } |
| |
| @override |
| bool moveNext() { |
| current++; |
| if (current > end) current = null; |
| return current != null; |
| } |
| } |
| |
| class IntRange with IterableMixin<int> implements Comparable { |
| IntRange(this.start, this.end); |
| |
| final int start; |
| final int end; |
| |
| @override |
| bool operator ==(other) => |
| other is IntRange && other.start == start && other.end == end; |
| |
| @override |
| int get hashCode => start + end * 40927 % 63703; |
| |
| @override |
| Iterator<int> get iterator => IntRangeIterator(start, end); |
| |
| @override |
| bool contains(Object i) => i as int >= start && i as int <= end; |
| |
| @override |
| int get length => end - start + 1; |
| |
| @override |
| int compareTo(dynamic other) { |
| IntRange o = other; // Throw TypeError if not an IntRange |
| return end == o.end ? start.compareTo(o.start) : end.compareTo(o.end); |
| } |
| |
| @override |
| String toString() => 'IntRange($start, $end)'; |
| } |
| |
| class Commit implements Comparable { |
| final String hash; |
| final String author; |
| final String title; |
| final DateTime created; |
| final int index; |
| |
| Commit(this.hash, this.author, this.title, this.created, this.index); |
| |
| Commit.fromDocument(firestore.DocumentSnapshot document) |
| : hash = document.id, |
| author = document.get('author'), |
| title = document.get('title'), |
| created = document.get('created'), |
| index = document.get('index'); |
| |
| /// Sort in reverse index order. |
| @override |
| int compareTo(other) => (other as Commit).index.compareTo(index); |
| |
| @override |
| String toString() => '$index $hash $author $created $title'; |
| } |
| |
| class LoadedResultsStatus { |
| bool loaded = true; |
| bool failuresOnly = false; |
| bool unapprovedOnly = false; |
| // If this is non-null, the ChangeGroup contains all results |
| // for this test only. |
| String singleTest; |
| } |
| |
| class ChangeGroup implements Comparable { |
| final IntRange range; |
| List<Commit> commits; |
| List<Comment> comments; |
| final Changes changes; |
| final Changes latestChanges; |
| Changes _filteredChanges; |
| Filter _filter = Filter.defaultFilter; |
| LoadedResultsStatus loadedResultsStatus; |
| |
| ChangeGroup(this.range, Map<int, Commit> allCommits, List<Comment> comments, |
| Iterable<Change> changeList, this.loadedResultsStatus) |
| : changes = Changes(changeList), |
| latestChanges = Changes.active(changeList) { |
| commits = [ |
| if (range != null) |
| for (int i in range) |
| if (allCommits[i] != null) allCommits[i] |
| ]..sort(); |
| this.comments = comments..sort(); |
| } |
| |
| /// Sort in reverse chronological order. |
| @override |
| int compareTo(other) => (other as ChangeGroup).range.compareTo(range); |
| |
| bool show(Filter filter) => filteredChanges(filter).isNotEmpty; |
| |
| Changes shownChanges(Filter filter) => |
| filter.showLatestFailures ? latestChanges : changes; |
| |
| Changes filteredChanges(Filter filter) => |
| (filter == _filter ? _filteredChanges : null) ?? |
| computeFilteredChanges(filter); |
| |
| Changes computeFilteredChanges(filter) { |
| _filteredChanges = shownChanges(filter).filtered(filter); |
| _filter = filter; |
| return _filteredChanges; |
| } |
| } |
| |
| /// A list of configurations affected by a result change. |
| /// The list is canonicalized, and configurations are grouped into |
| /// summaries of similar configurations. |
| class Configurations { |
| static final _instances = <String, Configurations>{}; |
| List<String> configurations; |
| String text; |
| Map<String, List<String>> summaries; |
| |
| Configurations._(List<dynamic> configurations) |
| : configurations = List<String>.from(configurations), |
| text = configurations.join(), |
| summaries = _summarize(configurations); |
| |
| factory Configurations(List configs) { |
| if (configs == null || configs.isEmpty) return null; |
| configs.sort(); |
| return _instances.putIfAbsent( |
| configs.join(' '), () => Configurations._(configs.toList())); |
| } |
| |
| static Map<String, List<String>> _summarize(List<dynamic> configurations) { |
| final groups = <String, List<String>>{}; |
| for (final String configuration in configurations) { |
| groups |
| .putIfAbsent(configuration.split('-').first, () => <String>[]) |
| .add(configuration); |
| } |
| // Sorts the values as a side effect, while changing their keys. |
| return SplayTreeMap.fromIterable(groups.values, |
| key: (list) => (list..sort).length == 1 |
| ? list.first |
| : list.first.split('-').first + '...'); |
| } |
| |
| bool show(Filter filter) => |
| filter.configurationGroups.isEmpty && filter.configurations.isEmpty || |
| configurations.any((configuration) => |
| filter.configurations.contains(configuration) || |
| filter.configurationGroups.any(configuration.startsWith)); |
| } |
| |
| class Change implements Comparable { |
| Change._( |
| this.id, |
| this.name, |
| this.configurations, |
| this.activeConfigurations, |
| this.result, |
| this.previousResult, |
| this.expected, |
| this.blamelistStartIndex, |
| this.blamelistEndIndex, |
| this.pinnedIndex, |
| this.review, |
| this.patchset, |
| this.approved, |
| this.active) |
| : changesText = '$previousResult -> $result (expected $expected)', |
| configurationsText = configurations.text; |
| |
| Change.fromDocument(firestore.DocumentSnapshot document) |
| : this._( |
| document.id, |
| document.get('name'), |
| Configurations(document.get('configurations')), |
| Configurations(document.get('active_configurations')), |
| document.get('result'), |
| document.get('previous_result'), |
| document.get('expected'), |
| document.get('blamelist_start_index'), |
| document.get('blamelist_end_index'), |
| document.get('pinned_index'), |
| document.get('review'), |
| document.get('patchset'), |
| // Old documents may not have this field. |
| document.get('approved') ?? false, |
| // Field is only present when true. |
| document.get('active') ?? false, |
| ); |
| static const skippedResult = 'skipped'; |
| static const oldFlakyResult = 'flaky'; |
| static const flakyResult = 'Flaky'; |
| |
| final String id; |
| final String name; |
| final Configurations configurations; |
| final Configurations activeConfigurations; |
| final String result; |
| final String previousResult; |
| final String expected; |
| final int blamelistStartIndex; |
| final int blamelistEndIndex; |
| int pinnedIndex; |
| final int review; |
| final int patchset; |
| bool approved; |
| bool active; |
| final String configurationsText; |
| final String changesText; |
| |
| @override |
| int compareTo(Object other) => name.compareTo((other as Change).name); |
| |
| bool get flaky => result == oldFlakyResult || result == flakyResult; |
| bool get skipped => result == skippedResult; |
| bool get success => result == expected; |
| bool get failed => !flaky && !skipped && !success; |
| String get resultStyle => flaky |
| ? 'flaky' |
| : skipped |
| ? 'skipped' |
| : success |
| ? 'success' |
| : 'failure'; |
| } |
| |
| class Changes with IterableMixin<List<List<Change>>> { |
| final List<List<List<Change>>> changes; |
| |
| Changes(Iterable<Change> newChanges) |
| : this.grouped(failuresFirst( |
| group(newChanges, (Change change) => change.configurations))); |
| |
| Changes.active(Iterable<Change> newChanges) |
| : this.grouped(failuresFirst(group( |
| newChanges.where((change) => change.active), |
| (Change change) => change.activeConfigurations))); |
| |
| Changes.grouped(this.changes); |
| |
| @override |
| Iterator<List<List<Change>>> get iterator => changes.iterator; |
| |
| Iterable<Change> get flat => changes.expand((l) => l.expand((l) => l)); |
| |
| /// The changes, grouped first by Configurations object, then by |
| /// changesText (the change in the results). Should not be modified |
| /// after it is created by this call. |
| static List<List<List<Change>>> group(Iterable<Change> newChanges, |
| Configurations Function(Change change) configuration) { |
| final map = SplayTreeMap<String, SplayTreeMap<String, List<Change>>>(); |
| for (final change in newChanges) { |
| map |
| .putIfAbsent(configuration(change).text, |
| () => SplayTreeMap<String, List<Change>>()) |
| .putIfAbsent(change.changesText, () => <Change>[]) |
| .add(change); |
| } |
| return [ |
| for (final configuration in map.keys) |
| [ |
| for (final change in map[configuration].keys) |
| map[configuration][change]..sort() |
| ] |
| ]; |
| } |
| |
| static List<List<List<Change>>> failuresFirst( |
| List<List<List<Change>>> unsorted) { |
| bool resultGroupIsFailure(List<Change> group) => group.first.failed; |
| bool resultGroupIsSuccess(List<Change> group) => !group.first.failed; |
| bool configurationGroupHasFailure(List<List<Change>> group) => |
| group.any(resultGroupIsFailure); |
| bool configurationGroupHasNoFailures(List<List<Change>> group) => |
| group.every(resultGroupIsSuccess); |
| |
| return [ |
| for (final configurationGroup in unsorted) |
| if (configurationGroupHasFailure(configurationGroup)) |
| [ |
| ...configurationGroup.where(resultGroupIsFailure), |
| ...configurationGroup.where(resultGroupIsSuccess), |
| ], |
| ...unsorted.where(configurationGroupHasNoFailures) |
| ]; |
| } |
| |
| static bool empty(x, f) => x.isEmpty; |
| |
| Changes filtered(Filter filter) { |
| final newChanges = |
| applyFilter<List<List<Change>>>(configurationFilter, changes, filter); |
| return newChanges == changes ? this : Changes.grouped(newChanges); |
| } |
| |
| /// Process a list by applying elementFilter to its elements, then throw |
| /// out empty or otherwise filtered elements with the emptyTest filter. |
| /// If no elements are modified or dropped, returns the input list object. |
| static List<T> applyFilter<T>( |
| T Function(T t, Filter f) elementFilter, List<T> list, Filter filter, |
| [bool Function(dynamic element, Filter f) emptyTest = empty]) { |
| final newList = <T>[]; |
| var changed = false; |
| for (final element in list) { |
| final newElement = elementFilter(element, filter); |
| if (emptyTest(newElement, filter)) { |
| changed = true; |
| } else { |
| newList.add(newElement); |
| changed = changed || newElement != element; |
| } |
| } |
| return changed ? newList : list; |
| } |
| |
| List<List<Change>> configurationFilter( |
| List<List<Change>> list, Filter filter) { |
| if (list.first.first.configurations.show(filter)) { |
| return applyFilter<List<Change>>(resultFilter, list, filter); |
| } |
| return []; |
| } |
| |
| List<Change> resultFilter(List<Change> list, Filter filter) { |
| if ((filter.showLatestFailures || filter.showUnapprovedOnly) && |
| !list.first.failed) { |
| return []; |
| } |
| return applyFilter<Change>((c, f) => c, list, filter, |
| (change, filter) => filter.showUnapprovedOnly && change.approved); |
| } |
| |
| List<String> listFlakyConfigurations({int threshold = 10}) { |
| final flakyConfigurations = <String, int>{}; |
| for (final configurationGroup in changes) { |
| for (final resultGroup in configurationGroup) { |
| final change = resultGroup.first; |
| if (change.flaky) { |
| for (final config in change.configurations.configurations) { |
| flakyConfigurations[config] = |
| (flakyConfigurations[config] ?? 0) + resultGroup.length; |
| } |
| } |
| } |
| } |
| return [ |
| for (final entry in flakyConfigurations.entries) |
| if (entry.value >= threshold) entry.key |
| ]; |
| } |
| } |
| |
| String githubNewIssueURL(Changes changes, String subject, String link) { |
| final failures = changes.flat.where((change) => change.failed).toList(); |
| failures.sort((a, b) => a.name.compareTo(b.name)); |
| final failuresLimit = 30; |
| final title = 'Failures on $subject'; |
| final body = failures.isEmpty |
| ? 'There are no new test failures on [$subject]($link).' |
| : [ |
| 'There are new test failures on [$subject]($link).', |
| '', |
| 'The tests', |
| '```', |
| for (final change in failures.take(failuresLimit)) |
| '${change.name} ${change.result} (expected ${change.expected})', |
| if (failures.length > failuresLimit) |
| ' and ${failures.length - failuresLimit} more tests', |
| '```', |
| 'are failing on configurations', |
| '```', |
| ...{ |
| for (final change in failures) |
| ...change.configurations.configurations |
| }.toList() |
| ..sort(), |
| '```' |
| ].join('\n'); |
| final query = {'title': title, 'body': body}; |
| return Uri.https('github.com', 'dart-lang/sdk/issues/new', query).toString(); |
| } |