| // 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:async'; |
| |
| import 'package:cocoon_common/core_extensions.dart'; |
| import 'package:cocoon_common/task_status.dart'; |
| import 'package:cocoon_server/logging.dart'; |
| import 'package:github/github.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import '../../cocoon_service.dart'; |
| import '../model/firestore/github_build_status.dart'; |
| import '../model/firestore/tree_status_change.dart'; |
| import 'build_status_provider/commit_tasks_status.dart'; |
| |
| /// Class that calculates the current build status. |
| interface class BuildStatusService { |
| const BuildStatusService({required FirestoreService firestore}) |
| : _firestore = firestore; |
| |
| final FirestoreService _firestore; |
| |
| @visibleForTesting |
| static const int numberOfCommitsToReferenceForTreeStatus = 20; |
| |
| /// Calculates and returns the "overall" status of the Flutter build. |
| /// |
| /// This calculation operates by looking for the most recent success or |
| /// failure for every (non-flaky) task in the manifest. |
| /// |
| /// Take the example build dashboard below: |
| /// ```txt |
| /// A B C D |
| /// 🧑💼 🟩 ⬜ 🟨 |
| /// 🧑💼 🟩 🟨 🟩 🟥 |
| /// 🧑💼 🟩 🟥 🟩 🟩 |
| /// ``` |
| /// |
| /// This build will fail because of Task `B` only: |
| /// |
| /// - Task `D` is not included in tip of tree (removed or marked `bringup`); |
| /// - Task `B` fails becuse its last known _completed_ status was failing |
| Future<BuildStatus> calculateCumulativeStatus( |
| RepositorySlug slug, { |
| required String branch, |
| }) async { |
| final commits = await retrieveCommitStatusFirestore( |
| limit: numberOfCommitsToReferenceForTreeStatus, |
| slug: slug, |
| branch: branch, |
| ); |
| if (commits.isEmpty) { |
| log.info('Tree status of failure for $slug: no commits found'); |
| return BuildStatus.failure(); |
| } |
| |
| // First, create a list of every ToT task we want to see non-failing. |
| final toBePassing = { |
| for (final t in commits.first.tasks) |
| if (!t.bringup) t.taskName, |
| }; |
| |
| final failingTasks = <String>{}; |
| final latestManualChange = await TreeStatusChange.getLatest( |
| _firestore, |
| repository: slug, |
| ); |
| log.debug('Latest manual closure on $slug: $latestManualChange'); |
| if (latestManualChange?.status == TreeStatus.failure) { |
| failingTasks.add( |
| 'Manual Closure: ${latestManualChange?.reason ?? 'Unspecified'}', |
| ); |
| } |
| |
| // Then, iterate through commit by commit. |
| // If we see a task fail, mark as failing. |
| // If we see a task pass, mark as passing and no longer look for it. |
| for (final commit in commits) { |
| for (final collatedTask in commit.collateTasksByTaskName()) { |
| if (!toBePassing.contains(collatedTask.task.taskName)) { |
| continue; |
| } |
| if (collatedTask.lastCompletedAttemptWasFailure) { |
| failingTasks.add(collatedTask.task.taskName); |
| } else if (collatedTask.task.status == TaskStatus.succeeded) { |
| toBePassing.remove(collatedTask.task.taskName); |
| } |
| } |
| } |
| |
| if (failingTasks.isEmpty) { |
| return BuildStatus.success(); |
| } else { |
| return BuildStatus.failure([...failingTasks]); |
| } |
| } |
| |
| /// Retrieves the comprehensive status of every task that runs per commit. |
| /// |
| /// The returned stream will be ordered by most recent commit first, then |
| /// the next newest, and so on. |
| Future<List<CommitTasksStatus>> retrieveCommitStatusFirestore({ |
| required RepositorySlug slug, |
| required int limit, |
| required String branch, |
| TimeRange? created, |
| }) async { |
| final commits = await _firestore.queryRecentCommits( |
| limit: limit, |
| created: created, |
| branch: branch, |
| slug: slug, |
| ); |
| return [ |
| for (final commit in commits) |
| // It's not obvious, but this is ordered by task creation time, descending. |
| CommitTasksStatus( |
| commit, |
| await _firestore.queryAllTasksForCommit(commitSha: commit.sha), |
| ), |
| ]; |
| } |
| } |
| |
| @immutable |
| final class BuildStatus { |
| const BuildStatus._(this.value, [this.failedTasks = const <String>[]]) |
| : assert( |
| value == GithubBuildStatus.statusSuccess || |
| value == GithubBuildStatus.statusFailure || |
| value == GithubBuildStatus.statusNeutral, |
| ); |
| factory BuildStatus.success() => |
| const BuildStatus._(GithubBuildStatus.statusSuccess); |
| factory BuildStatus.failure([List<String> failedTasks = const <String>[]]) => |
| BuildStatus._(GithubBuildStatus.statusFailure, failedTasks); |
| factory BuildStatus.neutral() => |
| const BuildStatus._(GithubBuildStatus.statusNeutral); |
| |
| final String value; |
| final List<String> failedTasks; |
| |
| bool get succeeded { |
| return value == GithubBuildStatus.statusSuccess; |
| } |
| |
| String get githubStatus => value; |
| |
| @override |
| int get hashCode { |
| var hash = 17; |
| hash = hash * 31 + value.hashCode; |
| hash = hash * 31 + failedTasks.hashCode; |
| return hash; |
| } |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(this, other)) { |
| return true; |
| } |
| if (other is BuildStatus) { |
| if (value != other.value) { |
| return false; |
| } |
| if (other.failedTasks.length != failedTasks.length) { |
| return false; |
| } |
| for (var i = 0; i < failedTasks.length; ++i) { |
| if (failedTasks[i] != other.failedTasks[i]) { |
| return false; |
| } |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| @override |
| String toString() => '$value $failedTasks'; |
| } |