blob: 69304e8c6a133606c58a393149b19e2ddb7165f3 [file] [log] [blame]
// Copyright 2019 The Flutter 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:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../logic/qualified_task.dart';
import '../logic/task_grid_filter.dart';
import '../model/commit.pb.dart';
import '../model/commit_status.pb.dart';
import '../model/task.pb.dart';
import '../state/build.dart';
import 'commit_box.dart';
import 'lattice.dart';
import 'task_box.dart';
import 'task_icon.dart';
import 'task_overlay.dart';
/// Container that manages the layout and data handling for [TaskGrid].
///
/// If there's no data for [TaskGrid], it shows [CircularProgressIndicator].
class TaskGridContainer extends StatelessWidget {
const TaskGridContainer({super.key, this.filter, this.useAnimatedLoading = false});
/// A notifier to hold a [TaskGridFilter] object to control the visibility of various
/// rows and columns of the task grid. This filter may be updated dynamically through
/// this notifier from elsewhere if the user starts editing the filter parameters in
/// the settings dialog.
final TaskGridFilter? filter;
final bool useAnimatedLoading;
@visibleForTesting
static const String errorFetchCommitStatus = 'An error occurred fetching commit statuses';
@visibleForTesting
static const String errorFetchTreeStatus = 'An error occurred fetching tree build status';
@visibleForTesting
static const Duration errorSnackbarDuration = Duration(seconds: 8);
@override
Widget build(BuildContext context) {
final BuildState buildState = Provider.of<BuildState>(context);
return AnimatedBuilder(
animation: buildState,
builder: (BuildContext context, Widget? child) {
final List<CommitStatus> commitStatuses = buildState.statuses;
// Assume if there is no data that it is loading.
if (commitStatuses.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return TaskGrid(
buildState: buildState,
commitStatuses: commitStatuses,
filter: filter,
useAnimatedLoading: useAnimatedLoading,
);
},
);
}
}
/// Display results from flutter/flutter repository's continuous integration.
///
/// Results are displayed in a matrix format. Rows are commits and columns
/// are the results from tasks.
class TaskGrid extends StatefulWidget {
const TaskGrid({
super.key,
// TODO(ianh): We really shouldn't take both of these, since buildState exposes status as well;
// it's asking for trouble because the tests can (and do) describe a mutually inconsistent state.
required this.buildState,
required this.commitStatuses,
this.filter,
this.useAnimatedLoading = false,
});
/// The build status data to display in the grid.
final List<CommitStatus> commitStatuses;
/// Reference to the build state to perform actions on [TaskMatrix], like rerunning tasks.
final BuildState buildState;
final bool useAnimatedLoading;
/// A [TaskGridFilter] object to control the visibility of various rows and columns of
/// the task grid. This filter may be updated dynamically from elsewhere if the user
/// starts editing the filter parameters in the settings dialog.
final TaskGridFilter? filter;
@override
State<TaskGrid> createState() => _TaskGridState();
}
/// Look up table for task status weights in the grid.
///
/// Weights should be in the range [0, 1.0] otherwise too much emphasis is placed on the first N rows, where N is the
/// largest integer weight.
const Map<String, double> _statusScores = <String, double>{
'Failed - Rerun': 1.0,
'Failed': 0.7,
'Infra Failure - Rerun': 0.69,
'Infra Failure': 0.68,
'Failed - Flaky': 0.67,
'Infra Failure - Flaky': 0.65,
'In Progress - Flaky': 0.64,
'New - Flaky': 0.63,
'Succeeded - Flaky': 0.61,
'New - Rerun': 0.5,
'In Progress - Rerun': 0.4,
'Unknown': 0.2,
'In Progress': 0.1,
'New': 0.1,
'Succeeded': 0.01,
'Skipped': 0.0,
};
class _TaskGridState extends State<TaskGrid> {
// TODO(ianh): Cache the lattice cells. Right now we are regenerating the entire
// lattice matrix each time the task grid has to update, regardless of whether
// we've received new data or not.
ScrollController? verticalController;
ScrollController? horizontalController;
@override
void initState() {
super.initState();
verticalController ??= ScrollController();
horizontalController ??= ScrollController();
widget.filter?.addListener(() {
setState(() {});
});
}
@override
void dispose() {
verticalController?.dispose();
horizontalController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LatticeScrollView(
// TODO(ianh): Provide some vertical scroll physics that disable
// the clamping in the vertical direction, so that you can keep
// scrolling past the end instead of hitting a wall every time
// we load.
// TODO(ianh): Trigger the loading from the scroll offset,
// rather than the current hack of loading during build.
cells: _processCommitStatuses(widget),
cellSize: const Size.square(TaskBox.cellSize),
verticalController: verticalController,
horizontalController: horizontalController,
);
}
/// This is the logic for turning the raw data from the [BuildState] object, a list of
/// [CommitStatus] objects, into the data that describes the rendering as used by the
/// [LatticeScrollView], a list of lists of [LatticeCell]s.
///
/// The process is as follows:
///
/// 1. We create `rows`, a list of [_Row] objects which are used to temporarily
/// represent each row in the data, where a row basically represents a [Commit].
///
/// These are derived from the `commitStatuses` directly -- each [CommitStatus] is one
/// row, representing one [Commit] and all its [Task]s.
///
/// 2. We walk the `commitStatuses` again, examining each [Task] of each [CommitStatus],
///
/// For the first 25 rows, we compute a score for each task, one commit at a time, so
/// that we'll be able to sort the tasks later. The score is based on [_statusScores]
/// (the map defined above). Each row is weighted in the score proportional to how
/// far from the first row it is, so the first row has a weight of 1.0, the second a
/// weight of 1/2, the third a weight of 1/3, etc.
///
/// Then, we update the `rows` list to contain a [LatticeCell] for this task on this
/// commit. The color of the square is derived from [_painterFor], the builder, if
/// any, is derived from [_builderFor], and the tap handler from [_tapHandlerFor].
///
/// 3. We create a list that represents all the tasks we've seen so far, sorted by
/// their score (tie-breaking on task names).
///
/// 4. Finally, we generate the output, by putting together all the data collected in
/// the second step, walking the tasks in the order determined in the third step.
//
// TODO(ianh): Find a way to save the majority of the work done each time we build the
// matrix. If you've scrolled down several thousand rows, you don't want to have to
// rebuild the entire matrix each time you load another 25 rows.
List<List<LatticeCell>> _processCommitStatuses(TaskGrid taskGrid) {
TaskGridFilter? filter = taskGrid.filter;
filter ??= TaskGridFilter();
// 1: PREPARE ROWS
final List<CommitStatus> filteredStatuses =
taskGrid.commitStatuses.where((CommitStatus commitStatus) => filter!.matchesCommit(commitStatus)).toList();
final List<_Row> rows =
filteredStatuses.map<_Row>((CommitStatus commitStatus) => _Row(commitStatus.commit)).toList();
// 2: WALK ALL TASKS
final Map<QualifiedTask, double> scores = <QualifiedTask, double>{};
final Map<QualifiedTask, Task> taskLookupMap = <QualifiedTask, Task>{};
int commitCount = 0;
for (final CommitStatus status in filteredStatuses) {
commitCount += 1;
for (final Task task in status.tasks) {
final QualifiedTask qualifiedTask = QualifiedTask.fromTask(task);
if (!filter.matchesTask(qualifiedTask)) {
continue;
}
taskLookupMap[qualifiedTask] = task;
if (commitCount <= 25) {
String weightStatus = task.status;
if (task.isFlaky || task.isTestFlaky) {
// Flaky tasks should be shown after failures and reruns as they take up infra capacity.
weightStatus += ' - Flaky';
} else if (task.attempts > 1) {
// Reruns take up extra infra capacity and should be prioritized.
weightStatus += ' - Rerun';
}
// Make the score relative to how long ago it was run.
final double score = _statusScores.containsKey(weightStatus)
? _statusScores[weightStatus]! / commitCount
: _statusScores['Unknown']! / commitCount;
scores.update(
qualifiedTask,
(double value) => value += score,
ifAbsent: () => score,
);
} else {
// In case we have a task that doesn't exist in the first 25 rows,
// we still push the task into the table of scores. Otherwise, we
// won't know how to sort the task later.
scores.putIfAbsent(
qualifiedTask,
() => 0.0,
);
}
rows[commitCount - 1].cells[qualifiedTask] = LatticeCell(
painter: _painterFor(task),
builder: _builderFor(task),
onTap: _tapHandlerFor(status.commit, task),
);
}
}
// 3: SORT
final List<QualifiedTask> tasks = scores.keys.toList()
..sort((QualifiedTask a, QualifiedTask b) {
final int scoreComparison = scores[b]!.compareTo(scores[a]!);
if (scoreComparison != 0) {
return scoreComparison;
}
// If the scores are identical, break ties on the name of the task.
// We do that because otherwise the sort order isn't stable.
if (a.stage != b.stage) {
return a.stage!.compareTo(b.stage!);
}
return a.task!.compareTo(b.task!);
});
// 4: GENERATE RESULTING LIST OF LISTS
return <List<LatticeCell>>[
<LatticeCell>[
const LatticeCell(),
...tasks.map<LatticeCell>(
(QualifiedTask task) =>
LatticeCell(builder: (BuildContext context) => TaskIcon(qualifiedTask: task), taskName: task.stage),
),
],
...rows.map<List<LatticeCell>>(
(_Row row) => <LatticeCell>[
LatticeCell(
builder: (BuildContext context) => CommitBox(commit: row.commit),
),
...tasks.map<LatticeCell>((QualifiedTask task) => row.cells[task] ?? const LatticeCell()),
],
),
if (widget.buildState.moreStatusesExist) _generateLoadingRow(tasks.length),
];
}
Painter _painterFor(Task task) {
final Paint backgroundPaint = Paint()..color = Theme.of(context).canvasColor;
final Paint paint = Paint()
..color = TaskBox.statusColor.containsKey(task.status) ? TaskBox.statusColor[task.status]! : Colors.black;
if (task.isFlaky) {
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 2.0;
return (Canvas canvas, Rect rect) {
canvas.drawRect(rect.deflate(6.0), paint);
};
}
return (Canvas canvas, Rect rect) {
canvas.drawRect(rect.deflate(2.0), paint);
if (task.attempts > 1 || task.isTestFlaky) {
canvas.drawCircle(rect.center, (rect.shortestSide / 2.0) - 6.0, backgroundPaint);
}
};
}
WidgetBuilder? _builderFor(Task task) {
if (task.attempts > 1 || task.isTestFlaky) {
return (BuildContext context) => const Padding(
padding: EdgeInsets.all(4.0),
child: Icon(Icons.priority_high),
);
}
return null;
}
static final List<String> _loadingMessage =
'LOADING...'.runes.map<String>((int codepoint) => String.fromCharCode(codepoint)).toList();
static const TextStyle loadingStyle = TextStyle(
fontSize: TaskBox.cellSize * 0.9,
fontWeight: FontWeight.w900,
);
List<LatticeCell> _generateLoadingRow(int length) {
return <LatticeCell>[
LatticeCell(
builder: (BuildContext context) {
return FittedBox(
fit: BoxFit.contain,
child: Padding(
padding: const EdgeInsets.all(12),
child: widget.useAnimatedLoading
? const RepaintBoundary(child: CircularProgressIndicator())
: const Icon(Icons.refresh),
),
);
},
),
for (int index = 0; index < max(length, _loadingMessage.length); index++)
LatticeCell(
builder: (BuildContext context) {
widget.buildState.fetchMoreCommitStatuses(); // This is safe to call many times.
return Text(
_loadingMessage[index % _loadingMessage.length],
style: loadingStyle,
textAlign: TextAlign.center,
);
},
)
];
}
OverlayEntry? _taskOverlay;
LatticeTapCallback _tapHandlerFor(Commit commit, Task task) {
return (Offset? localPosition) {
_taskOverlay?.remove();
_taskOverlay = OverlayEntry(
builder: (BuildContext context) => TaskOverlayEntry(
position: (this.context.findRenderObject() as RenderBox)
.localToGlobal(localPosition!, ancestor: Overlay.of(context).context.findRenderObject()),
task: task,
showSnackBarCallback: ScaffoldMessenger.of(context).showSnackBar,
closeCallback: _closeOverlay,
buildState: widget.buildState,
commit: commit,
),
);
Overlay.of(context).insert(_taskOverlay!);
};
}
void _closeOverlay() {
_taskOverlay!.remove();
_taskOverlay = null;
}
}
class _Row {
_Row(this.commit);
final Commit commit;
final Map<QualifiedTask, LatticeCell> cells = <QualifiedTask, LatticeCell>{};
}