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/cocoon_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export 'src/service/flags/ordered_presubmit_flags.dart';
export 'src/service/flags/unified_check_run_flow_flags.dart';
export 'src/service/gerrit_service.dart';
export 'src/service/github_checks_service.dart';
export 'src/service/issue_service.dart';
export 'src/service/luci_build_service.dart';
export 'src/service/pull_request_manager.dart';
export 'src/service/scheduler.dart';
export 'src/service/test_suppression.dart';
7 changes: 6 additions & 1 deletion app_dart/lib/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import 'src/service/content_aware_hash_service.dart';
import 'src/service/discord_service.dart';
import 'src/service/log_analyzer.dart';
import 'src/service/scheduler/ci_yaml_fetcher.dart';
import 'src/service/test_suppression.dart';

typedef Server = Future<void> Function(Request);

Expand Down Expand Up @@ -66,6 +65,11 @@ Server createServer({
cache: cache,
);

final issueService = IssueService(
firestore: firestore,
suppressionService: suppressionService,
);

final handlers = <String, RequestHandler>{
'/api/analyze-logs': AnalyzeLogs(
config: config,
Expand Down Expand Up @@ -110,6 +114,7 @@ Server createServer({
scheduler: scheduler,
commitService: commitService,
firestore: firestore,
issueService: issueService,
),
'/api/v2/presubmit-luci-subscription': PresubmitLuciSubscription(
cache: cache,
Expand Down
51 changes: 34 additions & 17 deletions app_dart/lib/src/model/firestore/suppressed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,18 @@ final class SuppressedTest extends AppDocument<SuppressedTest> {
'$fieldRepository =': repository,
'$fieldIsSuppressed =': true,
});
return docs.isEmpty
? []
: [for (final doc in docs) SuppressedTest.fromDocument(doc)];
return [for (final doc in docs) SuppressedTest.fromDocument(doc)];
}

/// Returns all [SuppressedTest] documents matching [issueLink].
static Future<List<SuppressedTest>> getByIssueLink(
FirestoreService firestore,
String issueLink,
) async {
final docs = await firestore.query(kCollectionId, {
'$fieldIssueLink =': issueLink,
});
return [for (final doc in docs) SuppressedTest.fromDocument(doc)];
}

/// Creates a new [SuppressedTest] document.
Expand All @@ -93,39 +102,47 @@ final class SuppressedTest extends AppDocument<SuppressedTest> {
}

/// The misbehaving test.
String get testName => fields[fieldName]!.stringValue!;
String get testName => fields[fieldName]?.stringValue ?? '';

/// The repository this test is evaluated with.
String get repository => fields[fieldRepository]!.stringValue!;
String get repository => fields[fieldRepository]?.stringValue ?? '';

/// A required github issue link describing why the test is suppressed.
String get issueLink => fields[fieldIssueLink]!.stringValue!;
String get issueLink => fields[fieldIssueLink]?.stringValue ?? '';

/// Whether this test is currently suppressed.
bool get isSuppressed => fields[fieldIsSuppressed]!.booleanValue!;
bool get isSuppressed => fields[fieldIsSuppressed]?.booleanValue ?? false;

/// When this document was created.
DateTime get createTimestamp =>
DateTime.parse(fields[fieldCreateTimestamp]!.timestampValue!);
DateTime get createTimestamp {
final timestamp = fields[fieldCreateTimestamp]?.timestampValue;
return timestamp != null
? DateTime.parse(timestamp)
: DateTime.fromMillisecondsSinceEpoch(0);
}

/// A list of updates to this document for audit purposes.
List<Map<String, dynamic>> get updates {
final values = fields[fieldUpdates]?.arrayValue?.values;
if (values == null) {
return const [];
}
return [...values.map(_valueToUpdateMap)];
return [for (final value in values) _valueToUpdateMap(value)];
}

static Map<String, dynamic> _valueToUpdateMap(Value value) {
final fields = value.mapValue!.fields!;
final fields = value.mapValue?.fields;
if (fields == null) {
return const {};
}
return {
updateFieldUser: fields[updateFieldUser]!.stringValue!,
updateFieldUpdateTimestamp: DateTime.parse(
fields[updateFieldUpdateTimestamp]!.timestampValue!,
),
updateFieldNote: fields[updateFieldNote]!.stringValue!,
updateFieldAction: fields[updateFieldAction]!.stringValue!,
updateFieldUser: fields[updateFieldUser]?.stringValue ?? '',
updateFieldUpdateTimestamp:
fields[updateFieldUpdateTimestamp]?.timestampValue != null
? DateTime.parse(fields[updateFieldUpdateTimestamp]!.timestampValue!)
: DateTime.fromMillisecondsSinceEpoch(0),
updateFieldNote: fields[updateFieldNote]?.stringValue ?? '',
updateFieldAction: fields[updateFieldAction]?.stringValue ?? '',
};
}
}
24 changes: 24 additions & 0 deletions app_dart/lib/src/request_handlers/github/webhook_subscription.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ final class GithubWebhookSubscription extends SubscriptionHandler {
required this.gerritService,
required this.commitService,
required this.firestore,
this.issueService,
super.authProvider,
this.pullRequestLabelProcessorProvider = PullRequestLabelProcessor.new,
@visibleForTesting DateTime Function() now = DateTime.now,
Expand All @@ -63,6 +64,7 @@ final class GithubWebhookSubscription extends SubscriptionHandler {

final FirestoreService firestore;
final PullRequestLabelProcessorProvider pullRequestLabelProcessorProvider;
final IssueService? issueService;

@override
Future<Response> post(Request request) async {
Expand All @@ -78,6 +80,8 @@ final class GithubWebhookSubscription extends SubscriptionHandler {
switch (webhook.event) {
case 'pull_request':
return _handlePullRequest(webhook.payload);
case 'issues':
return _handleIssues(webhook.payload);
case 'merge_group':
final result = await _handleMergeGroup(
webhook.payload,
Expand Down Expand Up @@ -417,6 +421,26 @@ final class GithubWebhookSubscription extends SubscriptionHandler {
}
}

Future<Response> _handleIssues(String rawRequest) async {
final issueEvent = _getIssueEvent(rawRequest);
if (issueEvent == null) {
throw const BadRequestException('Expected issue event.');
}
if (issueService case final service?) {
await service.handleIssueEvent(issueEvent);
}
return Response.emptyOk;
}

IssueEvent? _getIssueEvent(String request) {
try {
return IssueEvent.fromJson(jsonDecode(request) as Map<String, dynamic>);
} catch (e, s) {
log.warn('_getIssueEvent: Failed to parse $request', e, s);
return null;
}
}

LabeledEvent? _getLabeledEvent(String request) {
try {
return LabeledEvent.fromJson(
Expand Down
26 changes: 5 additions & 21 deletions app_dart/lib/src/request_handlers/update_suppressed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import 'package:github/github.dart';
import '../../cocoon_service.dart';
import '../request_handling/api_request_handler.dart';
import '../request_handling/exceptions.dart';
import '../service/test_suppression.dart'
show SuppressingAction, TestSuppression;

/// Manually updates the test suppression status.
///
Expand Down Expand Up @@ -97,16 +95,17 @@ final class UpdateSuppressedTest extends ApiRequestHandler {
'Parameter "$_paramIssueLink" must be a string',
);
}
issueLink = link;

// Validate issue link
final issueNumber = _parseIssueNumber(issueLink);
if (issueNumber == null) {
final canonicalUrl = TestSuppression.canonicalizeIssueUrl(link);
if (canonicalUrl == null) {
throw const BadRequestException(
'Invalid issue link format, expected https://github.com/flutter/flutter/issues/1234',
);
}

issueLink = canonicalUrl;
final issueNumber = int.parse(Uri.parse(canonicalUrl).pathSegments.last);

final githubService = await config.createGithubService(repository);
final Issue? issue;
try {
Expand Down Expand Up @@ -146,19 +145,4 @@ final class UpdateSuppressedTest extends ApiRequestHandler {

return Response.emptyOk;
}

int? _parseIssueNumber(String issueLink) {
// Expected format: https://github.com/flutter/flutter/issues/123456
final uri = Uri.tryParse(issueLink);
if (uri == null ||
uri.host != 'github.com' ||
uri.pathSegments.length < 4 ||
uri.pathSegments[uri.pathSegments.length - 2] != 'issues') {
return null;
}

// Path segments: [flutter, flutter, issues, 123456]
// Or just check the last segment if it is a number
return int.tryParse(uri.pathSegments.last);
}
}
111 changes: 111 additions & 0 deletions app_dart/lib/src/service/issue_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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:cocoon_server/logging.dart';
import 'package:github/github.dart';
import 'package:github/hooks.dart';

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

/// Processes GitHub issue events to unsuppress or re-suppress matching tests.
final class IssueService {
const IssueService({
required this.firestore,
required this.suppressionService,
});

final FirestoreService firestore;
final TestSuppression suppressionService;

/// Re-opens or closes suppressed tests matching a GitHub [IssueEvent].
Future<void> handleIssueEvent(IssueEvent event) async {
if (event.issue?.htmlUrl case final issueUrl? when issueUrl.isNotEmpty) {
switch (event.action) {
Comment thread
jtmcdole marked this conversation as resolved.
case 'closed':
await _handleIssueClosed(event, issueUrl);
case 'reopened':
await _handleIssueReopened(event, issueUrl);
case final action:
log.debug('Ignoring issue event action: $action');
}
return;
}
log.debug('IssueEvent has no htmlUrl; skipping.');
}

Future<void> _handleIssueClosed(IssueEvent event, String issueUrl) async {
final docs = await SuppressedTest.getByIssueLink(firestore, issueUrl);
final processed = <String>{};

for (final doc in docs) {
// Tests can be suppressed at different times; we only need to handle
// this event once per unique test.
final key = '${doc.repository}/${doc.testName}';
if (!processed.add(key)) {
continue;
}

final latest = await SuppressedTest.getLatest(
Comment thread
jtmcdole marked this conversation as resolved.
firestore,
doc.repository,
doc.testName,
);
if (latest == null ||
latest.issueLink != issueUrl ||
!latest.isSuppressed) {
continue;
}

log.info(
'Unsuppressing test ${doc.testName} in ${doc.repository} due to issue closed ($issueUrl)',
);
await suppressionService.updateSuppression(
testName: doc.testName,
email: event.sender?.login ?? 'github-webhook',
repository: RepositorySlug.full(doc.repository),
action: SuppressingAction.unsuppress,
note: 'Automatic unsuppression: issue $issueUrl was closed.',
);
}
}

Future<void> _handleIssueReopened(IssueEvent event, String issueUrl) async {
final docs = await SuppressedTest.getByIssueLink(firestore, issueUrl);
final processed = <String>{};

for (final doc in docs) {
// Tests can be suppressed at different times; we only need to handle
// this event once per unique test.
final key = '${doc.repository}/${doc.testName}';
if (!processed.add(key)) {
continue;
}

final latest = await SuppressedTest.getLatest(
Comment thread
jtmcdole marked this conversation as resolved.
firestore,
doc.repository,
doc.testName,
);
if (latest == null ||
latest.issueLink != issueUrl ||
latest.isSuppressed) {
continue;
}

log.info(
'Re-suppressing test ${doc.testName} in ${doc.repository} due to issue reopened ($issueUrl)',
);
await suppressionService.updateSuppression(
testName: doc.testName,
email: event.sender?.login ?? 'github-webhook',
repository: RepositorySlug.full(doc.repository),
action: SuppressingAction.suppress,
issueLink: issueUrl,
note: 'Automatic re-suppression: issue $issueUrl was reopened.',
);
}
}
}
46 changes: 43 additions & 3 deletions app_dart/lib/src/service/test_suppression.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,23 @@ class TestSuppression {
return;
}

if (issueLink == null) {
throw ArgumentError.notNull('issueLink');
}

final canonicalLink = canonicalizeIssueUrl(issueLink);
if (canonicalLink == null) {
throw ArgumentError.value(
issueLink,
'issueLink',
'Invalid GitHub issue URL format. Expected https://github.com/<owner>/<repo>/issues/<number>',
);
}

final newSuppression = SuppressedTest(
name: testName,
repository: repository.fullName,
issueLink:
issueLink ??
'BUG: You have found a bug! Please report to https://www.github.com/flutter/flutter/issues/new',
issueLink: canonicalLink,
isSuppressed: true,
createTimestamp: now,
updates: [updateEntry],
Expand All @@ -124,6 +135,35 @@ class TestSuppression {
);
}

/// Canonicalizes a GitHub issue URL into `https://github.com/<owner>/<repo>/issues/<issue_number>`.
///
/// Strips fragment anchors (e.g. `#issuecomment-123456`), query parameters, subpaths,
/// and trailing slashes. Returns `null` if the URL cannot be parsed into a valid GitHub issue link.
static String? canonicalizeIssueUrl(String issueUrl) {
final trimmed = issueUrl.trim();
if (Uri.tryParse(trimmed) case Uri(
:final host,
:final pathSegments,
) when host == 'github.com' || host == 'www.github.com') {
final segments = [
for (final s in pathSegments)
if (s.isNotEmpty) s,
];
if (segments case [
final owner,
final repo,
'issues',
final issueNumber,
...,
]) {
if (int.tryParse(issueNumber) != null) {
return 'https://github.com/$owner/$repo/issues/$issueNumber';
}
}
}
return null;
}

Future<bool> isTestSuppressed({
required String testName,
required RepositorySlug repository,
Expand Down
Loading
Loading