diff --git a/app_dart/lib/server.dart b/app_dart/lib/server.dart index ae07cd0a4..c7fb0212c 100644 --- a/app_dart/lib/server.dart +++ b/app_dart/lib/server.dart @@ -8,6 +8,7 @@ import 'dart:math'; import 'cocoon_service.dart'; import 'src/request_handlers/get_engine_artifacts_ready.dart'; import 'src/request_handlers/get_presubmit_checks.dart'; +import 'src/request_handlers/get_presubmit_guard.dart'; import 'src/request_handlers/get_tree_status_changes.dart'; import 'src/request_handlers/github_webhook_replay.dart'; import 'src/request_handlers/lookup_hash.dart'; @@ -156,8 +157,42 @@ Server createServer({ authenticationProvider: authProvider, firestore: firestore, ), + + /// Returns the presubmit guard status for a given slug and commit SHA. + /// + /// Consolidates multiple [PresubmitGuard] records (one per stage) into a single response. + /// + /// GET: /api/get-presubmit-guard + /// + /// Parameters: + /// slug: (string in query) required. The repository owner/name (e.g., 'flutter/flutter'). + /// sha: (string in query) required. The commit SHA to query for. + /// + /// Response: Status 200 OK + /// Returns [PresubmitGuardResponse]: + /// { + /// "pr_num": 123, + /// "check_run_id": 456, + /// "author": "dash", + /// "stages": [ + /// { + /// "name": "fusion", + /// "created_at": 123456789, + /// "builds": { + /// "test1": "Succeeded", + /// "test2": "In Progress" + /// } + /// } + /// ] + /// } + '/api/get-presubmit-guard': GetPresubmitGuard( + config: config, + authenticationProvider: authProvider, + firestore: firestore, + ), '/api/get-presubmit-checks': GetPresubmitChecks( config: config, + authenticationProvider: authProvider, firestore: firestore, ), '/api/update-suppressed-test': UpdateSuppressedTest( diff --git a/app_dart/lib/src/request_handlers/get_presubmit_checks.dart b/app_dart/lib/src/request_handlers/get_presubmit_checks.dart index 2ce6e4f97..af074107a 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_checks.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_checks.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:cocoon_common/rpc_model.dart'; import '../../cocoon_service.dart'; +import '../request_handling/api_request_handler.dart'; import '../service/firestore/unified_check_run.dart'; /// Returns all checks for a specific presubmit check run. @@ -30,9 +31,10 @@ import '../service/firestore/unified_check_run.dart'; /// "summary": "Check passed" /// } /// ] -final class GetPresubmitChecks extends RequestHandler { +final class GetPresubmitChecks extends ApiRequestHandler { const GetPresubmitChecks({ required super.config, + required super.authenticationProvider, required FirestoreService firestore, }) : _firestore = firestore; diff --git a/app_dart/lib/src/request_handlers/get_presubmit_guard.dart b/app_dart/lib/src/request_handlers/get_presubmit_guard.dart new file mode 100644 index 000000000..dbe1b76eb --- /dev/null +++ b/app_dart/lib/src/request_handlers/get_presubmit_guard.dart @@ -0,0 +1,96 @@ +// Copyright 2026 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 'dart:io'; + +import 'package:cocoon_common/guard_status.dart'; +import 'package:cocoon_common/rpc_model.dart' as rpc_model; +import 'package:github/github.dart'; +import 'package:meta/meta.dart'; + +import '../../cocoon_service.dart'; +import '../request_handling/api_request_handler.dart'; +import '../service/firestore/unified_check_run.dart'; + +/// Request handler for retrieving the aggregated presubmit guard status. +/// +/// This handler queries the presubmit guards for a specific commit SHA and +/// returns an aggregated response including the overall guard status and +/// individual stage statuses. +@immutable +final class GetPresubmitGuard extends ApiRequestHandler { + /// Defines the [GetPresubmitGuard] handler. + const GetPresubmitGuard({ + required super.config, + required super.authenticationProvider, + required FirestoreService firestore, + }) : _firestore = firestore; + + final FirestoreService _firestore; + + /// The name of the query parameter for the repository slug (e.g. 'flutter/flutter'). + static const String kSlugParam = 'slug'; + + /// The name of the query parameter for the commit SHA. + static const String kShaParam = 'sha'; + + /// Handles the HTTP GET request. + /// + /// Requires [kSlugParam] and [kShaParam] query parameters. + /// Returns a JSON response with the aggregated presubmit guard data. + @override + Future get(Request request) async { + checkRequiredQueryParameters(request, [kSlugParam, kShaParam]); + + final slugName = request.uri.queryParameters[kSlugParam]!; + final sha = request.uri.queryParameters[kShaParam]!; + + final slug = RepositorySlug.full(slugName); + final guards = await UnifiedCheckRun.getPresubmitGuardsForCommitSha( + firestoreService: _firestore, + slug: slug, + commitSha: sha, + ); + + if (guards.isEmpty) { + return Response.json({ + 'error': 'No guard found for slug $slug and sha $sha', + }, statusCode: HttpStatus.notFound); + } + + // Consolidate metadata from the first record. + final first = guards.first; + + final GuardStatus guardStatus; + if (guards.any((g) => g.failedBuilds > 0)) { + guardStatus = GuardStatus.failed; + } else if (guards.every( + (g) => g.failedBuilds == 0 && g.remainingBuilds == 0, + )) { + guardStatus = GuardStatus.succeeded; + } else if (guards.every((g) => g.remainingBuilds == g.builds.length)) { + guardStatus = GuardStatus.waitingForBackfill; + } else { + guardStatus = GuardStatus.inProgress; + } + + final response = rpc_model.PresubmitGuardResponse( + prNum: first.pullRequestId, + checkRunId: first.checkRunId, + author: first.author, + guardStatus: guardStatus, + stages: [ + for (final g in guards) + rpc_model.PresubmitGuardStage( + name: g.stage.name, + createdAt: g.creationTime, + builds: g.builds, + ), + ], + ); + + return Response.json(response); + } +} diff --git a/app_dart/lib/src/service/firestore.dart b/app_dart/lib/src/service/firestore.dart index 19e5faa08..b8ca80526 100644 --- a/app_dart/lib/src/service/firestore.dart +++ b/app_dart/lib/src/service/firestore.dart @@ -16,6 +16,7 @@ import '../../cocoon_service.dart'; import '../model/firestore/commit.dart'; import '../model/firestore/github_build_status.dart'; import '../model/firestore/github_gold_status.dart'; + import '../model/firestore/task.dart'; import 'firestore/commit_and_tasks.dart'; diff --git a/app_dart/lib/src/service/firestore/unified_check_run.dart b/app_dart/lib/src/service/firestore/unified_check_run.dart index 2598f4930..771e722b3 100644 --- a/app_dart/lib/src/service/firestore/unified_check_run.dart +++ b/app_dart/lib/src/service/firestore/unified_check_run.dart @@ -230,6 +230,19 @@ final class UnifiedCheckRun { )).firstOrNull; } + /// Queries for [PresubmitGuard] records by [slug] and [commitSha]. + static Future> getPresubmitGuardsForCommitSha({ + required FirestoreService firestoreService, + required RepositorySlug slug, + required String commitSha, + }) async { + return await _queryPresubmitGuards( + firestoreService: firestoreService, + slug: slug, + commitSha: commitSha, + ); + } + static Future> _queryPresubmitGuards({ required FirestoreService firestoreService, Transaction? transaction, @@ -246,10 +259,10 @@ final class UnifiedCheckRun { int? limit, }) async { final filterMap = { - '${PresubmitGuard.fieldSlug} =': ?slug, + '${PresubmitGuard.fieldSlug} =': ?slug?.fullName, '${PresubmitGuard.fieldPullRequestId} =': ?pullRequestId, '${PresubmitGuard.fieldCheckRunId} =': ?checkRunId, - '${PresubmitGuard.fieldStage} =': ?stage, + '${PresubmitGuard.fieldStage} =': ?stage?.name, '${PresubmitGuard.fieldCreationTime} =': ?creationTime, '${PresubmitGuard.fieldAuthor} =': ?author, '${PresubmitGuard.fieldCommitSha} =': ?commitSha, diff --git a/app_dart/test/request_handlers/get_presubmit_checks_test.dart b/app_dart/test/request_handlers/get_presubmit_checks_test.dart index e5a3104a1..ffc39bdeb 100644 --- a/app_dart/test/request_handlers/get_presubmit_checks_test.dart +++ b/app_dart/test/request_handlers/get_presubmit_checks_test.dart @@ -14,6 +14,7 @@ import 'package:cocoon_service/src/request_handlers/get_presubmit_checks.dart'; import 'package:test/test.dart'; import '../src/fake_config.dart'; +import '../src/request_handling/fake_dashboard_authentication.dart'; import '../src/request_handling/fake_http.dart'; import '../src/request_handling/request_handler_tester.dart'; import '../src/service/fake_firestore_service.dart'; @@ -30,7 +31,11 @@ void main() { config = FakeConfig(); tester = RequestHandlerTester(); firestoreService = FakeFirestoreService(); - handler = GetPresubmitChecks(config: config, firestore: firestoreService); + handler = GetPresubmitChecks( + config: config, + firestore: firestoreService, + authenticationProvider: FakeDashboardAuthentication(), + ); }); Future?> getPresubmitCheckResponse( diff --git a/app_dart/test/request_handlers/get_presubmit_guard_test.dart b/app_dart/test/request_handlers/get_presubmit_guard_test.dart new file mode 100644 index 000000000..65e74e3e2 --- /dev/null +++ b/app_dart/test/request_handlers/get_presubmit_guard_test.dart @@ -0,0 +1,200 @@ +// Copyright 2026 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:convert'; +import 'dart:io'; + +import 'package:cocoon_common/guard_status.dart'; +import 'package:cocoon_common/src/rpc_model/presubmit_guard.dart'; +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/request_handlers/get_presubmit_guard.dart'; +import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:github/github.dart'; +import 'package:test/test.dart'; + +import '../src/fake_config.dart'; +import '../src/request_handling/fake_dashboard_authentication.dart'; +import '../src/request_handling/fake_http.dart'; +import '../src/request_handling/request_handler_tester.dart'; +import '../src/service/fake_firestore_service.dart'; +import '../src/utilities/entity_generators.dart'; + +void main() { + useTestLoggerPerTest(); + + late RequestHandlerTester tester; + late GetPresubmitGuard handler; + late FakeFirestoreService firestore; + + Future getResponse() async { + final response = await tester.get(handler); + if (response.statusCode != HttpStatus.ok) { + return null; + } + final responseBody = await utf8.decoder + .bind(response.body as Stream>) + .transform(json.decoder) + .single; + if (responseBody == null) { + return null; + } + return PresubmitGuardResponse.fromJson( + responseBody as Map, + ); + } + + setUp(() { + firestore = FakeFirestoreService(); + tester = RequestHandlerTester(); + handler = GetPresubmitGuard( + config: FakeConfig(), + authenticationProvider: FakeDashboardAuthentication(), + firestore: firestore, + ); + }); + + test('missing parameters', () async { + tester.request = FakeHttpRequest(); + expect(tester.get(handler), throwsA(isA())); + }); + + test('no guards found', () async { + tester.request = FakeHttpRequest( + queryParametersValue: { + GetPresubmitGuard.kSlugParam: 'flutter/flutter', + GetPresubmitGuard.kShaParam: 'abc', + }, + ); + + final result = await getResponse(); + expect(result, isNull); + }); + + test('consolidates multiple stages', () async { + final slug = RepositorySlug('flutter', 'flutter'); + const sha = 'abc'; + + final guard1 = generatePresubmitGuard( + slug: slug, + commitSha: sha, + stage: CiStage.fusionTests, + builds: {'test1': TaskStatus.succeeded}, + ); + guard1.failedBuilds = 0; + guard1.remainingBuilds = 0; + + final guard2 = generatePresubmitGuard( + slug: slug, + commitSha: sha, + stage: CiStage.fusionEngineBuild, + builds: {'engine1': TaskStatus.inProgress}, + ); + guard2.failedBuilds = 0; + guard2.remainingBuilds = 1; + + firestore.putDocuments([guard1, guard2]); + + tester.request = FakeHttpRequest( + queryParametersValue: { + GetPresubmitGuard.kSlugParam: 'flutter/flutter', + GetPresubmitGuard.kShaParam: sha, + }, + ); + + final result = (await getResponse())!; + expect(result.prNum, 123); + expect(result.author, 'dash'); + expect(result.checkRunId, 1); + expect(result.guardStatus, GuardStatus.inProgress); + + final stages = result.stages; + expect(stages.length, 2); + + final fusionStage = stages.firstWhere((s) => s.name == 'fusion'); + expect(fusionStage.builds, {'test1': TaskStatus.succeeded}); + + final engineStage = stages.firstWhere((s) => s.name == 'engine'); + expect(engineStage.builds, {'engine1': TaskStatus.inProgress}); + }); + + test('guardStatus is Failed if any stage has failed builds', () async { + final slug = RepositorySlug('flutter', 'flutter'); + const sha = 'abc'; + + final guard = generatePresubmitGuard( + slug: slug, + commitSha: sha, + builds: {'test1': TaskStatus.failed}, + ); + guard.failedBuilds = 1; + guard.remainingBuilds = 0; + + firestore.putDocuments([guard]); + + tester.request = FakeHttpRequest( + queryParametersValue: { + GetPresubmitGuard.kSlugParam: 'flutter/flutter', + GetPresubmitGuard.kShaParam: sha, + }, + ); + + final result = (await getResponse())!; + expect(result.guardStatus, GuardStatus.failed); + }); + + test( + 'guardStatus is Succeeded if all stages are complete without failures', + () async { + final slug = RepositorySlug('flutter', 'flutter'); + const sha = 'abc'; + + final guard = generatePresubmitGuard( + slug: slug, + commitSha: sha, + builds: {'test1': TaskStatus.succeeded}, + ); + guard.failedBuilds = 0; + guard.remainingBuilds = 0; + + firestore.putDocuments([guard]); + + tester.request = FakeHttpRequest( + queryParametersValue: { + GetPresubmitGuard.kSlugParam: 'flutter/flutter', + GetPresubmitGuard.kShaParam: sha, + }, + ); + + final result = (await getResponse())!; + expect(result.guardStatus, GuardStatus.succeeded); + }, + ); + + test('guardStatus is New if all stages are waiting for backfill', () async { + final slug = RepositorySlug('flutter', 'flutter'); + const sha = 'abc'; + + final guard = generatePresubmitGuard( + slug: slug, + commitSha: sha, + builds: {'test1': TaskStatus.waitingForBackfill}, + ); + guard.failedBuilds = 0; + guard.remainingBuilds = 1; + + firestore.putDocuments([guard]); + + tester.request = FakeHttpRequest( + queryParametersValue: { + GetPresubmitGuard.kSlugParam: 'flutter/flutter', + GetPresubmitGuard.kShaParam: sha, + }, + ); + + final result = (await getResponse())!; + expect(result.guardStatus, GuardStatus.waitingForBackfill); + }); +} diff --git a/app_dart/test/service/presubmit_guard_query_test.dart b/app_dart/test/service/presubmit_guard_query_test.dart new file mode 100644 index 000000000..884d601d1 --- /dev/null +++ b/app_dart/test/service/presubmit_guard_query_test.dart @@ -0,0 +1,70 @@ +// 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_server_test/test_logging.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; +import 'package:cocoon_service/src/service/firestore/unified_check_run.dart'; +import 'package:github/github.dart'; +import 'package:test/test.dart'; + +import '../src/service/fake_firestore_service.dart'; +import '../src/utilities/entity_generators.dart'; + +void main() { + useTestLoggerPerTest(); + + late FakeFirestoreService firestoreService; + + setUp(() { + firestoreService = FakeFirestoreService(); + }); + + test('queryPresubmitGuards returns matching guards', () async { + final slug = RepositorySlug('flutter', 'flutter'); + const commitSha = 'abc'; + + final guard1 = generatePresubmitGuard( + slug: slug, + commitSha: commitSha, + stage: CiStage.fusionTests, + ); + final guard2 = generatePresubmitGuard( + slug: slug, + commitSha: commitSha, + stage: CiStage.fusionEngineBuild, + ); + final otherGuard = generatePresubmitGuard( + slug: slug, + pullRequestId: 456, + commitSha: 'def', + stage: CiStage.fusionTests, + ); + + firestoreService.putDocuments([guard1, guard2, otherGuard]); + + final results = await UnifiedCheckRun.getPresubmitGuardsForCommitSha( + firestoreService: firestoreService, + slug: slug, + commitSha: commitSha, + ); + + expect(results.length, 2); + expect(results.any((g) => g.stage == CiStage.fusionTests), isTrue); + expect(results.any((g) => g.stage == CiStage.fusionEngineBuild), isTrue); + }); + + test( + 'queryPresubmitGuards returns empty list when no guards found', + () async { + final slug = RepositorySlug('flutter', 'flutter'); + final results = await UnifiedCheckRun.getPresubmitGuardsForCommitSha( + firestoreService: firestoreService, + slug: slug, + commitSha: 'non-existent', + ); + + expect(results, isEmpty); + }, + ); +} diff --git a/app_dart/test/src/utilities/entity_generators.dart b/app_dart/test/src/utilities/entity_generators.dart index 71a2d42ad..db320f0ac 100644 --- a/app_dart/test/src/utilities/entity_generators.dart +++ b/app_dart/test/src/utilities/entity_generators.dart @@ -5,9 +5,11 @@ import 'package:buildbucket/buildbucket_pb.dart' as bbv2; import 'package:cocoon_common/task_status.dart'; import 'package:cocoon_service/ci_yaml.dart'; +import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/commit.dart'; import 'package:cocoon_service/src/model/firestore/github_build_status.dart'; import 'package:cocoon_service/src/model/firestore/github_gold_status.dart'; +import 'package:cocoon_service/src/model/firestore/presubmit_guard.dart'; import 'package:cocoon_service/src/model/firestore/suppressed_test.dart'; import 'package:cocoon_service/src/model/firestore/task.dart'; import 'package:cocoon_service/src/model/gerrit/commit.dart'; @@ -308,3 +310,28 @@ SuppressedTest generateSuppressedTest({ issueLink: issueLink, createTimestamp: createTimestamp ?? DateTime.fromMillisecondsSinceEpoch(1), )..name = '$kDatabase/documents/${SuppressedTest.kCollectionId}/$name'; + +PresubmitGuard generatePresubmitGuard({ + github.RepositorySlug? slug, + int pullRequestId = 123, + github.CheckRun? checkRun, + CiStage stage = CiStage.fusionTests, + String commitSha = 'abc', + int creationTime = 1, + String author = 'dash', + int buildCount = 1, + Map? builds, +}) { + return PresubmitGuard( + slug: slug ?? github.RepositorySlug('flutter', 'flutter'), + pullRequestId: pullRequestId, + checkRun: checkRun ?? generateCheckRun(1), + stage: stage, + commitSha: commitSha, + creationTime: creationTime, + author: author, + remainingBuilds: buildCount, + failedBuilds: 0, + builds: builds, + ); +} diff --git a/cocoon.code-workspace b/cocoon.code-workspace index 85a636ce9..b4b45992c 100644 --- a/cocoon.code-workspace +++ b/cocoon.code-workspace @@ -2,7 +2,7 @@ "folders": [ { "path": "analyze" - }, + }, { "path": "app_dart" }, @@ -29,6 +29,9 @@ }, { "path": "tooling" + }, + { + "path": "conductor" } ], "settings": {} diff --git a/conductor/archive/get_presubmit_guard_20260204/index.md b/conductor/archive/get_presubmit_guard_20260204/index.md new file mode 100644 index 000000000..77b51d306 --- /dev/null +++ b/conductor/archive/get_presubmit_guard_20260204/index.md @@ -0,0 +1,5 @@ +# Track get_presubmit_guard_20260204 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/get_presubmit_guard_20260204/metadata.json b/conductor/archive/get_presubmit_guard_20260204/metadata.json new file mode 100644 index 000000000..96cc3d944 --- /dev/null +++ b/conductor/archive/get_presubmit_guard_20260204/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "get_presubmit_guard_20260204", + "type": "feature", + "status": "new", + "created_at": "2026-02-04T12:00:00Z", + "updated_at": "2026-02-04T12:00:00Z", + "description": "Implement get presubmit guard api" +} diff --git a/conductor/archive/get_presubmit_guard_20260204/plan.md b/conductor/archive/get_presubmit_guard_20260204/plan.md new file mode 100644 index 000000000..aef4c8d86 --- /dev/null +++ b/conductor/archive/get_presubmit_guard_20260204/plan.md @@ -0,0 +1,36 @@ +# Implementation Plan: Implement get presubmit guard api + +## Phase 1: Infrastructure & Data Model +Establish the data structures and service interfaces required to interact with Firestore for `PresubmitGuard` records. + +- [x] Task: Define `PresubmitGuard` Model and DTOs [checkpoint: 8f78382] + - [x] Create/Update the Firestore model for `PresubmitGuard` if not already present in `app_dart/lib/src/model/firestore/`. + - [x] Create a `GetPresubmitGuardResponse` DTO to match the specified API response format (pr_num, check_run_id, author, stages). +- [x] Task: Create Firestore Service Method [checkpoint: 8f78382] + - [x] Implement a method in the Firestore service (e.g., `app_dart/lib/src/service/firestore.dart`) to query `PresubmitGuard` records by `slug` and `commit_sha`. +- [x] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Data Model' (Protocol in workflow.md) + +## Phase 2: API Endpoint Implementation +Implement the request handler and wire it into the `app_dart` server. + +- [x] Task: Implement `GetPresubmitGuard` Request Handler [checkpoint: 8f78382] + - [x] Create a new request handler class in `app_dart/lib/src/request_handlers/`. + - [x] Implement logic to: + - Extract `slug` and `sha` from query parameters. + - Call the Firestore service to retrieve records. + - Consolidate records into the `GetPresubmitGuardResponse` format. + - Handle cases with no matching records (e.g., return 404 or empty response). +- [x] Task: Register Endpoint in Server [checkpoint: 8f78382] + - [x] Add the new `/api/get-presubmit-guard` route to the `app_dart` server configuration (e.g., `app_dart/lib/src/server.dart` or equivalent). +- [x] Task: Conductor - User Manual Verification 'Phase 2: API Endpoint Implementation' (Protocol in workflow.md) + +## Phase 3: Verification & Documentation +Ensure the feature is robust, tested, and documented. + +- [x] Task: Write Unit Tests for Request Handler [checkpoint: 8f78382] + - [x] Implement tests in `app_dart/test/request_handlers/` using mocked Firestore service. + - [x] Verify consolidation logic for multiple stages. + - [x] Verify mapping of `pull_request_id` to `pr_num`. +- [x] Task: Update API Documentation [checkpoint: 8f78382] + - [x] Document the new endpoint in the project's API docs or README. +- [x] Task: Conductor - User Manual Verification 'Phase 3: Verification & Documentation' (Protocol in workflow.md) diff --git a/conductor/archive/get_presubmit_guard_20260204/spec.md b/conductor/archive/get_presubmit_guard_20260204/spec.md new file mode 100644 index 000000000..ee4acf2cd --- /dev/null +++ b/conductor/archive/get_presubmit_guard_20260204/spec.md @@ -0,0 +1,40 @@ +# Specification: Implement get presubmit guard api + +## Overview +This track involves implementing a new backend API endpoint in the `app_dart` service to serve real-time presubmit check statuses (based on the `PresubmitGuard` entity) to the Cocoon dashboard. The dashboard needs to display the progress and results of various validation stages for active pull requests to provide developers with actionable visibility into their PR health. + +## User Stories +As a Flutter developer, I want to see the real-time status of my PR's presubmit checks on the Cocoon dashboard so that I can quickly identify and address failures without navigating through multiple GitHub or LUCI pages. + +## Functional Requirements +1. **New API Endpoint:** Create an authenticated GET endpoint in `app_dart` (e.g., `/api/get-presubmit-guard`). +2. **Input Parameters:** The endpoint must accept a `slug` (e.g., `flutter/flutter`) and a `commit_sha` as query parameters. +3. **Data Retrieval:** + - Query Cloud Firestore for all `PresubmitGuard` records matching the provided slug and commit SHA. + - Note: There is a separate `PresubmitGuard` record for every stage (e.g., one for `fusion`, one for `engine`). +4. **Response Format:** Return a single consolidated JSON object containing: + - `pr_num`: The GitHub Pull Request Number (mapped from `pull_request_id` in Firestore). + - `check_run_id`: The GitHub Check Run ID (shared across all stage records). + - `author`: The GitHub handle of the PR author (shared across all stage records). + - `stages`: A list of objects (one for each record found), each containing: + - `name`: The name of the stage (e.g., `fusion` or `engine`). + - `created_at`: The timestamp when the stage was created/started. + - `builds`: A map where keys are `check_name` and values are of type `TaskStatus`. + - **Sample `TaskStatus` values:** "Cancelled", "New", "In Progress", "Infra Failure", "Failed", "Succeeded", "Skipped". + +## Non-Functional Requirements +- **Performance:** The endpoint should respond within 200ms for typical queries. +- **Reliability:** Handle cases where Firestore records may be partially populated or missing due to webhook latency. Consolidate metadata from the first available record. +- **Security:** Ensure the endpoint is protected by existing authentication mechanisms used for dashboard APIs. + +## Acceptance Criteria +- [ ] A new GET endpoint exists in `app_dart` that accepts `slug` and `sha`. +- [ ] The endpoint correctly retrieves all matching `PresubmitGuard` records and consolidates them into a single response. +- [ ] The response includes the shared `pr_num`, `check_run_id`, `author`, and the list of individual stages with their build statuses. +- [ ] Unit tests in `app_dart` verify the consolidation logic with mocked Firestore data. +- [ ] The API documentation is updated to include this new endpoint. + +## Out of Scope +- Implementing the frontend UI in the `dashboard/` package. +- Modifying the webhook ingestion logic (assuming `PresubmitGuard` is already being populated correctly). +- Support for historical/archived presubmit data (focus is on real-time). diff --git a/conductor/product.md b/conductor/product.md index 40860ad37..93b76087b 100644 --- a/conductor/product.md +++ b/conductor/product.md @@ -19,6 +19,7 @@ Cocoon is the CI coordination and orchestration system for the Flutter project. * **Tree Status Dashboard:** A Flutter-based web application that provides a visual overview of build health across various commits and branches. * **Presubmit Check Details:** APIs to retrieve detailed attempt history and status for specific presubmit checks, aiding in debugging and visibility. * **Merge Queue Visibility:** APIs for querying and inspecting recent GitHub Merge Queue webhook events to diagnose integration issues. +* **Presubmit Visibility:** Backend APIs to provide real-time status of presubmit checks (Presubmit Guard) to the dashboard for active pull requests. * **Auto-submit Bot:** Handles automated pull request management, including label-based merges, reverts, and validation checks. * **GitHub Integration:** Robust handling of GitHub webhooks to sync commits, manage check runs, and report build statuses back to PRs. * **Unified Data Store:** Leverages Cloud Firestore and BigQuery for tracking build history, metrics, and CI health trends. diff --git a/packages/cocoon_common/lib/guard_status.dart b/packages/cocoon_common/lib/guard_status.dart new file mode 100644 index 000000000..5f4c45333 --- /dev/null +++ b/packages/cocoon_common/lib/guard_status.dart @@ -0,0 +1,29 @@ +// Copyright 2026 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. + +/// Represents different states of a presubmit guard. +library; + +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum(valueField: 'value') +enum GuardStatus { + /// The guard is waiting for backfill. + waitingForBackfill('New'), + + /// The guard is in progress. + inProgress('In Progress'), + + /// The guard has failed. + failed('Failed'), + + /// The guard ran successfully. + succeeded('Succeeded'); + + const GuardStatus(this.value); + final String value; + + /// Returns the JSON representation of `this`. + Object? toJson() => value; +} diff --git a/packages/cocoon_common/lib/rpc_model.dart b/packages/cocoon_common/lib/rpc_model.dart index f201c7599..9de70abc0 100644 --- a/packages/cocoon_common/lib/rpc_model.dart +++ b/packages/cocoon_common/lib/rpc_model.dart @@ -36,6 +36,8 @@ export 'src/rpc_model/merge_group_hooks.dart' show MergeGroupHook, MergeGroupHooks; export 'src/rpc_model/presubmit_check_response.dart' show PresubmitCheckResponse; +export 'src/rpc_model/presubmit_guard.dart' + show PresubmitGuardResponse, PresubmitGuardStage; export 'src/rpc_model/suppressed_test.dart' show SuppressedTest, SuppressionUpdate; export 'src/rpc_model/task.dart' show Task; diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.dart new file mode 100644 index 000000000..7c1c0b4d7 --- /dev/null +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.dart @@ -0,0 +1,77 @@ +// Copyright 2026 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:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; + +import '../../guard_status.dart'; +import '../../task_status.dart'; + +part 'presubmit_guard.g.dart'; + +/// Response model for a Presubmit Guard. +/// +/// Contains the aggregated status and stages of presubmit checks for a specific commit. +@immutable +@JsonSerializable(fieldRename: FieldRename.snake) +final class PresubmitGuardResponse { + const PresubmitGuardResponse({ + required this.prNum, + required this.checkRunId, + required this.author, + required this.stages, + required this.guardStatus, + }); + + /// The pull request number. + final int prNum; + + /// The check run ID associated with the presubmit guard. + final int checkRunId; + + /// The login name of the author of the commit or pull request. + final String author; + + /// The list of stages and their corresponding builds for this presubmit guard. + final List stages; + + /// The overall status of the presubmit guard across all stages. + final GuardStatus guardStatus; + + /// Creates a [PresubmitGuardResponse] from a JSON map. + factory PresubmitGuardResponse.fromJson(Map json) => + _$PresubmitGuardResponseFromJson(json); + + /// Converts this [PresubmitGuardResponse] to a JSON map. + Map toJson() => _$PresubmitGuardResponseToJson(this); +} + +/// Represents a single stage in a presubmit guard. +/// +/// A stage groups related builds (tasks) together, for example, 'fusion' or 'engine'. +@immutable +@JsonSerializable(fieldRename: FieldRename.snake) +final class PresubmitGuardStage { + const PresubmitGuardStage({ + required this.name, + required this.createdAt, + required this.builds, + }); + + /// The name of the stage (e.g., 'fusion', 'engine'). + final String name; + + /// The creation timestamp of this stage in milliseconds since the epoch. + final int createdAt; + + /// Map of build names to their current statuses. + final Map builds; + + /// Creates a [PresubmitGuardStage] from a JSON map. + factory PresubmitGuardStage.fromJson(Map json) => + _$PresubmitGuardStageFromJson(json); + + /// Converts this [PresubmitGuardStage] to a JSON map. + Map toJson() => _$PresubmitGuardStageToJson(this); +} diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart new file mode 100644 index 000000000..25ff56afc --- /dev/null +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'presubmit_guard.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PresubmitGuardResponse _$PresubmitGuardResponseFromJson( + Map json, +) => PresubmitGuardResponse( + prNum: (json['pr_num'] as num).toInt(), + checkRunId: (json['check_run_id'] as num).toInt(), + author: json['author'] as String, + stages: (json['stages'] as List) + .map((e) => PresubmitGuardStage.fromJson(e as Map)) + .toList(), + guardStatus: $enumDecode(_$GuardStatusEnumMap, json['guard_status']), +); + +Map _$PresubmitGuardResponseToJson( + PresubmitGuardResponse instance, +) => { + 'pr_num': instance.prNum, + 'check_run_id': instance.checkRunId, + 'author': instance.author, + 'stages': instance.stages, + 'guard_status': instance.guardStatus, +}; + +const _$GuardStatusEnumMap = { + GuardStatus.waitingForBackfill: 'New', + GuardStatus.inProgress: 'In Progress', + GuardStatus.failed: 'Failed', + GuardStatus.succeeded: 'Succeeded', +}; + +PresubmitGuardStage _$PresubmitGuardStageFromJson(Map json) => + PresubmitGuardStage( + name: json['name'] as String, + createdAt: (json['created_at'] as num).toInt(), + builds: (json['builds'] as Map).map( + (k, e) => MapEntry(k, $enumDecode(_$TaskStatusEnumMap, e)), + ), + ); + +Map _$PresubmitGuardStageToJson( + PresubmitGuardStage instance, +) => { + 'name': instance.name, + 'created_at': instance.createdAt, + 'builds': instance.builds, +}; + +const _$TaskStatusEnumMap = { + TaskStatus.cancelled: 'Cancelled', + TaskStatus.waitingForBackfill: 'New', + TaskStatus.inProgress: 'In Progress', + TaskStatus.infraFailure: 'Infra Failure', + TaskStatus.failed: 'Failed', + TaskStatus.succeeded: 'Succeeded', + TaskStatus.skipped: 'Skipped', +}; diff --git a/packages/cocoon_common/lib/task_status.dart b/packages/cocoon_common/lib/task_status.dart index fa933fd3c..993f5f2db 100644 --- a/packages/cocoon_common/lib/task_status.dart +++ b/packages/cocoon_common/lib/task_status.dart @@ -5,6 +5,9 @@ import 'package:collection/collection.dart'; /// Represents differerent states of a task, or an execution of a build target. +import 'package:json_annotation/json_annotation.dart'; + +@JsonEnum(valueField: 'value') enum TaskStatus { /// The task was cancelled. cancelled('Cancelled'),