blob: 0f79019d2e0a1ebdac1f09ddcfee461d7a0d4e01 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. 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:async';
import 'package:flutter/foundation.dart';
import 'package:cocoon_service/protos.dart'
show Commit, CommitStatus, RootKey, Task;
import '../service/cocoon.dart';
import '../service/google_authentication.dart';
/// State for the Flutter Build Dashboard
class FlutterBuildState extends ChangeNotifier {
/// Creates a new [FlutterBuildState].
///
/// If [CocoonService] is not specified, a new [CocoonService] instance is created.
FlutterBuildState({
CocoonService cocoonServiceValue,
GoogleSignInService authServiceValue,
}) : _cocoonService = cocoonServiceValue ?? CocoonService(),
authService = authServiceValue ?? GoogleSignInService() {
authService.notifyListeners = notifyListeners;
}
/// Cocoon backend service that retrieves the data needed for this state.
final CocoonService _cocoonService;
/// Authentication service for managing Google Sign In.
GoogleSignInService authService;
/// How often to query the Cocoon backend for the current build state.
@visibleForTesting
final Duration refreshRate = const Duration(seconds: 10);
/// Timer that calls [_fetchBuildStatusUpdate] on a set interval.
@visibleForTesting
Timer refreshTimer;
/// The current status of the commits loaded.
List<CommitStatus> _statuses = <CommitStatus>[];
List<CommitStatus> get statuses => _statuses;
/// Whether or not flutter/flutter currently passes tests.
bool _isTreeBuilding;
bool get isTreeBuilding => _isTreeBuilding;
/// A [ChangeNotifer] for knowing when errors occur that relate to this [FlutterBuildState].
FlutterBuildStateErrors errors = FlutterBuildStateErrors();
@visibleForTesting
static const String errorMessageFetchingStatuses =
'An error occured fetching build statuses from Cocoon';
@visibleForTesting
static const String errorMessageFetchingTreeStatus =
'An error occured fetching tree status from Cocoon';
/// Start a fixed interval loop that fetches build state updates based on [refreshRate].
Future<void> startFetchingBuildStateUpdates() async {
if (refreshTimer != null) {
// There's already an update loop, no need to make another.
return;
}
/// [Timer.periodic] does not necessarily run at the start of the timer.
_fetchBuildStatusUpdate();
refreshTimer =
Timer.periodic(refreshRate, (_) => _fetchBuildStatusUpdate());
}
/// Request the latest [statuses] and [isTreeBuilding] from [CocoonService].
Future<void> _fetchBuildStatusUpdate() async {
await Future.wait(<Future<void>>[
_cocoonService
.fetchCommitStatuses()
.then((CocoonResponse<List<CommitStatus>> response) {
if (response.error != null) {
print(response.error);
errors.message = errorMessageFetchingStatuses;
errors.notifyListeners();
} else {
_mergeRecentCommitStatusesWithStoredStatuses(response.data);
}
notifyListeners();
}),
_cocoonService
.fetchTreeBuildStatus()
.then((CocoonResponse<bool> response) {
if (response.error != null) {
print(response.error);
errors.message = errorMessageFetchingTreeStatus;
errors.notifyListeners();
} else {
_isTreeBuilding = response.data;
}
notifyListeners();
}),
]);
}
/// Handle merging status updates with the current data in [statuses].
///
/// [recentStatuses] is expected to be sorted from newest commit to oldest
/// commit. This is the same order as [statuses].
///
/// If the current list of statuses is empty, [recentStatuses] is set
/// to be the current [statuses].
///
/// Otherwise, follow this algorithm:
/// 1. Create a new [List<CommitStatus>] that is from [recentStatuses].
/// 2. Find where [recentStatuses] does not have [CommitStatus] that
/// [statuses] has. This is called the [lastKnownIndex].
/// 3. Append the range of [statuses] from ([lastKnownIndex] to the end of
/// statuses) to [recentStatuses]. This is the merged [statuses].
void _mergeRecentCommitStatusesWithStoredStatuses(
List<CommitStatus> recentStatuses,
) {
/// If the current statuses is empty, no merge logic is necessary.
/// This is used on the first call for statuses.
if (_statuses.isEmpty) {
_statuses = recentStatuses;
return;
}
assert(_statusesInOrder(recentStatuses));
final List<CommitStatus> mergedStatuses =
List<CommitStatus>.from(recentStatuses);
/// Bisect statuses to find the set that doesn't exist in [recentStatuses].
final CommitStatus lastRecentStatus = recentStatuses.last;
final int lastKnownIndex =
_findCommitStatusIndex(_statuses, lastRecentStatus);
/// If this assertion error occurs, the Cocoon backend needs to be updated
/// to return more commit statuses. This error will only occur if there
/// is a gap between [recentStatuses] and [statuses].
assert(lastKnownIndex != -1);
final int firstIndex = lastKnownIndex + 1;
final int lastIndex = _statuses.length;
/// If the current statuses has the same statuses as [recentStatuses],
/// there will be no subset of remaining statuses. Instead, it will give
/// a list with a null generated [CommitStatus]. Therefore we manually
/// return an empty list.
final List<CommitStatus> remainingStatuses = (firstIndex < lastIndex)
? _statuses.getRange(firstIndex, lastIndex).toList()
: <CommitStatus>[];
mergedStatuses.addAll(remainingStatuses);
_statuses = mergedStatuses;
assert(_statusesAreUnique(statuses));
}
/// Find the index in [statuses] that has [statusToFind] based on the key.
/// Return -1 if it does not exist.
///
/// The rest of the data in the [CommitStatus] can be different.
int _findCommitStatusIndex(
List<CommitStatus> statuses,
CommitStatus statusToFind,
) {
for (int index = 0; index < statuses.length; index++) {
final CommitStatus current = _statuses[index];
if (current.commit.key == statusToFind.commit.key) {
return index;
}
}
return -1;
}
/// When the user reaches the end of [statuses], we load more from Cocoon
/// to create an infinite scroll effect.
Future<void> fetchMoreCommitStatuses() async {
assert(_statuses.isNotEmpty);
final CocoonResponse<List<CommitStatus>> response = await _cocoonService
.fetchCommitStatuses(lastCommitStatus: _statuses.last);
if (response.error != null) {
print(response.error);
errors.message = errorMessageFetchingStatuses;
errors.notifyListeners();
return;
}
final List<CommitStatus> newStatuses = response.data;
assert(_statusesInOrder(newStatuses));
/// The [List<CommitStatus>] returned is the statuses that come at the end
/// of our current list and can just be appended.
_statuses.addAll(newStatuses);
notifyListeners();
assert(_statusesAreUnique(statuses));
}
Future<void> signIn() => authService.signIn();
Future<void> signOut() => authService.signOut();
Future<bool> rerunTask(Task task) async {
return _cocoonService.rerunTask(task, await authService.idToken);
}
Future<bool> downloadLog(Task task, Commit commit) async {
return _cocoonService.downloadLog(
task, await authService.idToken, commit.sha);
}
@override
void dispose() {
refreshTimer?.cancel();
super.dispose();
}
/// Assert that [statuses] is ordered from newest commit to oldest.
bool _statusesInOrder(List<CommitStatus> statuses) {
for (int i = 0; i < statuses.length - 1; i++) {
final Commit current = statuses[i].commit;
final Commit next = statuses[i + 1].commit;
if (current.timestamp < next.timestamp) {
return false;
}
}
return true;
}
/// Assert that there are no duplicate commits in [statuses].
bool _statusesAreUnique(List<CommitStatus> statuses) {
final Map<RootKey, bool> uniqueStatuses = <RootKey, bool>{};
for (int i = 0; i < statuses.length; i++) {
final Commit current = statuses[i].commit;
if (uniqueStatuses.containsKey(current.key)) {
return false;
} else {
uniqueStatuses[current.key] = true;
}
}
return true;
}
}
class FlutterBuildStateErrors extends ChangeNotifier {
String message;
}