Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app_dart/lib/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Server createServer({
githubChecksService: githubChecksService,
scheduler: scheduler,
ciYamlFetcher: ciYamlFetcher,
firestore: firestore,
),
'/api/v2/postsubmit-luci-subscription': PostsubmitLuciSubscription(
cache: cache,
Expand Down Expand Up @@ -218,6 +219,7 @@ Server createServer({
authenticationProvider: dashboardAuthProvider,
firestore: firestore,
config: config,
cache: cache,
),
'/api/merge_queue_hooks': MergeQueueHooks(
authenticationProvider: dashboardAuthProvider,
Expand Down
30 changes: 24 additions & 6 deletions app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,19 @@ import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:buildbucket/buildbucket_pb.dart' as bbv2;
import 'package:cocoon_server/logging.dart';
import 'package:github/github.dart';

import '../../cocoon_service.dart';
import '../model/bbv2_extension.dart';
import '../model/ci_yaml/ci_yaml.dart';
import '../model/ci_yaml/target.dart';
import '../model/commit_ref.dart';
import '../model/common/presubmit_completed_check.dart';
import '../request_handling/authentication.dart';
import '../request_handling/exceptions.dart';
import '../request_handling/request_handler.dart';
import '../request_handling/response.dart';
import '../request_handling/subscription_handler.dart';
import '../service/github_checks_service.dart';
import '../service/luci_build_service.dart';
import '../service/extensions/cache_service_test_suppression.dart';
import '../service/luci_build_service/build_tags.dart';
import '../service/luci_build_service/user_data.dart';
import '../service/scheduler.dart';
import '../service/scheduler/ci_yaml_fetcher.dart';

/// An endpoint for listening to LUCI status updates for scheduled builds.
Expand All @@ -46,17 +43,20 @@ final class PresubmitLuciSubscription extends SubscriptionHandler {
required GithubChecksService githubChecksService,
required CiYamlFetcher ciYamlFetcher,
required Scheduler scheduler,
required FirestoreService firestore,
AuthenticationProvider? authProvider,
}) : _ciYamlFetcher = ciYamlFetcher,
_githubChecksService = githubChecksService,
_luciBuildService = luciBuildService,
_scheduler = scheduler,
_firestore = firestore,
super(subscriptionName: 'build-bucket-presubmit-sub');

final LuciBuildService _luciBuildService;
final GithubChecksService _githubChecksService;
final CiYamlFetcher _ciYamlFetcher;
final Scheduler _scheduler;
final FirestoreService _firestore;

@override
Future<Response> post(Request request) async {
Expand Down Expand Up @@ -134,12 +134,30 @@ final class PresubmitLuciSubscription extends SubscriptionHandler {
}
}
if (!isUnifiedCheckRun) {
String? suppressedMessage;
CheckRunConclusion? override;
if (build.status.isTaskFailed() && !rescheduled) {
// If a test is suppressed; we avoid setting a failing status.
final isSuppressed = await cache.isTestSuppressed(
testName: builderName,
repository: userData.commit.slug,
firestore: _firestore,
);
if (isSuppressed) {
override = CheckRunConclusion.neutral;
suppressedMessage =
'### ⚠️ Test failed but marked as suppressed on dashboard';
}
}

await _githubChecksService.updateCheckStatus(
checkRunId: userData.checkRunId!,
build: build,
luciBuildService: _luciBuildService,
slug: userData.commit.slug,
rescheduled: rescheduled,
conclusionOverride: override,
summaryPrepend: suppressedMessage,
);
}
if (!rescheduled) {
Expand Down
18 changes: 13 additions & 5 deletions app_dart/lib/src/request_handlers/update_suppressed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ import 'package:github/github.dart';
import 'package:googleapis/firestore/v1.dart';
import 'package:meta/meta.dart';

import '../model/firestore/suppressed_test.dart';
import '../../cocoon_service.dart';
import '../request_handling/api_request_handler.dart';
import '../request_handling/exceptions.dart';
import '../request_handling/request_handler.dart'; // For Request
import '../request_handling/response.dart';
import '../service/firestore.dart';
import '../service/extensions/cache_service_test_suppression.dart';

/// Manually updates the test suppression status.
///
Expand All @@ -30,12 +28,15 @@ final class UpdateSuppressedTest extends ApiRequestHandler {
required FirestoreService firestore,
required super.config,
required super.authenticationProvider,
required CacheService cache,
@visibleForTesting DateTime Function() now = DateTime.now,
}) : _firestore = firestore,
_now = now;
_now = now,
_cache = cache;

final FirestoreService _firestore;
final DateTime Function() _now;
final CacheService _cache;

static const _paramTestName = 'testName';
static const _paramRepository = 'repository';
Expand Down Expand Up @@ -239,5 +240,12 @@ final class UpdateSuppressedTest extends ApiRequestHandler {
collectionId: SuppressedTest.kCollectionId,
);
}

// Update cache now that we've written it
await _cache.setTestSuppression(
testName: testName,
repository: repository,
isSuppressed: isSuppressed,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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:github/github.dart';

import '../../model/firestore/suppressed_test.dart';
import '../cache_service.dart';
import '../firestore.dart';

/// Handy extension on the caching service for test suppression.
extension SuppressedTestCache on CacheService {
static const String _subcacheName = 'test_suppression';

Future<bool> isTestSuppressed({
required String testName,
required RepositorySlug repository,
required FirestoreService firestore,
}) async {
final cacheValue = await getOrCreate(
_subcacheName,
'${repository.fullName}/$testName',
createFn: () async {
final latest = await SuppressedTest.getLatest(
firestore,
repository.fullName,
testName,
);
if (latest == null) return false.toUint8List();
return latest.isSuppressed.toUint8List();
},
// Only cache for a short while.
ttl: const Duration(minutes: 5),
);

return cacheValue?.toBool() ?? false;
}

Future<void> setTestSuppression({
required String testName,
required RepositorySlug repository,
required bool isSuppressed,
}) async {
await set(
_subcacheName,
'${repository.fullName}/$testName',
isSuppressed.toUint8List(),
ttl: const Duration(minutes: 5),
);
}
}
19 changes: 12 additions & 7 deletions app_dart/lib/src/service/github_checks_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class GithubChecksService {
required github.RepositorySlug slug,
required int checkRunId,
bool rescheduled = false,
github.CheckRunConclusion? conclusionOverride,
String? summaryPrepend,
}) async {
var status = statusForResult(build.status);
log.info('status for build ${build.id} is ${status.value}');
Expand All @@ -63,9 +65,11 @@ class GithubChecksService {
'name': build.builder.builder,
});

var conclusion = (terminalStatuses.contains(build.status))
? conclusionForResult(build.status)
: null;
var conclusion =
conclusionOverride ??
((terminalStatuses.contains(build.status))
? conclusionForResult(build.status)
: null);
log.info(
'conclusion for build ${build.id} is ${(conclusion != null) ? conclusion.value : null}',
);
Expand Down Expand Up @@ -94,10 +98,11 @@ class GithubChecksService {
allFields: true,
),
);
output = github.CheckRunOutput(
title: checkRun.name!,
summary: getGithubSummary(buildbucketBuild.summaryMarkdown),
);
var summary = getGithubSummary(buildbucketBuild.summaryMarkdown);
if (summaryPrepend != null && summaryPrepend.isNotEmpty) {
summary = '$summaryPrepend\n\n$summary';
}
output = github.CheckRunOutput(title: checkRun.name!, summary: summary);
log.debug(
'Updating check run with output: [${output.toJson().toString()}]',
);
Expand Down
117 changes: 116 additions & 1 deletion app_dart/test/request_handlers/presubmit_luci_subscription_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ void main() {
late MockLuciBuildService mockLuciBuildService;
late FakeCiYamlFetcher ciYamlFetcher;
late MockScheduler mockScheduler;
late FakeFirestoreService firestore;

setUp(() async {
final firestore = FakeFirestoreService();
firestore = FakeFirestoreService();

config = FakeConfig(
dynamicConfig: DynamicConfig.fromJson({
Expand All @@ -62,6 +63,7 @@ void main() {
authProvider: FakeDashboardAuthentication(),
scheduler: mockScheduler,
ciYamlFetcher: ciYamlFetcher,
firestore: firestore,
);
request = FakeHttpRequest();

Expand Down Expand Up @@ -274,6 +276,7 @@ void main() {
authProvider: FakeDashboardAuthentication(),
scheduler: mockScheduler,
ciYamlFetcher: ciYamlFetcher,
firestore: firestore,
);

await tester.post(luciHandler);
Expand Down Expand Up @@ -506,6 +509,7 @@ void main() {
authProvider: FakeDashboardAuthentication(),
scheduler: mockScheduler,
ciYamlFetcher: ciYamlFetcher,
firestore: firestore,
);

await tester.post(luciHandler);
Expand Down Expand Up @@ -577,4 +581,115 @@ void main() {
.having((e) => e.headBranch, 'headBranch', 'master'),
);
});

test('Requests when task failed and is suppressed', () async {
final userData = PresubmitUserData(
commit: CommitRef(
sha: 'abc',
branch: 'master',
slug: RepositorySlug('flutter', 'flutter'),
),
checkRunId: 1,
checkSuiteId: 2,
);

// Setup Firestore
firestore.putDocument(
SuppressedTest(
name: 'Linux A',
repository: 'flutter/flutter',
issueLink: 'https://github.com/flutter/flutter/issues/123',
isSuppressed: true,
createTimestamp: DateTime.now(),
)
..name = firestore.resolveDocumentName(
SuppressedTest.kCollectionId,
'suppressed_1',
),
);

when(
mockGithubChecksService.updateCheckStatus(
build: anyNamed('build'),
checkRunId: anyNamed('checkRunId'),
luciBuildService: anyNamed('luciBuildService'),
slug: anyNamed('slug'),
conclusionOverride: github.CheckRunConclusion.neutral,
summaryPrepend: argThat(
contains('marked as suppressed'),
named: 'summaryPrepend',
),
),
).thenAnswer((_) async => true);

when(
mockScheduler.processCheckRunCompleted(any),
).thenAnswer((_) async => true);

tester.message = createPushMessage(
Int64(1),
status: bbv2.Status.FAILURE,
builder: 'Linux A',
userData: userData,
);

await tester.post(handler);

verify(
mockGithubChecksService.updateCheckStatus(
build: anyNamed('build'),
checkRunId: anyNamed('checkRunId'),
luciBuildService: anyNamed('luciBuildService'),
slug: anyNamed('slug'),
conclusionOverride: github.CheckRunConclusion.neutral,
summaryPrepend: argThat(
contains('### ⚠️ Test failed but marked as suppressed on dashboard'),
named: 'summaryPrepend',
),
),
).called(1);
});

test('Suppression check skipped when rescheduled', () async {
tester.message = createPushMessage(
Int64(1),
status: bbv2.Status.FAILURE,
builder: 'Linux presubmit_max_attempts=2',
userData: PresubmitUserData(
commit: CommitRef(
sha: 'abc',
branch: 'master',
slug: RepositorySlug('flutter', 'flutter'),
),
checkRunId: 1,
checkSuiteId: 2,
),
);

when(
mockGithubChecksService.updateCheckStatus(
build: anyNamed('build'),
checkRunId: anyNamed('checkRunId'),
luciBuildService: anyNamed('luciBuildService'),
slug: anyNamed('slug'),
rescheduled: true,
conclusionOverride: null,
summaryPrepend: null,
),
).thenAnswer((_) async => true);

await tester.post(handler);

verify(
mockGithubChecksService.updateCheckStatus(
build: anyNamed('build'),
checkRunId: anyNamed('checkRunId'),
luciBuildService: anyNamed('luciBuildService'),
slug: anyNamed('slug'),
rescheduled: true,
conclusionOverride: null,
summaryPrepend: null,
),
).called(1);
});
}
Loading
Loading