diff --git a/app_dart/lib/src/model/common/presubmit_check_state.dart b/app_dart/lib/src/model/common/presubmit_check_state.dart new file mode 100644 index 0000000000..64da23b7da --- /dev/null +++ b/app_dart/lib/src/model/common/presubmit_check_state.dart @@ -0,0 +1,57 @@ +// Copyright 2025 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. + +/// @docImport 'presubmit_check_state.dart'; +library; + +import 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; + +import '../../service/luci_build_service/build_tags.dart'; +import '../bbv2_extension.dart'; + +/// Represents the current state of a check run. +class PresubmitCheckState { + final String buildName; + final TaskStatus status; + final int attemptNumber; //static int _currentAttempt(BuildTags buildTags) + final int? startTime; + final int? endTime; + final String? summary; + + const PresubmitCheckState({ + required this.buildName, + required this.status, + required this.attemptNumber, + this.startTime, + this.endTime, + this.summary, + }); + + /// Returns true if the build is waiting for backfill or in progress. + bool get isBuildInProgress => + status == TaskStatus.waitingForBackfill || + status == TaskStatus.inProgress; + + /// Returns true if the build succeeded or was skipped. + bool get isBuildSuccessed => + status == TaskStatus.succeeded || status == TaskStatus.skipped; + + /// Returns true if the build failed, had an infra failure, or was cancelled. + bool get isBuildFailed => + status == TaskStatus.failed || + status == TaskStatus.infraFailure || + status == TaskStatus.cancelled; +} + +extension BuildToPresubmitCheckState on bbv2.Build { + PresubmitCheckState toPresubmitCheckState() => PresubmitCheckState( + buildName: builder.builder, + status: status.toTaskStatus(), + attemptNumber: BuildTags.fromStringPairs(tags).currentAttempt, + startTime: startTime.toDateTime().microsecondsSinceEpoch, + endTime: endTime.toDateTime().microsecondsSinceEpoch, + summary: summaryMarkdown, + ); +} diff --git a/app_dart/lib/src/model/common/presubmit_guard_conclusion.dart b/app_dart/lib/src/model/common/presubmit_guard_conclusion.dart new file mode 100644 index 0000000000..fe14c82b35 --- /dev/null +++ b/app_dart/lib/src/model/common/presubmit_guard_conclusion.dart @@ -0,0 +1,81 @@ +// Copyright 2025 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. + +/// @docImport 'presubmit_guard_conclusion.dart'; +library; + +/// Explains what happened when attempting to mark the conclusion of a check run +/// using [PresubmitGuard.markConclusion]. +enum PresubmitGuardConclusionResult { + /// Check run update recorded successfully in the respective CI stage. + /// + /// It is OK to evaluate returned results for stage completeness. + ok, + + /// The check run is not in the specified CI stage. + /// + /// Perhaps it's from a different CI stage. + missing, + + /// An unexpected error happened, and the results of the conclusion are + /// undefined. + /// + /// Examples of situations that can lead to this result: + /// + /// * The Firestore document is missing. + /// * The contents of the Firestore document are inconsistent. + /// * An unexpected error happend while trying to read from/write to Firestore. + /// + /// When this happens, it's best to stop the current transaction, report the + /// error to the logs, and have someone investigate the issue. + internalError, +} + +/// Results from attempting to mark a staging task as completed. +/// +/// See: [PresubmitGuard.markConclusion] +class PresubmitGuardConclusion { + final PresubmitGuardConclusionResult result; + final int remaining; + final int? checkRunId; + final int failed; + final String summary; + final String details; + + const PresubmitGuardConclusion({ + required this.result, + required this.remaining, + required this.checkRunId, + required this.failed, + required this.summary, + required this.details, + }); + + bool get isOk => result == PresubmitGuardConclusionResult.ok; + + bool get isPending => isOk && remaining > 0; + + bool get isFailed => isOk && !isPending && failed > 0; + + bool get isComplete => isOk && !isPending && !isFailed; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PresubmitGuardConclusion && + other.result == result && + other.remaining == remaining && + other.checkRunId == checkRunId && + other.failed == failed && + other.summary == summary && + other.details == details); + + @override + int get hashCode => + Object.hashAll([result, remaining, checkRunId, failed, summary, details]); + + @override + String toString() => + 'BuildConclusion("$result", "$remaining", "$checkRunId", "$failed", "$summary", "$details")'; +} diff --git a/app_dart/lib/src/model/firestore/base.dart b/app_dart/lib/src/model/firestore/base.dart index d84275ee74..584fb6779f 100644 --- a/app_dart/lib/src/model/firestore/base.dart +++ b/app_dart/lib/src/model/firestore/base.dart @@ -7,6 +7,25 @@ import 'dart:convert'; import 'package:googleapis/firestore/v1.dart' as g; import 'package:meta/meta.dart'; +/// Well-defined stages in the build infrastructure. +enum CiStage implements Comparable { + /// Build engine artifacts + fusionEngineBuild('engine'), + + /// All non-engine artifact tests (engine & framework) + fusionTests('fusion'); + + const CiStage(this.name); + + final String name; + + @override + int compareTo(CiStage other) => index - other.index; + + @override + String toString() => name; +} + /// Defines the `documentId` for a given document [T]. /// /// The path to a document in Firestore follows the pattern: diff --git a/app_dart/lib/src/model/firestore/ci_staging.dart b/app_dart/lib/src/model/firestore/ci_staging.dart index e55fc52573..e607b60377 100644 --- a/app_dart/lib/src/model/firestore/ci_staging.dart +++ b/app_dart/lib/src/model/firestore/ci_staging.dart @@ -479,25 +479,6 @@ enum TaskConclusion { bool get isSuccess => this == success; } -/// Well-defined stages in the build infrastructure. -enum CiStage implements Comparable { - /// Build engine artifacts - fusionEngineBuild('engine'), - - /// All non-engine artifact tests (engine & framework) - fusionTests('fusion'); - - const CiStage(this.name); - - final String name; - - @override - int compareTo(CiStage other) => index - other.index; - - @override - String toString() => name; -} - /// Explains what happened when attempting to mark the conclusion of a check run /// using [CiStaging.markConclusion]. enum StagingConclusionResult { diff --git a/app_dart/lib/src/model/firestore/presubmit_check.dart b/app_dart/lib/src/model/firestore/presubmit_check.dart new file mode 100644 index 0000000000..80fa78b265 --- /dev/null +++ b/app_dart/lib/src/model/firestore/presubmit_check.dart @@ -0,0 +1,269 @@ +// Copyright 2025 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. + +/// @docImport 'presubmit_check.dart'; +library; + +import 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; +import 'package:googleapis/firestore/v1.dart' hide Status; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +import '../../service/firestore.dart'; +import '../bbv2_extension.dart'; +import 'base.dart'; + +const String collectionId = 'presubmit_checks'; + +@immutable +final class PresubmitCheckId extends AppDocumentId { + PresubmitCheckId({ + required this.checkRunId, + required this.buildName, + required this.attemptNumber, + }) { + if (checkRunId < 1) { + throw RangeError.value(checkRunId, 'checkRunId', 'Must be at least 1'); + } else if (attemptNumber < 1) { + throw RangeError.value( + attemptNumber, + 'attemptNumber', + 'Must be at least 1', + ); + } + } + + /// Parse the inverse of [PresubmitCheckId.documentName]. + factory PresubmitCheckId.parse(String documentName) { + final result = tryParse(documentName); + if (result == null) { + throw FormatException( + 'Unexpected firestore presubmit check document name: "$documentName"', + ); + } + return result; + } + + /// Tries to parse the inverse of [PresubmitCheckId.documentName]. + /// + /// If could not be parsed, returns `null`. + static PresubmitCheckId? tryParse(String documentName) { + if (_parseDocumentName.matchAsPrefix(documentName) case final match?) { + final checkRunId = int.tryParse(match.group(1)!); + final buildName = match.group(2)!; + final attemptNumber = int.tryParse(match.group(3)!); + if (checkRunId != null && attemptNumber != null) { + return PresubmitCheckId( + checkRunId: checkRunId, + buildName: buildName, + attemptNumber: attemptNumber, + ); + } + } + return null; + } + + /// Parses `{checkRunId}_{buildName}_{attemptNumber}`. + /// + /// [buildName] could also include underscores which led us to use regexp . + /// But we dont have build number at the moment of creating the document and + /// we need to query by checkRunId and buildName for updating the document. + static final _parseDocumentName = RegExp(r'([0-9]+)_(.*)_([0-9]+)$'); + + final int checkRunId; + final String buildName; + final int attemptNumber; + + @override + String get documentId { + return [checkRunId, buildName, attemptNumber].join('_'); + } + + @override + AppDocumentMetadata get runtimeMetadata => + PresubmitCheck.metadata; +} + +final class PresubmitCheck extends AppDocument { + static const fieldCheckRunId = 'checkRunId'; + static const fieldBuildName = 'buildName'; + static const fieldBuildNumber = 'buildNumber'; + static const fieldStatus = 'status'; + static const fieldAttemptNumber = 'attemptNumber'; + static const fieldCreationTime = 'creationTime'; + static const fieldStartTime = 'startTime'; + static const fieldEndTime = 'endTime'; + static const fieldSummary = 'summary'; + + static AppDocumentId documentIdFor({ + required int checkRunId, + required String buildName, + required int attemptNumber, + }) { + return PresubmitCheckId( + checkRunId: checkRunId, + buildName: buildName, + attemptNumber: attemptNumber, + ); + } + + /// Returns a firebase documentName used in [fromFirestore]. + static String documentNameFor({ + required int checkRunId, + required String buildName, + required int attemptNumber, + }) { + // Document names cannot cannot have '/' in the document id. + final docId = documentIdFor( + checkRunId: checkRunId, + buildName: buildName, + attemptNumber: attemptNumber, + ); + return '$kDocumentParent/$collectionId/${docId.documentId}'; + } + + @override + AppDocumentMetadata get runtimeMetadata => metadata; + + static final metadata = AppDocumentMetadata( + collectionId: collectionId, + fromDocument: PresubmitCheck.fromDocument, + ); + + static Future fromFirestore( + FirestoreService firestoreService, + AppDocumentId id, + ) async { + final document = await firestoreService.getDocument( + p.posix.join(kDatabase, 'documents', collectionId, id.documentId), + ); + return PresubmitCheck.fromDocument(document); + } + + factory PresubmitCheck({ + required int checkRunId, + required String buildName, + required TaskStatus status, + required int attemptNumber, + required int creationTime, + int? buildNumber, + int? startTime, + int? endTime, + String? summary, + }) { + return PresubmitCheck._( + { + fieldCheckRunId: checkRunId.toValue(), + fieldBuildName: buildName.toValue(), + fieldBuildNumber: ?buildNumber?.toValue(), + fieldStatus: status.value.toValue(), + fieldAttemptNumber: attemptNumber.toValue(), + fieldCreationTime: creationTime.toValue(), + fieldStartTime: ?startTime?.toValue(), + fieldEndTime: ?endTime?.toValue(), + fieldSummary: ?summary?.toValue(), + }, + name: documentNameFor( + checkRunId: checkRunId, + buildName: buildName, + attemptNumber: attemptNumber, + ), + ); + } + + factory PresubmitCheck.fromDocument(Document document) { + return PresubmitCheck._(document.fields!, name: document.name!); + } + + factory PresubmitCheck.init({ + required String buildName, + required int checkRunId, + required int creationTime, + }) { + return PresubmitCheck( + buildName: buildName, + attemptNumber: 1, + checkRunId: checkRunId, + creationTime: creationTime, + status: TaskStatus.waitingForBackfill, + buildNumber: null, + startTime: null, + endTime: null, + summary: null, + ); + } + + PresubmitCheck._(Map fields, {required String name}) { + this + ..fields = fields + ..name = name; + } + + int get checkRunId => int.parse(fields[fieldCheckRunId]!.integerValue!); + String get buildName => fields[fieldBuildName]!.stringValue!; + int get attemptNumber => int.parse(fields[fieldAttemptNumber]!.integerValue!); + int get creationTime => int.parse(fields[fieldCreationTime]!.integerValue!); + int? get buildNumber => fields[fieldBuildNumber] != null + ? int.parse(fields[fieldBuildNumber]!.integerValue!) + : null; + int? get startTime => fields[fieldStartTime] != null + ? int.parse(fields[fieldStartTime]!.integerValue!) + : null; + int? get endTime => fields[fieldEndTime] != null + ? int.parse(fields[fieldEndTime]!.integerValue!) + : null; + String? get summary => fields[fieldSummary]?.stringValue; + + TaskStatus get status { + final rawValue = fields[fieldStatus]!.stringValue!; + return TaskStatus.from(rawValue); + } + + set status(TaskStatus status) { + fields[fieldStatus] = status.value.toValue(); + } + + set startTime(int startTime) { + fields[fieldStartTime] = startTime.toValue(); + } + + set endTime(int endTime) { + fields[fieldEndTime] = endTime.toValue(); + } + + set summary(String summary) { + fields[fieldSummary] = summary.toValue(); + } + + void updateFromBuild(bbv2.Build build) { + fields[fieldBuildNumber] = build.number.toValue(); + fields[fieldCreationTime] = build.createTime + .toDateTime() + .millisecondsSinceEpoch + .toValue(); + + if (build.hasStartTime()) { + fields[fieldStartTime] = build.startTime + .toDateTime() + .millisecondsSinceEpoch + .toValue(); + } + + if (build.hasEndTime()) { + fields[fieldEndTime] = build.endTime + .toDateTime() + .millisecondsSinceEpoch + .toValue(); + } + _setStatusFromLuciStatus(build); + } + + void _setStatusFromLuciStatus(bbv2.Build build) { + if (status.isComplete) { + return; + } + status = build.status.toTaskStatus(); + } +} diff --git a/app_dart/lib/src/model/firestore/presubmit_guard.dart b/app_dart/lib/src/model/firestore/presubmit_guard.dart new file mode 100644 index 0000000000..c94cae0426 --- /dev/null +++ b/app_dart/lib/src/model/firestore/presubmit_guard.dart @@ -0,0 +1,234 @@ +// Copyright 2025 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. + +/// @docImport 'presubmit_guard.dart'; +library; + +import 'dart:convert'; + +import 'package:cocoon_common/task_status.dart'; +import 'package:github/github.dart'; +import 'package:googleapis/firestore/v1.dart' hide Status; +import 'package:path/path.dart' as p; + +import '../../../cocoon_service.dart'; +import '../../service/firestore.dart'; +import 'base.dart'; + +final class PresubmitGuardId extends AppDocumentId { + PresubmitGuardId({ + required this.slug, + required this.pullRequestId, + required this.checkRunId, + required this.stage, + }); + + /// The repository owner/name. + final RepositorySlug slug; + + /// The pull request id. + final int pullRequestId; + + /// The Check Run Id. + final int checkRunId; + + /// The stage of the CI process. + final CiStage stage; + + @override + String get documentId => + [slug.owner, slug.name, pullRequestId, checkRunId, stage].join('_'); + + @override + AppDocumentMetadata get runtimeMetadata => + PresubmitGuard.metadata; +} + +final class PresubmitGuard extends AppDocument { + static const collectionId = 'presubmit_guards'; + static const fieldCheckRun = 'check_run'; + static const fieldCommitSha = 'commit_sha'; + static const fieldAuthor = 'author'; + static const fieldCreationTime = 'creation_time'; + static const fieldRemainingBuilds = 'remaining_builds'; + static const fieldFailedBuilds = 'failed_builds'; + static const fieldBuilds = 'builds'; + + static AppDocumentId documentIdFor({ + required RepositorySlug slug, + required int pullRequestId, + required int checkRunId, + required CiStage stage, + }) => PresubmitGuardId( + slug: slug, + pullRequestId: pullRequestId, + checkRunId: checkRunId, + stage: stage, + ); + + /// Returns a firebase documentName used in [fromFirestore]. + static String documentNameFor({ + required RepositorySlug slug, + required int pullRequestId, + required int checkRunId, + required CiStage stage, + }) { + // Document names cannot cannot have '/' in the document id. + final docId = documentIdFor( + slug: slug, + pullRequestId: pullRequestId, + checkRunId: checkRunId, + stage: stage, + ); + return '$kDocumentParent/$collectionId/${docId.documentId}'; + } + + /// Returns the document ID for the given parameters. + // static String documentId({ + // required RepositorySlug slug, + // required int pullRequestId, + // required int checkRunId, + // required CiStage stage, + // }) => + // '${slug.owner}_${slug.name}_${pullRequestId}_${checkRunId}_${stage.name}'; + + @override + AppDocumentMetadata get runtimeMetadata => metadata; + + static final metadata = AppDocumentMetadata( + collectionId: collectionId, + fromDocument: PresubmitGuard.fromDocument, + ); + + factory PresubmitGuard.init({ + required RepositorySlug slug, + required int pullRequestId, + required CheckRun checkRun, + required CiStage stage, + required String commitSha, + required int creationTime, + required String author, + required int buildCount, + }) { + return PresubmitGuard( + checkRun: checkRun, + commitSha: commitSha, + slug: slug, + pullRequestId: pullRequestId, + stage: stage, + author: author, + creationTime: creationTime, + remainingBuilds: buildCount, + failedBuilds: 0, + ); + } + + factory PresubmitGuard.fromDocument(Document document) { + return PresubmitGuard._(document.fields!, name: document.name!); + } + + factory PresubmitGuard({ + required CheckRun checkRun, + required String commitSha, + required RepositorySlug slug, + required int pullRequestId, + required CiStage stage, + required int creationTime, + required String author, + int? remainingBuilds, + int? failedBuilds, + Map? builds, + }) { + return PresubmitGuard._( + { + fieldCommitSha: commitSha.toValue(), + fieldCreationTime: creationTime.toValue(), + fieldAuthor: author.toValue(), + fieldCheckRun: json.encode(checkRun.toJson()).toValue(), + fieldRemainingBuilds: ?remainingBuilds?.toValue(), + fieldFailedBuilds: ?failedBuilds?.toValue(), + if (builds != null) + fieldBuilds: Value( + mapValue: MapValue( + fields: builds.map((k, v) => MapEntry(k, v.value.toValue())), + ), + ), + }, + name: documentNameFor( + slug: slug, + pullRequestId: pullRequestId, + checkRunId: checkRun.id!, + stage: stage, + ), + ); + } + + PresubmitGuard._(Map fields, {required String name}) { + this.fields = fields; + this.name = name; + } + + String get commitSha => fields[fieldCommitSha]!.stringValue!; + String get author => fields[fieldAuthor]!.stringValue!; + int get creationTime => int.parse(fields[fieldCreationTime]!.integerValue!); + int? get remainingBuilds => fields[fieldRemainingBuilds] != null + ? int.parse(fields[fieldRemainingBuilds]!.integerValue!) + : null; + int? get failedBuilds => fields[fieldFailedBuilds] != null + ? int.parse(fields[fieldFailedBuilds]!.integerValue!) + : null; + Map? get builds => + fields[fieldBuilds]?.mapValue?.fields?.map( + (k, v) => MapEntry(k, TaskStatus.from(v.stringValue!)), + ); + CheckRun get checkRun { + final jsonData = + jsonDecode(fields[fieldCheckRun]!.stringValue!) as Map; + return CheckRun.fromJson(jsonData); + } + + /// The repository that this stage is recorded for. + RepositorySlug get slug { + // Read it from the document name. + final [owner, repo, _, _, _] = p.posix.basename(name!).split('_'); + return RepositorySlug(owner, repo); + } + + /// The pull request for which this stage is recorded for. + int get pullRequestId { + // Read it from the document name. + final [_, _, pullRequestId, _, _] = p.posix.basename(name!).split('_'); + return int.parse(pullRequestId); + } + + /// Which commit this stage is recorded for. + int get checkRunId { + // Read it from the document name. + final [_, _, _, checkRunId, _] = p.posix.basename(name!).split('_'); + return int.parse(checkRunId); + } + + /// The stage of the CI process. + CiStage get stage { + // Read it from the document name. + final [_, _, _, _, stageName] = p.posix.basename(name!).split('_'); + return CiStage.values.firstWhere((e) => e.name == stageName); + } + + set remainingBuilds(int remainingBuilds) { + fields[fieldRemainingBuilds] = remainingBuilds.toValue(); + } + + set failedBuilds(int failedBuilds) { + fields[fieldFailedBuilds] = failedBuilds.toValue(); + } + + set builds(Map builds) { + fields[fieldBuilds] = Value( + mapValue: MapValue( + fields: builds.map((k, v) => MapEntry(k, v.value.toValue())), + ), + ); + } +} diff --git a/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart b/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart index b8a8cb81f3..b7c5cac4e9 100644 --- a/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart +++ b/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:googleapis/firestore/v1.dart'; import '../../cocoon_service.dart'; +import '../model/firestore/base.dart'; import '../model/firestore/ci_staging.dart'; import '../request_handling/exceptions.dart'; diff --git a/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart b/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart index 42210ce9c6..941b95f920 100644 --- a/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart +++ b/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart @@ -158,10 +158,7 @@ final class PresubmitLuciSubscription extends SubscriptionHandler { /// It returns 1 if this is the first run. static int _nextAttempt(BuildTags buildTags) { final attempt = buildTags.getTagOfType(); - if (attempt == null) { - return 1; - } - return attempt.attemptNumber; + return attempt?.attemptNumber ?? 1; } Future _getMaxAttempt( diff --git a/app_dart/lib/src/service/firestore/unified_check_run.dart b/app_dart/lib/src/service/firestore/unified_check_run.dart new file mode 100644 index 0000000000..981febb6db --- /dev/null +++ b/app_dart/lib/src/service/firestore/unified_check_run.dart @@ -0,0 +1,393 @@ +// Copyright 2025 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. + +/// @docImport 'unified_check_run.dart'; +library; + +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_server/logging.dart'; +import 'package:collection/collection.dart'; +import 'package:github/github.dart'; +import 'package:googleapis/firestore/v1.dart' hide Status; + +import '../../model/common/presubmit_check_state.dart'; +import '../../model/common/presubmit_guard_conclusion.dart'; +import '../../model/firestore/base.dart'; +import '../../model/firestore/ci_staging.dart'; +import '../../model/firestore/presubmit_check.dart'; +import '../../model/firestore/presubmit_guard.dart'; +import '../config.dart'; +import '../firestore.dart'; + +final class UnifiedCheckRun { + static Future initializeCiStagingDocument({ + required FirestoreService firestoreService, + required RepositorySlug slug, + required String sha, + required CiStage stage, + required List tasks, + required Config config, + PullRequest? pullRequest, + CheckRun? checkRun, + }) async { + if (checkRun != null && + pullRequest != null && + config.flags.isUnifiedCheckRunFlowEnabledForUser( + pullRequest.user!.login!, + )) { + log.info( + 'Storing UnifiedCheckRun data for ${slug.fullName}#${pullRequest.number} as it enabled for user ${pullRequest.user!.login}.', + ); + // Create the UnifiedCheckRun and UnifiedCheckRunBuilds. + final guard = PresubmitGuard( + checkRun: checkRun, + commitSha: sha, + slug: slug, + pullRequestId: pullRequest.number!, + stage: stage, + creationTime: pullRequest.createdAt!.microsecondsSinceEpoch, + author: pullRequest.user!.login!, + remainingBuilds: null, + failedBuilds: null, + builds: {for (final task in tasks) task: TaskStatus.waitingForBackfill}, + ); + final checks = [ + for (final task in tasks) + PresubmitCheck.init( + buildName: task, + checkRunId: checkRun.id!, + creationTime: pullRequest.createdAt!.microsecondsSinceEpoch, + ), + ]; + await firestoreService.writeViaTransaction( + documentsToWrites([...checks, guard], exists: false), + ); + } else { + // Initialize the CiStaging document. + await CiStaging.initializeDocument( + firestoreService: firestoreService, + slug: slug, + sha: sha, + stage: stage, + tasks: tasks, + checkRunGuard: checkRun != null ? '$checkRun' : '', + ); + } + } + + /// Initializes a new document for the given [tasks] in Firestore so that stage-tracking can succeed. + /// + /// The list of tasks will be written as fields of a document with additional fields for tracking the creationTime + /// number of tasks, remaining count. It is required to include [commitSha] as a json encoded [CheckRun] as this + /// will be used to unlock any check runs blocking progress. + /// + /// Returns the created document or throws an error. + static Future initializePresubmitGuardDocument({ + required FirestoreService firestoreService, + + required RepositorySlug slug, + required int pullRequestId, + required CheckRun checkRun, + required CiStage stage, + required String commitSha, + required int creationTime, + required String author, + required List tasks, + }) async { + final buildCount = tasks.length; + final logCrumb = + 'initializeDocument(${slug.owner}_${slug.name}_${pullRequestId}_${checkRun.id}_$stage, $buildCount builds)'; + + final presubmitGuard = PresubmitGuard( + checkRun: checkRun, + commitSha: commitSha, + slug: slug, + pullRequestId: pullRequestId, + stage: stage, + creationTime: creationTime, + author: author, + remainingBuilds: buildCount, + failedBuilds: 0, + builds: {for (final task in tasks) task: TaskStatus.waitingForBackfill}, + ); + + final presubmitGuardId = PresubmitGuard.documentIdFor( + slug: slug, + pullRequestId: pullRequestId, + checkRunId: checkRun.id!, + stage: stage, + ); + + try { + // Calling createDocument multiple times for the same documentId will return a 409 - ALREADY_EXISTS error; + // this is good because it means we don't have to do any transactions. + // curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer " "https://firestore.googleapis.com/v1beta1/projects/flutter-dashboard/databases/cocoon/documents/presubmit_guard?documentId=foo_bar_baz" -d '{"fields": {"test": {"stringValue": "baz"}}}' + final newDoc = await firestoreService.createDocument( + presubmitGuard, + collectionId: presubmitGuard.runtimeMetadata.collectionId, + documentId: presubmitGuardId.documentId, + ); + log.info('$logCrumb: document created'); + return newDoc; + } catch (e) { + log.warn('$logCrumb: failed to create document', e); + rethrow; + } + } + + /// Returns _all_ checks running against the specified github [checkRunId]. + static Future> queryAllPresubmitChecksForGuard({ + required FirestoreService firestoreService, + required int checkRunId, + TaskStatus? status, + String? buildName, + Transaction? transaction, + }) async { + return await _queryPresubmitChecks( + firestoreService: firestoreService, + checkRunId: checkRunId, + buildName: buildName, + status: status, + transaction: transaction, + ); + } + + /// Returns check for the specified github [checkRunId] and + /// [buildName] and [attemptNumber]. + static Future queryPresubmitCheck({ + required FirestoreService firestoreService, + required int checkRunId, + required String buildName, + required int attemptNumber, + Transaction? transaction, + }) async { + return (await _queryPresubmitChecks( + firestoreService: firestoreService, + checkRunId: checkRunId, + buildName: buildName, + status: null, + attemptNumber: attemptNumber, + transaction: transaction, + )).firstOrNull; + } + + static Future> _queryPresubmitChecks({ + required FirestoreService firestoreService, + required int checkRunId, + String? buildName, + TaskStatus? status, + Transaction? transaction, + int? attemptNumber, + }) async { + final filterMap = { + '${PresubmitCheck.fieldCheckRunId} =': checkRunId, + '${PresubmitCheck.fieldBuildName} =': ?buildName, + '${PresubmitCheck.fieldStatus} =': ?status?.value, + '${PresubmitCheck.fieldAttemptNumber} =': ?attemptNumber, + }; + // For tasks, therer is no reason to _not_ order this way. + final orderMap = {PresubmitCheck.fieldCreationTime: kQueryOrderDescending}; + final documents = await firestoreService.query( + collectionId, + filterMap, + orderMap: orderMap, + transaction: transaction, + ); + return [...documents.map(PresubmitCheck.fromDocument)]; + } + + /// Mark a [buildName] for a given [stage] with [conclusion]. + /// + /// Returns a [PresubmitGuardConclusion] record or throws. If the check_run was + /// both valid and recorded successfully, the record's `remaining` value + /// signals how many more tests are running. Returns the record (valid: false) + /// otherwise. + static Future markConclusion({ + required FirestoreService firestoreService, + required PresubmitGuardId guardId, + required PresubmitCheckState state, + }) async { + final changeCrumb = + '${guardId.slug.owner}_${guardId.slug.name}_${guardId.pullRequestId}_${guardId.checkRunId}'; + final logCrumb = + 'markConclusion(${changeCrumb}_${guardId.stage}, ${state.buildName}, ${state.status}, ${state.attemptNumber})'; + + // Marking needs to happen while in a transaction to ensure `remaining` is + // updated correctly. For that to happen correctly; we need to perform a + // read of the document in the transaction as well. So start the transaction + // first thing. + final transaction = await firestoreService.beginTransaction(); + + var remaining = -1; + var failed = -1; + var valid = false; + + late final PresubmitGuard presubmitGuard; + late final PresubmitCheck presubmitCheck; + // transaction block + try { + // First: read the fields we want to change. + final presubmitGuardDocumentName = PresubmitGuard.documentNameFor( + slug: guardId.slug, + pullRequestId: guardId.pullRequestId, + checkRunId: guardId.checkRunId, + stage: guardId.stage, + ); + final presubmitGuardDocument = await firestoreService.getDocument( + presubmitGuardDocumentName, + transaction: transaction, + ); + presubmitGuard = PresubmitGuard.fromDocument(presubmitGuardDocument); + + // Check if the build is present in the guard before trying to load it. + if (presubmitGuard.builds?[state.buildName] == null) { + log.info( + '$logCrumb: ${state.buildName} with attemptNumber ${state.attemptNumber} not present for $transaction / ${presubmitGuardDocument.fields}', + ); + await firestoreService.rollback(transaction); + return PresubmitGuardConclusion( + result: PresubmitGuardConclusionResult.missing, + remaining: presubmitGuard.remainingBuilds ?? -1, + checkRunId: guardId.checkRunId, + failed: presubmitGuard.failedBuilds ?? -1, + summary: + 'Check run "${state.buildName}" not present in ${guardId.stage} CI stage', + details: 'Change $changeCrumb', + ); + } + + final checkDocName = PresubmitCheck.documentNameFor( + checkRunId: guardId.checkRunId, + buildName: state.buildName, + attemptNumber: state.attemptNumber, + ); + final presubmitCheckDocument = await firestoreService.getDocument( + checkDocName, + transaction: transaction, + ); + presubmitCheck = PresubmitCheck.fromDocument(presubmitCheckDocument); + + remaining = presubmitGuard.remainingBuilds!; + failed = presubmitGuard.failedBuilds!; + final builds = presubmitGuard.builds; + + // If build is in progress, we should only update appropriate checks with + // that [TaskStatus] + if (state.isBuildInProgress) { + presubmitCheck.startTime = state.startTime!; + } else { + // "remaining" should go down if buildSuccessed of any attempt + // or buildFailed a first attempt. + // "failed_count" can go up or down depending on: + // attemptNumber > 1 && buildSuccessed: down (-1) + // attemptNumber = 1 && buildFailed: up (+1) + // So if the test existed and either remaining or failed_count is changed; + // the response is valid. + + if (state.isBuildSuccessed || + state.attemptNumber == 1 && state.isBuildFailed) { + // Guard against going negative and log enough info so we can debug. + if (remaining == 0) { + throw '$logCrumb: field "${PresubmitGuard.fieldRemainingBuilds}" is already zero for $transaction / ${presubmitGuardDocument.fields}'; + } + remaining = remaining - 1; + valid = true; + } + + // Only rollback the "failed" counter if this is a successful test run, + // i.e. the test failed, the user requested a rerun, and now it passes. + if (state.attemptNumber > 1 && state.isBuildSuccessed) { + log.info( + '$logCrumb: conclusion flipped to positive - assuming test was re-run', + ); + if (failed == 0) { + throw '$logCrumb: field "${PresubmitGuard.fieldFailedBuilds}" is already zero for $transaction / ${presubmitGuardDocument.fields}'; + } + valid = true; + failed = failed - 1; + } + + // Only increment the "failed" counter if the conclusion failed for the first attempt. + if (state.attemptNumber == 1 && state.isBuildFailed) { + log.info('$logCrumb: test failed'); + valid = true; + failed = failed + 1; + } + + // All checks pass. "valid" is only set to true if there was a change in either the remaining or failed count. + log.info( + '$logCrumb: setting remaining to $remaining, failed to $failed', + ); + presubmitGuard.remainingBuilds = remaining; + presubmitGuard.failedBuilds = failed; + presubmitCheck.endTime = state.endTime!; + } + builds![state.buildName] = state.status; + presubmitGuard.builds = builds; + presubmitCheck.status = state.status; + } on DetailedApiRequestError catch (e, stack) { + if (e.status == 404) { + // An attempt to read a document not in firestore should not be retried. + log.info( + '$logCrumb: $collectionId document not found for $transaction', + ); + await firestoreService.rollback(transaction); + return PresubmitGuardConclusion( + result: PresubmitGuardConclusionResult.internalError, + remaining: -1, + checkRunId: null, + failed: failed, + summary: 'Internal server error', + details: + ''' +$collectionId document not found for stage "${guardId.stage}" for $changeCrumb. Got 404 from Firestore. +Error: ${e.toString()} +$stack +''', + ); + } + // All other errors should bubble up and be retried. + await firestoreService.rollback(transaction); + rethrow; + } catch (e) { + // All other errors should bubble up and be retried. + await firestoreService.rollback(transaction); + rethrow; + } + // Commit this write firebase and if no one else was writing at the same time, return success. + // If this commit fails, that means someone else modified firestore and the caller should try again. + // We do not need to rollback the transaction; firebase documentation says a failed commit takes care of that. + try { + final response = await firestoreService.commit( + transaction, + documentsToWrites([presubmitGuard, presubmitCheck], exists: true), + ); + log.info( + '$logCrumb: results = ${response.writeResults?.map((e) => e.toJson())}', + ); + return PresubmitGuardConclusion( + result: valid + ? PresubmitGuardConclusionResult.ok + : PresubmitGuardConclusionResult.internalError, + remaining: remaining, + checkRunId: guardId.checkRunId, + failed: failed, + summary: valid + ? 'Successfully updated presubmit guard status' + : 'Not a valid state transition for ${state.buildName}', + details: valid + ? ''' +For CI stage ${guardId.stage}: + Pending: $remaining + Failed: $failed +''' + : 'Attempted to set the state of check run ${state.buildName} ' + 'to "${state.status.name}".', + ); + } catch (e) { + log.info('$logCrumb: failed to update presubmit check', e); + rethrow; + } + } +} diff --git a/app_dart/lib/src/service/luci_build_service/build_tags.dart b/app_dart/lib/src/service/luci_build_service/build_tags.dart index 93c41d2645..e472f6544b 100644 --- a/app_dart/lib/src/service/luci_build_service/build_tags.dart +++ b/app_dart/lib/src/service/luci_build_service/build_tags.dart @@ -75,6 +75,20 @@ final class BuildTags { List toStringPairs() { return buildTags.map((e) => e.toStringPair()).toList(); } + + /// the current reschedule attempt. + /// + /// It returns 1 if this is the first run. + int get currentAttempt { + final attempt = getTagOfType(); + return attempt?.attemptNumber ?? 1; + } + + /// GitHub Pull Request Number + int get pullRequestNumber { + final prTag = getTagOfType(); + return prTag!.pullRequestNumber; + } } /// Valid tags for [bbv2.ScheduleBuildRequest.tags]. @@ -320,6 +334,16 @@ final class GitHubCheckRunIdBuildTag extends BuildTag { final int checkRunId; } +/// A link back to the GitHub checkRun for this build. +final class GuardCheckRunIdBuildTag extends BuildTag { + static const _keyName = 'guard_checkrun'; + GuardCheckRunIdBuildTag({required this.checkRunId}) + : super(_keyName, '$checkRunId'); + + /// ID of the checkRun. + final int checkRunId; +} + /// A build tag that specifies the ID of the scheduling job. /// /// For Flutter, this is always `flutter/{Build Target}`. diff --git a/app_dart/lib/src/service/scheduler.dart b/app_dart/lib/src/service/scheduler.dart index 881a7d1da4..9acbb720af 100644 --- a/app_dart/lib/src/service/scheduler.dart +++ b/app_dart/lib/src/service/scheduler.dart @@ -20,6 +20,7 @@ import '../foundation/utils.dart'; import '../model/ci_yaml/ci_yaml.dart'; import '../model/ci_yaml/target.dart'; import '../model/commit_ref.dart'; +import '../model/firestore/base.dart'; import '../model/firestore/ci_staging.dart'; import '../model/firestore/commit.dart' as fs; import '../model/firestore/pr_check_runs.dart'; diff --git a/app_dart/test/model/firestore/ci_staging_test.dart b/app_dart/test/model/firestore/ci_staging_test.dart index e189baf4fe..a364f3403d 100644 --- a/app_dart/test/model/firestore/ci_staging_test.dart +++ b/app_dart/test/model/firestore/ci_staging_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/ci_staging.dart'; import 'package:cocoon_service/src/service/firestore.dart'; import 'package:github/github.dart'; diff --git a/app_dart/test/model/firestore/presubmit_check_test.dart b/app_dart/test/model/firestore/presubmit_check_test.dart new file mode 100644 index 0000000000..8548f209a4 --- /dev/null +++ b/app_dart/test/model/firestore/presubmit_check_test.dart @@ -0,0 +1,174 @@ +// Copyright 2025 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 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/firestore/presubmit_check.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:test/test.dart'; + +import '../../src/service/fake_firestore_service.dart'; + +void main() { + useTestLoggerPerTest(); + + group('PresubmitCheckId', () { + test('validates checkRunId', () { + expect( + () => PresubmitCheckId( + checkRunId: 0, + buildName: 'linux', + attemptNumber: 1, + ), + throwsA(isA()), + ); + }); + + test('validates attemptNumber', () { + expect( + () => PresubmitCheckId( + checkRunId: 1, + buildName: 'linux', + attemptNumber: 0, + ), + throwsA(isA()), + ); + }); + + test('generates correct documentId', () { + final id = PresubmitCheckId( + checkRunId: 123, + buildName: 'linux_test', + attemptNumber: 2, + ); + expect(id.documentId, '123_linux_test_2'); + }); + + test('parses valid documentName', () { + final id = PresubmitCheckId.parse('123_linux_test_2'); + expect(id.checkRunId, 123); + expect(id.buildName, 'linux_test'); + expect(id.attemptNumber, 2); + }); + + test('tryParse returns null for invalid format', () { + expect(PresubmitCheckId.tryParse('invalid'), isNull); + expect(PresubmitCheckId.tryParse('123_linux'), isNull); + }); + }); + + group('PresubmitCheck', () { + late FakeFirestoreService firestoreService; + + setUp(() { + firestoreService = FakeFirestoreService(); + }); + + test('init creates correct initial state', () { + final check = PresubmitCheck.init( + buildName: 'linux', + checkRunId: 123, + creationTime: 1000, + ); + + expect(check.buildName, 'linux'); + expect(check.checkRunId, 123); + expect(check.creationTime, 1000); + expect(check.attemptNumber, 1); + expect(check.status, TaskStatus.waitingForBackfill); + expect(check.buildNumber, isNull); + expect(check.startTime, isNull); + expect(check.endTime, isNull); + expect(check.summary, isNull); + }); + + test('fromFirestore loads document correctly', () async { + final check = PresubmitCheck( + checkRunId: 123, + buildName: 'linux', + status: TaskStatus.succeeded, + attemptNumber: 1, + creationTime: 1000, + buildNumber: 456, + startTime: 2000, + endTime: 3000, + summary: 'Success', + ); + + // Use the helper to get the correct document name + final docName = PresubmitCheck.documentNameFor( + checkRunId: 123, + buildName: 'linux', + attemptNumber: 1, + ); + + // Manually ensuring the name is set for the fake service, usually done by `putDocument` + // but we should verify the `PresubmitCheck` object has it right via the factory or init. + // Actually `PresubmitCheck` constructor sets name. + firestoreService.putDocument( + Document(name: docName, fields: check.fields), + ); + + final loadedCheck = await PresubmitCheck.fromFirestore( + firestoreService, + PresubmitCheckId(checkRunId: 123, buildName: 'linux', attemptNumber: 1), + ); + + expect(loadedCheck.checkRunId, 123); + expect(loadedCheck.buildName, 'linux'); + expect(loadedCheck.status, TaskStatus.succeeded); + expect(loadedCheck.attemptNumber, 1); + expect(loadedCheck.creationTime, 1000); + expect(loadedCheck.buildNumber, 456); + expect(loadedCheck.startTime, 2000); + expect(loadedCheck.endTime, 3000); + expect(loadedCheck.summary, 'Success'); + }); + + test('updateFromBuild updates fields', () { + final check = PresubmitCheck.init( + buildName: 'linux', + checkRunId: 123, + creationTime: 1000, + ); + + final build = bbv2.Build( + number: 456, + createTime: bbv2.Timestamp(seconds: Int64(2000)), + startTime: bbv2.Timestamp(seconds: Int64(2100)), + endTime: bbv2.Timestamp(seconds: Int64(2200)), + status: bbv2.Status.SUCCESS, + ); + + check.updateFromBuild(build); + + expect(check.buildNumber, 456); + expect(check.creationTime, 2000000); // seconds to millis + expect(check.startTime, 2100000); + expect(check.endTime, 2200000); + expect(check.status, TaskStatus.succeeded); + }); + + test('updateFromBuild does not update status if already complete', () { + final check = PresubmitCheck.init( + buildName: 'linux', + checkRunId: 123, + creationTime: 1000, + ); + check.status = TaskStatus.succeeded; + + final build = bbv2.Build( + number: 456, + createTime: bbv2.Timestamp(seconds: Int64(2000)), + status: bbv2.Status.STARTED, + ); + + check.updateFromBuild(build); + + expect(check.status, TaskStatus.succeeded); + }); + }); +} diff --git a/app_dart/test/model/firestore/presubmit_guard_test.dart b/app_dart/test/model/firestore/presubmit_guard_test.dart new file mode 100644 index 0000000000..e74d8a24df --- /dev/null +++ b/app_dart/test/model/firestore/presubmit_guard_test.dart @@ -0,0 +1,127 @@ +// Copyright 2025 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 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; +import 'package:cocoon_service/src/model/firestore/presubmit_guard.dart'; + +import 'package:github/github.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:test/test.dart'; + +void main() { + useTestLoggerPerTest(); + + group('PresubmitGuardId', () { + test('generates correct documentId', () { + final id = PresubmitGuardId( + slug: RepositorySlug('flutter', 'flutter'), + pullRequestId: 123, + checkRunId: 456, + stage: CiStage.fusionEngineBuild, + ); + expect(id.documentId, 'flutter_flutter_123_456_engine'); + }); + }); + + group('PresubmitGuard', () { + final slug = RepositorySlug('flutter', 'flutter'); + final checkRun = CheckRun.fromJson({ + 'id': 456, + 'name': 'Merge Queue Guard', + 'head_sha': 'abc', + 'started_at': DateTime.now().toIso8601String(), + 'check_suite': {'id': 789}, + }); + + test('init creates correct initial state', () { + final guard = PresubmitGuard.init( + slug: slug, + pullRequestId: 123, + checkRun: checkRun, + stage: CiStage.fusionEngineBuild, + commitSha: 'abc', + creationTime: 1000, + author: 'author', + buildCount: 2, + ); + + expect(guard.slug, slug); + expect(guard.pullRequestId, 123); + expect(guard.checkRunId, 456); + expect(guard.stage, CiStage.fusionEngineBuild); + expect(guard.commitSha, 'abc'); + expect(guard.creationTime, 1000); + expect(guard.author, 'author'); + expect(guard.remainingBuilds, 2); + expect(guard.failedBuilds, 0); + expect(guard.checkRun.id, 456); + }); + + test('fromDocument loads correct state', () { + final guard = PresubmitGuard.init( + slug: slug, + pullRequestId: 123, + checkRun: checkRun, + stage: CiStage.fusionEngineBuild, + commitSha: 'abc', + creationTime: 1000, + author: 'author', + buildCount: 2, + ); + guard.builds = {'linux': TaskStatus.succeeded}; + + final doc = Document(name: guard.name, fields: guard.fields); + + final loadedGuard = PresubmitGuard.fromDocument(doc); + + expect(loadedGuard.slug, slug); + expect(loadedGuard.pullRequestId, 123); + expect(loadedGuard.checkRunId, 456); + expect(loadedGuard.stage, CiStage.fusionEngineBuild); + expect(loadedGuard.builds, {'linux': TaskStatus.succeeded}); + expect(loadedGuard.remainingBuilds, 2); + }); + + test('updates fields correctly', () { + final guard = PresubmitGuard.init( + slug: slug, + pullRequestId: 123, + checkRun: checkRun, + stage: CiStage.fusionEngineBuild, + commitSha: 'abc', + creationTime: 1000, + author: 'author', + buildCount: 2, + ); + + guard.remainingBuilds = 1; + guard.failedBuilds = 1; + guard.builds = {'linux': TaskStatus.failed}; + + expect(guard.remainingBuilds, 1); + expect(guard.failedBuilds, 1); + expect(guard.builds, {'linux': TaskStatus.failed}); + }); + + test('parses properties from document name', () { + // flutter_flutter_123_456_fusionEngineBuild + final guard = PresubmitGuard( + checkRun: checkRun, + commitSha: 'abc', + slug: slug, + pullRequestId: 123, + stage: CiStage.fusionEngineBuild, + creationTime: 1000, + author: 'author', + ); + + expect(guard.slug, slug); + expect(guard.pullRequestId, 123); + expect(guard.checkRunId, 456); + expect(guard.stage, CiStage.fusionEngineBuild); + }); + }); +} diff --git a/app_dart/test/request_handlers/get_engine_artifacts_ready_test.dart b/app_dart/test/request_handlers/get_engine_artifacts_ready_test.dart index dbd0df8cab..9d2484c506 100644 --- a/app_dart/test/request_handlers/get_engine_artifacts_ready_test.dart +++ b/app_dart/test/request_handlers/get_engine_artifacts_ready_test.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/model/common/firestore_extensions.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/ci_staging.dart'; import 'package:cocoon_service/src/request_handlers/get_engine_artifacts_ready.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; diff --git a/app_dart/test/request_handlers/github/webhook_subscription_test.dart b/app_dart/test/request_handlers/github/webhook_subscription_test.dart index 75e3157058..bd11cbfb81 100644 --- a/app_dart/test/request_handlers/github/webhook_subscription_test.dart +++ b/app_dart/test/request_handlers/github/webhook_subscription_test.dart @@ -11,6 +11,7 @@ import 'package:cocoon_common_test/cocoon_common_test.dart'; import 'package:cocoon_server/logging.dart'; import 'package:cocoon_server_test/mocks.dart'; import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/ci_staging.dart'; import 'package:cocoon_service/src/model/firestore/commit.dart' as fs; import 'package:cocoon_service/src/model/firestore/pr_check_runs.dart'; diff --git a/app_dart/test/service/firestore/unified_check_run_test.dart b/app_dart/test/service/firestore/unified_check_run_test.dart new file mode 100644 index 0000000000..ed01d1be10 --- /dev/null +++ b/app_dart/test/service/firestore/unified_check_run_test.dart @@ -0,0 +1,286 @@ +// Copyright 2025 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 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/common/presubmit_check_state.dart'; +import 'package:cocoon_service/src/model/common/presubmit_guard_conclusion.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; +import 'package:cocoon_service/src/model/firestore/presubmit_check.dart'; +import 'package:cocoon_service/src/model/firestore/presubmit_guard.dart'; +import 'package:cocoon_service/src/service/firestore.dart'; +import 'package:cocoon_service/src/service/firestore/unified_check_run.dart'; +import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; +import 'package:github/github.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:test/test.dart'; + +import '../../src/fake_config.dart'; +import '../../src/service/fake_firestore_service.dart'; +import '../../src/utilities/entity_generators.dart'; + +void main() { + useTestLoggerPerTest(); + + late FakeConfig config; + late FakeFirestoreService firestoreService; + + setUp(() { + config = FakeConfig(); + firestoreService = FakeFirestoreService(); + }); + + group('UnifiedCheckRun', () { + final slug = RepositorySlug('flutter', 'flutter'); + final checkRun = CheckRun.fromJson({ + 'id': 123, + 'name': 'check_run', + 'head_sha': 'context_sha', + 'started_at': DateTime.now().toIso8601String(), + 'check_suite': {'id': 456}, + }); + final pullRequest = generatePullRequest( + id: 789, + authorLogin: 'dash', + number: 1, + ); + const sha = 'sha'; + + group('initializeCiStagingDocument', () { + test('creates PresubmitGuard and Checks when enabled for user', () async { + config.dynamicConfig = DynamicConfig.fromJson({ + 'unifiedCheckRunFlow': { + 'useForUsers': ['dash'], + }, + }); + + await UnifiedCheckRun.initializeCiStagingDocument( + firestoreService: firestoreService, + slug: slug, + sha: sha, + stage: CiStage.fusionEngineBuild, + tasks: ['linux', 'mac'], + config: config, + pullRequest: pullRequest, + checkRun: checkRun, + ); + + final guardId = PresubmitGuard.documentIdFor( + slug: slug, + pullRequestId: 1, + checkRunId: 123, + stage: CiStage.fusionEngineBuild, + ); + final guardDoc = await firestoreService.getDocument( + 'projects/flutter-dashboard/databases/cocoon/documents/presubmit_guards/${guardId.documentId}', + ); + expect(guardDoc.name, endsWith(guardId.documentId)); + + final checkId = PresubmitCheck.documentIdFor( + checkRunId: 123, + buildName: 'linux', + attemptNumber: 1, + ); + final checkDoc = await firestoreService.getDocument( + 'projects/flutter-dashboard/databases/cocoon/documents/presubmit_checks/${checkId.documentId}', + ); + expect(checkDoc.name, endsWith(checkId.documentId)); + }); + + test('initializes CiStagingDocument when NOT enabled for user', () async { + config.dynamicConfig = DynamicConfig.fromJson({ + 'unifiedCheckRunFlow': {'useForUsers': []}, + }); + + await UnifiedCheckRun.initializeCiStagingDocument( + firestoreService: firestoreService, + slug: slug, + sha: sha, + stage: CiStage.fusionEngineBuild, + tasks: ['linux', 'mac'], + config: config, + pullRequest: pullRequest, + checkRun: checkRun, + ); + + // Verify PresubmitGuard is NOT created + final guardId = PresubmitGuard.documentIdFor( + slug: slug, + pullRequestId: 1, + checkRunId: 123, + stage: CiStage.fusionEngineBuild, + ); + expect( + () => firestoreService.getDocument( + 'projects/flutter-dashboard/databases/cocoon/documents/presubmit_guards/${guardId.documentId}', + ), + throwsA(isA()), + ); + }); + }); + + group('markConclusion', () { + late PresubmitGuardId guardId; + + setUp(() async { + guardId = PresubmitGuardId( + slug: slug, + pullRequestId: 1, + checkRunId: 123, + stage: CiStage.fusionEngineBuild, + ); + + // Initialize documents + await UnifiedCheckRun.initializePresubmitGuardDocument( + firestoreService: firestoreService, + slug: slug, + pullRequestId: 1, + checkRun: checkRun, + stage: CiStage.fusionEngineBuild, + commitSha: sha, + creationTime: 1000, + author: 'dash', + tasks: ['linux', 'mac'], + ); + + final check1 = PresubmitCheck.init( + buildName: 'linux', + checkRunId: 123, + creationTime: 1000, + ); + final check2 = PresubmitCheck.init( + buildName: 'mac', + checkRunId: 123, + creationTime: 1000, + ); + + await firestoreService.writeViaTransaction( + documentsToWrites([ + Document(name: check1.name, fields: check1.fields), + Document(name: check2.name, fields: check2.fields), + ], exists: false), + ); + }); + + test('updates check status and remaining count on success', () async { + final state = const PresubmitCheckState( + buildName: 'linux', + status: TaskStatus.succeeded, + attemptNumber: 1, + startTime: 2000, + endTime: 3000, + ); + + final result = await UnifiedCheckRun.markConclusion( + firestoreService: firestoreService, + guardId: guardId, + state: state, + ); + + expect(result.result, PresubmitGuardConclusionResult.ok); + expect(result.remaining, 1); + expect(result.failed, 0); + + final checkDoc = await PresubmitCheck.fromFirestore( + firestoreService, + PresubmitCheckId( + checkRunId: 123, + buildName: 'linux', + attemptNumber: 1, + ), + ); + expect(checkDoc.status, TaskStatus.succeeded); + expect(checkDoc.endTime, 3000); + }); + + test( + 'update all check status to succeeded lead to complete guard', + () async { + final result1 = await UnifiedCheckRun.markConclusion( + firestoreService: firestoreService, + guardId: guardId, + state: const PresubmitCheckState( + buildName: 'linux', + status: TaskStatus.succeeded, + attemptNumber: 1, + startTime: 2000, + endTime: 3000, + ), + ); + + expect(result1.remaining, 1); + expect(result1.failed, 0); + expect(result1.isOk, true); + expect(result1.isComplete, false); + expect(result1.isPending, true); + + final result2 = await UnifiedCheckRun.markConclusion( + firestoreService: firestoreService, + guardId: guardId, + state: const PresubmitCheckState( + buildName: 'mac', + status: TaskStatus.succeeded, + attemptNumber: 1, + startTime: 2000, + endTime: 3000, + ), + ); + + expect(result2.remaining, 0); + expect(result2.failed, 0); + expect(result2.isOk, true); + expect(result2.isComplete, true); + expect(result2.isPending, false); + + final checkDoc = await PresubmitCheck.fromFirestore( + firestoreService, + PresubmitCheckId( + checkRunId: 123, + buildName: 'linux', + attemptNumber: 1, + ), + ); + expect(checkDoc.status, TaskStatus.succeeded); + expect(checkDoc.endTime, 3000); + }, + ); + + test('updates check status and failed count on failure', () async { + final state = const PresubmitCheckState( + buildName: 'linux', + status: TaskStatus.failed, + attemptNumber: 1, + startTime: 2000, + endTime: 3000, + ); + + final result = await UnifiedCheckRun.markConclusion( + firestoreService: firestoreService, + guardId: guardId, + state: state, + ); + + expect(result.result, PresubmitGuardConclusionResult.ok); + expect(result.remaining, 1); + expect(result.failed, 1); + }); + + test('handles missing check gracefully', () async { + final state = const PresubmitCheckState( + buildName: 'windows', // Missing + status: TaskStatus.succeeded, + attemptNumber: 1, + ); + + final result = await UnifiedCheckRun.markConclusion( + firestoreService: firestoreService, + guardId: guardId, + state: state, + ); + + expect(result.result, PresubmitGuardConclusionResult.missing); + }); + }); + }); +} diff --git a/app_dart/test/service/scheduler_test.dart b/app_dart/test/service/scheduler_test.dart index 8ffdfd6cf4..ac1b1d1784 100644 --- a/app_dart/test/service/scheduler_test.dart +++ b/app_dart/test/service/scheduler_test.dart @@ -12,6 +12,7 @@ import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/ci_yaml/ci_yaml.dart'; import 'package:cocoon_service/src/model/ci_yaml/target.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/ci_staging.dart'; import 'package:cocoon_service/src/model/firestore/commit.dart' as fs; import 'package:cocoon_service/src/model/firestore/pr_check_runs.dart'; diff --git a/app_dart/test/src/utilities/entity_generators.dart b/app_dart/test/src/utilities/entity_generators.dart index 60d1cab273..71a2d42ad0 100644 --- a/app_dart/test/src/utilities/entity_generators.dart +++ b/app_dart/test/src/utilities/entity_generators.dart @@ -215,6 +215,7 @@ github.PullRequest generatePullRequest({ String title = 'example message', int number = 123, DateTime? mergedAt, + DateTime? createdAt, String headSha = 'abc', String baseSha = 'def', bool merged = true, @@ -222,11 +223,13 @@ github.PullRequest generatePullRequest({ int changedFilesCount = 1, }) { mergedAt ??= DateTime.fromMillisecondsSinceEpoch(1); + createdAt ??= DateTime.fromMillisecondsSinceEpoch(1); return github.PullRequest( id: id, title: title, number: number, mergedAt: mergedAt, + createdAt: createdAt, base: github.PullRequestHead( ref: branch, sha: baseSha, diff --git a/app_dart/test/src/utilities/mocks.mocks.dart b/app_dart/test/src/utilities/mocks.mocks.dart index b0cb822145..afa6778535 100644 --- a/app_dart/test/src/utilities/mocks.mocks.dart +++ b/app_dart/test/src/utilities/mocks.mocks.dart @@ -12,10 +12,9 @@ import 'package:buildbucket/buildbucket_pb.dart' as _i6; import 'package:cocoon_common/rpc_model.dart' as _i19; import 'package:cocoon_service/cocoon_service.dart' as _i17; import 'package:cocoon_service/src/foundation/github_checks_util.dart' as _i10; -import 'package:cocoon_service/src/model/ci_yaml/ci_yaml.dart' as _i41; +import 'package:cocoon_service/src/model/ci_yaml/ci_yaml.dart' as _i40; import 'package:cocoon_service/src/model/ci_yaml/target.dart' as _i28; import 'package:cocoon_service/src/model/commit_ref.dart' as _i32; -import 'package:cocoon_service/src/model/firestore/ci_staging.dart' as _i40; import 'package:cocoon_service/src/model/firestore/commit.dart' as _i38; import 'package:cocoon_service/src/model/firestore/task.dart' as _i33; import 'package:cocoon_service/src/model/github/checks.dart' as _i31; @@ -37,7 +36,7 @@ import 'package:cocoon_service/src/service/luci_build_service/pending_task.dart' import 'package:cocoon_service/src/service/luci_build_service/user_data.dart' as _i30; import 'package:cocoon_service/src/service/scheduler/process_check_run_result.dart' - as _i42; + as _i41; import 'package:fixnum/fixnum.dart' as _i34; import 'package:github/github.dart' as _i7; import 'package:github/hooks.dart' as _i22; @@ -5555,7 +5554,7 @@ class MockScheduler extends _i1.Mock implements _i17.Scheduler { String? baseRef, _i7.RepositorySlug? slug, String? headSha, - _i40.CiStage? stage, + dynamic stage, ) => (super.noSuchMethod( Invocation.method(#getMergeGroupTargetsForStage, [ @@ -5573,7 +5572,7 @@ class MockScheduler extends _i1.Mock implements _i17.Scheduler { String? baseRef, _i7.RepositorySlug? slug, String? headSha, { - _i41.CiType? type = _i41.CiType.any, + _i40.CiType? type = _i40.CiType.any, }) => (super.noSuchMethod( Invocation.method( @@ -5665,7 +5664,7 @@ class MockScheduler extends _i1.Mock implements _i17.Scheduler { @override _i13.Future> getPresubmitTargets( _i7.PullRequest? pullRequest, { - _i41.CiType? type = _i41.CiType.any, + _i40.CiType? type = _i40.CiType.any, }) => (super.noSuchMethod( Invocation.method( @@ -5718,19 +5717,19 @@ class MockScheduler extends _i1.Mock implements _i17.Scheduler { as _i13.Future); @override - _i13.Future<_i42.ProcessCheckRunResult> processCheckRun( + _i13.Future<_i41.ProcessCheckRunResult> processCheckRun( _i31.CheckRunEvent? checkRunEvent, ) => (super.noSuchMethod( Invocation.method(#processCheckRun, [checkRunEvent]), - returnValue: _i13.Future<_i42.ProcessCheckRunResult>.value( - _i20.dummyValue<_i42.ProcessCheckRunResult>( + returnValue: _i13.Future<_i41.ProcessCheckRunResult>.value( + _i20.dummyValue<_i41.ProcessCheckRunResult>( this, Invocation.method(#processCheckRun, [checkRunEvent]), ), ), ) - as _i13.Future<_i42.ProcessCheckRunResult>); + as _i13.Future<_i41.ProcessCheckRunResult>); @override _i7.CheckRun checkRunFromString(String? input) =>