From 0741efc6b851382952a924705e9b71e734120817 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Tue, 28 Apr 2026 15:53:02 -0700 Subject: [PATCH 01/11] add `APP_DART_GEMINI_LOG_ANALYZER_KEY` secret --- app_dart/lib/src/service/config.dart | 3 +++ app_dart/test/service/config_test.dart | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app_dart/lib/src/service/config.dart b/app_dart/lib/src/service/config.dart index bf76c4d13..24e8396f7 100644 --- a/app_dart/lib/src/service/config.dart +++ b/app_dart/lib/src/service/config.dart @@ -259,6 +259,9 @@ interface class Config extends DynamicallyUpdatedConfig { Future get discordTreeStatusWebhookUrl => _getSingleValue('TREE_STATUS_DISCORD_WEBHOOK_URL'); + Future get geminiLogAnalyzerKey => + _getSingleValue('APP_DART_GEMINI_LOG_ANALYZER_KEY'); + String get wrongBaseBranchPullRequestMessage => 'This pull request was opened against a branch other than ' '_{{default_branch}}_. Since Flutter pull requests should not ' diff --git a/app_dart/test/service/config_test.dart b/app_dart/test/service/config_test.dart index 8fe7f3f0d..6763c834d 100644 --- a/app_dart/test/service/config_test.dart +++ b/app_dart/test/service/config_test.dart @@ -55,6 +55,19 @@ void main() { expect(githubToken, 'githubToken'); }); + test('geminiLogAnalyzerKey pulls from cache', () async { + const secretValue = 'my-gemini-key'; + final cachedValue = Uint8List.fromList(secretValue.codeUnits); + await cacheService.set( + Config.configCacheName, + 'APP_DART_GEMINI_LOG_ANALYZER_KEY', + cachedValue, + ); + + final key = await config.geminiLogAnalyzerKey; + expect(key, equals('my-gemini-key')); + }); + test('Returns the right flutter gold alert', () { expect( config.flutterGoldAlertConstant(RepositorySlug.full('flutter/flutter')), From 9f734983ed9303c0537e2215c1d6529fb49816a1 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Tue, 28 Apr 2026 16:06:16 -0700 Subject: [PATCH 02/11] added `log_analysis` filed to `presubmit_jobs` collection --- app_dart/lib/src/model/firestore/presubmit_job.dart | 13 +++++++++++++ .../lib/src/fakes/fake_config.dart | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/app_dart/lib/src/model/firestore/presubmit_job.dart b/app_dart/lib/src/model/firestore/presubmit_job.dart index 5c285fafe..1c4f9754c 100644 --- a/app_dart/lib/src/model/firestore/presubmit_job.dart +++ b/app_dart/lib/src/model/firestore/presubmit_job.dart @@ -110,6 +110,7 @@ final class PresubmitJob extends AppDocument { static const fieldStartTime = 'start_time'; static const fieldEndTime = 'end_time'; static const fieldSummary = 'summary'; + static const fieldLogAnalysis = 'log_analysis'; static AppDocumentId documentIdFor({ required RepositorySlug slug, @@ -171,6 +172,7 @@ final class PresubmitJob extends AppDocument { int? startTime, int? endTime, String? summary, + String? logAnalysis, }) { return PresubmitJob._( { @@ -184,6 +186,7 @@ final class PresubmitJob extends AppDocument { fieldStartTime: ?startTime?.toValue(), fieldEndTime: ?endTime?.toValue(), fieldSummary: ?summary?.toValue(), + fieldLogAnalysis: ?logAnalysis?.toValue(), }, name: documentNameFor( slug: slug, @@ -216,6 +219,7 @@ final class PresubmitJob extends AppDocument { startTime: null, endTime: null, summary: null, + logAnalysis: null, ); } @@ -247,6 +251,7 @@ final class PresubmitJob extends AppDocument { ? int.parse(fields[fieldEndTime]!.integerValue!) : null; String? get summary => fields[fieldSummary]?.stringValue; + String? get logAnalysis => fields[fieldLogAnalysis]?.stringValue; TaskStatus get status { final rawValue = fields[fieldStatus]!.stringValue!; @@ -281,6 +286,14 @@ final class PresubmitJob extends AppDocument { } } + set logAnalysis(String? logAnalysis) { + if (logAnalysis == null) { + fields.remove(fieldLogAnalysis); + } else { + fields[fieldLogAnalysis] = logAnalysis.toValue(); + } + } + void updateFromBuild(bbv2.Build build) { fields[fieldBuildNumber] = build.number.toValue(); fields[fieldCreationTime] = build.createTime diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart index 8213e3965..da8748b2e 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart @@ -23,6 +23,7 @@ class FakeConfig implements Config { this.maxFilesChangedForSkippingEnginePhaseValue, this.oauthClientIdValue, this.githubOAuthTokenValue, + this.geminiLogAnalyzerKeyValue, this.mergeConflictPullRequestMessageValue = 'default mergeConflictPullRequestMessageValue', this.missingTestsPullRequestMessageValue = @@ -66,6 +67,7 @@ class FakeConfig implements Config { int? batchSizeValue; String? oauthClientIdValue; String? githubOAuthTokenValue; + String? geminiLogAnalyzerKeyValue; String mergeConflictPullRequestMessageValue; String missingTestsPullRequestMessageValue; String? wrongBaseBranchPullRequestMessageValue; @@ -176,6 +178,9 @@ class FakeConfig implements Config { @override Future get githubOAuthToken async => githubOAuthTokenValue ?? 'token'; + @override + Future get geminiLogAnalyzerKey async => geminiLogAnalyzerKeyValue ?? 'fake-gemini-key'; + @override String get mergeConflictPullRequestMessage => mergeConflictPullRequestMessageValue; From b3d038aa7d0852e01bd7f6c7366e2d4296e08169 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Wed, 29 Apr 2026 11:26:49 -0700 Subject: [PATCH 03/11] added `build_id` into `presubmit_job` document --- .../common/presubmit_completed_check.dart | 5 +++++ .../src/model/common/presubmit_job_state.dart | 3 +++ .../src/model/firestore/presubmit_job.dart | 16 +++++++++++++++ .../request_handlers/get_presubmit_jobs.dart | 1 + .../service/firestore/unified_check_run.dart | 2 ++ .../common/presubmit_check_state_test.dart | 2 ++ .../presubmit_completed_check_test.dart | 5 +++++ .../model/firestore/presubmit_check_test.dart | 20 +++++++++++++++++++ .../get_presubmit_checks_test.dart | 2 ++ .../firestore/unified_check_run_test.dart | 4 ++++ .../src/rpc_model/presubmit_job_response.dart | 4 ++++ .../rpc_model/presubmit_job_response.g.dart | 3 +++ 12 files changed, 67 insertions(+) diff --git a/app_dart/lib/src/model/common/presubmit_completed_check.dart b/app_dart/lib/src/model/common/presubmit_completed_check.dart index b5102732d..325130798 100644 --- a/app_dart/lib/src/model/common/presubmit_completed_check.dart +++ b/app_dart/lib/src/model/common/presubmit_completed_check.dart @@ -41,6 +41,7 @@ class PresubmitCompletedJob { final int? endTime; final String? summary; final int? buildNumber; + final int? buildId; const PresubmitCompletedJob({ required this.name, @@ -59,6 +60,7 @@ class PresubmitCompletedJob { this.endTime, this.summary, this.buildNumber, + this.buildId, }); /// Creates a [PresubmitCompletedJob] from a GitHub [CheckRun]. @@ -81,6 +83,7 @@ class PresubmitCompletedJob { endTime: null, summary: null, buildNumber: null, + buildId: null, ); } @@ -107,6 +110,7 @@ class PresubmitCompletedJob { endTime: build.endTime.toDateTime().millisecondsSinceEpoch, summary: build.summaryMarkdown, buildNumber: build.number, + buildId: build.id.toInt(), ); } @@ -149,6 +153,7 @@ class PresubmitCompletedJob { endTime: endTime, summary: summary, buildNumber: buildNumber, + buildId: buildId, ); } diff --git a/app_dart/lib/src/model/common/presubmit_job_state.dart b/app_dart/lib/src/model/common/presubmit_job_state.dart index 84582c7d9..b9756b1ab 100644 --- a/app_dart/lib/src/model/common/presubmit_job_state.dart +++ b/app_dart/lib/src/model/common/presubmit_job_state.dart @@ -20,6 +20,7 @@ class PresubmitJobState { final int? endTime; final String? summary; final int? buildNumber; + final int? buildId; const PresubmitJobState({ required this.jobName, @@ -29,6 +30,7 @@ class PresubmitJobState { this.endTime, this.summary, this.buildNumber, + this.buildId, }); } @@ -41,5 +43,6 @@ extension BuildToPresubmitJobState on bbv2.Build { endTime: endTime.toDateTime().millisecondsSinceEpoch, summary: summaryMarkdown, buildNumber: number, + buildId: id.toInt(), ); } diff --git a/app_dart/lib/src/model/firestore/presubmit_job.dart b/app_dart/lib/src/model/firestore/presubmit_job.dart index 1c4f9754c..80fc5ea0d 100644 --- a/app_dart/lib/src/model/firestore/presubmit_job.dart +++ b/app_dart/lib/src/model/firestore/presubmit_job.dart @@ -104,6 +104,7 @@ final class PresubmitJob extends AppDocument { static const fieldSlug = 'slug'; static const fieldJobName = 'job_name'; static const fieldBuildNumber = 'build_number'; + static const fieldBuildId = 'build_id'; static const fieldStatus = 'status'; static const fieldAttemptNumber = 'attempt_number'; static const fieldCreationTime = 'creation_time'; @@ -169,6 +170,7 @@ final class PresubmitJob extends AppDocument { required int attemptNumber, required int creationTime, int? buildNumber, + int? buildId, int? startTime, int? endTime, String? summary, @@ -180,6 +182,7 @@ final class PresubmitJob extends AppDocument { fieldCheckRunId: checkRunId.toValue(), fieldJobName: jobName.toValue(), fieldBuildNumber: ?buildNumber?.toValue(), + fieldBuildId: ?buildId?.toValue(), fieldStatus: status.value.toValue(), fieldAttemptNumber: attemptNumber.toValue(), fieldCreationTime: creationTime.toValue(), @@ -216,6 +219,7 @@ final class PresubmitJob extends AppDocument { creationTime: creationTime, status: TaskStatus.waitingForBackfill, buildNumber: null, + buildId: null, startTime: null, endTime: null, summary: null, @@ -244,6 +248,9 @@ final class PresubmitJob extends AppDocument { int? get buildNumber => fields[fieldBuildNumber] != null ? int.parse(fields[fieldBuildNumber]!.integerValue!) : null; + int? get buildId => fields[fieldBuildId] != null + ? int.parse(fields[fieldBuildId]!.integerValue!) + : null; int? get startTime => fields[fieldStartTime] != null ? int.parse(fields[fieldStartTime]!.integerValue!) : null; @@ -278,6 +285,14 @@ final class PresubmitJob extends AppDocument { } } + set buildId(int? buildId) { + if (buildId == null) { + fields.remove(fieldBuildId); + } else { + fields[fieldBuildId] = buildId.toValue(); + } + } + set summary(String? summary) { if (summary == null) { fields.remove(fieldSummary); @@ -296,6 +311,7 @@ final class PresubmitJob extends AppDocument { void updateFromBuild(bbv2.Build build) { fields[fieldBuildNumber] = build.number.toValue(); + fields[fieldBuildId] = Value(integerValue: build.id.toString()); fields[fieldCreationTime] = build.createTime .toDateTime() .millisecondsSinceEpoch diff --git a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart index b770dc8a0..bb16a8eb1 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart @@ -100,6 +100,7 @@ final class GetPresubmitJobs extends PublicApiRequestHandler { status: job.status, summary: job.summary, buildNumber: job.buildNumber, + buildId: job.buildId, ), ]; 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 05454aa0e..fea62cc59 100644 --- a/app_dart/lib/src/service/firestore/unified_check_run.dart +++ b/app_dart/lib/src/service/firestore/unified_check_run.dart @@ -584,6 +584,7 @@ final class UnifiedCheckRun { } else if (state.status == TaskStatus.inProgress) { presubmitJob.startTime = state.startTime!; presubmitJob.buildNumber = state.buildNumber; + presubmitJob.buildId = state.buildId; // If the job is not completed, update the status. if (!status.isComplete) { status = state.status; @@ -622,6 +623,7 @@ final class UnifiedCheckRun { presubmitJob.endTime = state.endTime!; presubmitJob.summary = state.summary; presubmitJob.buildNumber = state.buildNumber; + presubmitJob.buildId = state.buildId; } else { status = state.status; valid = true; diff --git a/app_dart/test/model/common/presubmit_check_state_test.dart b/app_dart/test/model/common/presubmit_check_state_test.dart index 9285c0725..ea337af9e 100644 --- a/app_dart/test/model/common/presubmit_check_state_test.dart +++ b/app_dart/test/model/common/presubmit_check_state_test.dart @@ -14,6 +14,7 @@ void main() { group('PresubmitJobState', () { test('BuildToPresubmitJobState extension maps build number', () { final build = bbv2.Build( + id: Int64(67890), builder: bbv2.BuilderID(builder: 'linux_test'), status: bbv2.Status.SUCCESS, number: 12345, @@ -27,6 +28,7 @@ void main() { expect(state.jobName, 'linux_test'); expect(state.status, TaskStatus.succeeded); expect(state.buildNumber, 12345); + expect(state.buildId, 67890); }); }); } diff --git a/app_dart/test/model/common/presubmit_completed_check_test.dart b/app_dart/test/model/common/presubmit_completed_check_test.dart index ad9a33a31..9b26167b5 100644 --- a/app_dart/test/model/common/presubmit_completed_check_test.dart +++ b/app_dart/test/model/common/presubmit_completed_check_test.dart @@ -5,6 +5,7 @@ import 'package:buildbucket/buildbucket_pb.dart'; import 'package:cocoon_common/task_status.dart'; import 'package:cocoon_server_test/test_logging.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:cocoon_service/src/model/commit_ref.dart'; import 'package:cocoon_service/src/model/common/presubmit_completed_check.dart'; import 'package:cocoon_service/src/model/firestore/base.dart'; @@ -45,6 +46,7 @@ void main() { test('fromBuild creates correct unified check', () { final build = Build( + id: Int64(98765), builder: BuilderID(builder: 'test_builder'), status: Status.SUCCESS, ); @@ -74,10 +76,12 @@ void main() { expect(check.isUnifiedCheckRun, true); expect(check.checkRun.name, Config.kFlutterPresubmitsName); expect(check.buildNumber, 0); + expect(check.buildId, 98765); }); test('fromBuild creates correct legacy check', () { final build = Build( + id: Int64(98765), builder: BuilderID(builder: 'test_builder'), status: Status.SUCCESS, number: 1234, @@ -108,6 +112,7 @@ void main() { expect(check.isUnifiedCheckRun, false); expect(check.checkRun.name, 'test_builder'); expect(check.buildNumber, 1234); + expect(check.buildId, 98765); }); }); } diff --git a/app_dart/test/model/firestore/presubmit_check_test.dart b/app_dart/test/model/firestore/presubmit_check_test.dart index 94317643c..051516126 100644 --- a/app_dart/test/model/firestore/presubmit_check_test.dart +++ b/app_dart/test/model/firestore/presubmit_check_test.dart @@ -100,6 +100,7 @@ void main() { expect(check.attemptNumber, 1); expect(check.status, TaskStatus.waitingForBackfill); expect(check.buildNumber, isNull); + expect(check.buildId, isNull); expect(check.startTime, isNull); expect(check.endTime, isNull); expect(check.summary, isNull); @@ -131,6 +132,7 @@ void main() { attemptNumber: 1, creationTime: 1000, buildNumber: 456, + buildId: 789, startTime: 2000, endTime: 3000, summary: 'Success', @@ -168,6 +170,7 @@ void main() { expect(loadedCheck.attemptNumber, 1); expect(loadedCheck.creationTime, 1000); expect(loadedCheck.buildNumber, 456); + expect(loadedCheck.buildId, 789); expect(loadedCheck.startTime, 2000); expect(loadedCheck.endTime, 3000); expect(loadedCheck.summary, 'Success'); @@ -182,6 +185,7 @@ void main() { ); final build = bbv2.Build( + id: Int64(789), number: 456, createTime: bbv2.Timestamp(seconds: Int64(2000)), startTime: bbv2.Timestamp(seconds: Int64(2100)), @@ -192,6 +196,7 @@ void main() { check.updateFromBuild(build); expect(check.buildNumber, 456); + expect(check.buildId, 789); expect(check.creationTime, 2000000); // seconds to millis expect(check.startTime, 2100000); expect(check.endTime, 2200000); @@ -232,5 +237,20 @@ void main() { check.buildNumber = null; expect(check.buildNumber, isNull); }); + + test('buildId setter updates fields', () { + final check = PresubmitJob.init( + slug: slug, + jobName: 'linux', + checkRunId: 123, + creationTime: 1000, + ); + + check.buildId = 789; + expect(check.buildId, 789); + + check.buildId = null; + expect(check.buildId, isNull); + }); }); } 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 420609091..b9712c039 100644 --- a/app_dart/test/request_handlers/get_presubmit_checks_test.dart +++ b/app_dart/test/request_handlers/get_presubmit_checks_test.dart @@ -83,6 +83,7 @@ void main() { endTime: 120, summary: 'all good', buildNumber: 456, + buildId: 98765, ); await firestoreService.writeViaTransaction( documentsToWrites([job], exists: false), @@ -100,6 +101,7 @@ void main() { expect(jobs[0].jobName, 'linux'); expect(jobs[0].status, TaskStatus.succeeded); expect(jobs[0].buildNumber, 456); + expect(jobs[0].buildId, 98765); }); test('returns checks when found with owner and repo', () async { diff --git a/app_dart/test/service/firestore/unified_check_run_test.dart b/app_dart/test/service/firestore/unified_check_run_test.dart index d47b48436..38bc618e7 100644 --- a/app_dart/test/service/firestore/unified_check_run_test.dart +++ b/app_dart/test/service/firestore/unified_check_run_test.dart @@ -176,6 +176,7 @@ void main() { startTime: 2000, endTime: 3000, buildNumber: 456, + buildId: 98765, ); final result = await UnifiedCheckRun.markConclusion( @@ -200,6 +201,7 @@ void main() { expect(checkDoc.status, TaskStatus.succeeded); expect(checkDoc.endTime, 3000); expect(checkDoc.buildNumber, 456); + expect(checkDoc.buildId, 98765); }); test( @@ -297,6 +299,7 @@ void main() { attemptNumber: 1, startTime: 2000, buildNumber: 456, + buildId: 98765, ); final result = await UnifiedCheckRun.markConclusion( @@ -319,6 +322,7 @@ void main() { expect(checkDoc.status, TaskStatus.inProgress); expect(checkDoc.startTime, 2000); expect(checkDoc.buildNumber, 456); + expect(checkDoc.buildId, 98765); }); }); group('reInitializeFailedChecks', () { diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart index a9543c285..7f62882f6 100644 --- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart @@ -28,6 +28,7 @@ final class PresubmitJobResponse extends Model { required this.status, this.summary, this.buildNumber, + this.buildId, }); /// Creates a [PresubmitJobResponse] from [json] representation. @@ -63,6 +64,9 @@ final class PresubmitJobResponse extends Model { /// The LUCI build number. final int? buildNumber; + /// The LUCI build ID. + final int? buildId; + @override Map toJson() => _$PresubmitJobResponseToJson(this); } diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart index c26621f0e..a904a923c 100644 --- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart @@ -27,6 +27,7 @@ PresubmitJobResponse _$PresubmitJobResponseFromJson( ), summary: $checkedConvert('summary', (v) => v as String?), buildNumber: $checkedConvert('build_number', (v) => (v as num?)?.toInt()), + buildId: $checkedConvert('build_id', (v) => (v as num?)?.toInt()), ); return val; }, @@ -37,6 +38,7 @@ PresubmitJobResponse _$PresubmitJobResponseFromJson( 'startTime': 'start_time', 'endTime': 'end_time', 'buildNumber': 'build_number', + 'buildId': 'build_id', }, ); @@ -51,6 +53,7 @@ Map _$PresubmitJobResponseToJson( 'status': instance.status, 'summary': ?instance.summary, 'build_number': ?instance.buildNumber, + 'build_id': ?instance.buildId, }; const _$TaskStatusEnumMap = { From faaab1670c37dc22c6c3ea13ab0527659989a7f9 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 09:24:52 -0700 Subject: [PATCH 04/11] added genkit --- app_dart/bin/gae_server.dart | 5 +++++ app_dart/pubspec.yaml | 2 ++ 2 files changed, 7 insertions(+) diff --git a/app_dart/bin/gae_server.dart b/app_dart/bin/gae_server.dart index 4fe15669a..ea8e16ab7 100644 --- a/app_dart/bin/gae_server.dart +++ b/app_dart/bin/gae_server.dart @@ -20,6 +20,8 @@ import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; import 'package:cocoon_service/src/service/flags/dynamic_config_updater.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; +import 'package:genkit/genkit.dart'; +import 'package:genkit_google_genai/genkit_google_genai.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; @@ -60,6 +62,9 @@ Future main() async { // every ~1 minute. configUpdater.startUpdateLoop(config); + final geminiKey = await config.geminiLogAnalyzerKey; + final ai = Genkit(plugins: [googleAI(apiKey: geminiKey)]); + final firebaseJwtValidator = FirebaseJwtValidator(cache: cache); final dashboardAuthProvider = DashboardAuthentication( cache: cache, diff --git a/app_dart/pubspec.yaml b/app_dart/pubspec.yaml index 2caf9b00c..b64ecf038 100644 --- a/app_dart/pubspec.yaml +++ b/app_dart/pubspec.yaml @@ -27,6 +27,8 @@ dependencies: crypto: ^3.0.6 dbcrypt: 2.0.0 file: ^7.0.1 + genkit: ^0.13.0 + genkit_google_genai: ^0.2.5 fixnum: 1.1.1 gcloud: 0.8.19 github: 9.25.0 From 7ef51e41c822d2d5c067e4cb963796187aaaf75c Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 10:37:01 -0700 Subject: [PATCH 05/11] implemented `analyze_logs` api --- app_dart/bin/gae_server.dart | 2 + app_dart/config.yaml | 3 + app_dart/lib/server.dart | 10 + app_dart/lib/src/generated_config.dart | 3 + .../src/request_handlers/analyze_logs.dart | 157 +++++++++++ .../service/firestore/unified_check_run.dart | 12 + .../lib/src/service/flags/dynamic_config.dart | 8 + .../src/service/flags/dynamic_config.g.dart | 2 + app_dart/lib/src/service/log_analyzer.dart | 40 +++ app_dart/pubspec.yaml | 4 +- .../presubmit_completed_check_test.dart | 2 +- .../request_handlers/analyze_logs_test.dart | 244 ++++++++++++++++++ app_dart/test/server_test.dart | 2 + app_dart/test/service/log_analyzer_test.dart | 35 +++ app_dart/tool/local_server.dart | 7 +- .../buildbucket-dart/lib/buildbucket_pb.dart | 5 +- .../lib/src/fakes/fake_config.dart | 3 +- .../lib/src/server.dart | 2 + 18 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 app_dart/lib/src/request_handlers/analyze_logs.dart create mode 100644 app_dart/lib/src/service/log_analyzer.dart create mode 100644 app_dart/test/request_handlers/analyze_logs_test.dart create mode 100644 app_dart/test/service/log_analyzer_test.dart diff --git a/app_dart/bin/gae_server.dart b/app_dart/bin/gae_server.dart index ea8e16ab7..a2808e834 100644 --- a/app_dart/bin/gae_server.dart +++ b/app_dart/bin/gae_server.dart @@ -19,6 +19,7 @@ import 'package:cocoon_service/src/service/content_aware_hash_service.dart'; import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; import 'package:cocoon_service/src/service/flags/dynamic_config_updater.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; +import 'package:cocoon_service/src/service/log_analyzer.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; import 'package:genkit/genkit.dart'; import 'package:genkit_google_genai/genkit_google_genai.dart'; @@ -163,6 +164,7 @@ Future main() async { ciYamlFetcher: ciYamlFetcher, buildStatusService: buildStatusService, contentAwareHashService: contentHashService, + logAnalyzer: GenkitLogAnalyzer(ai, modelName: config.flags.geminiModel), ); return runAppEngine( diff --git a/app_dart/config.yaml b/app_dart/config.yaml index 5db9483e7..1a1dc3ec9 100644 --- a/app_dart/config.yaml +++ b/app_dart/config.yaml @@ -22,6 +22,9 @@ closeMqGuardAfterPresubmit: true # Whether to allow the tree status to be suppressed for specific failed tests. dynamicTestSuppression: true +# The Gemini model to use for log analysis. +geminiModel: gemini-3-flash-preview + # Whether to allow unified check run flow to specific users or to everyone. unifiedCheckRunFlow: useForAll: false diff --git a/app_dart/lib/server.dart b/app_dart/lib/server.dart index 3979a15ac..7c036032a 100644 --- a/app_dart/lib/server.dart +++ b/app_dart/lib/server.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'cocoon_service.dart'; +import 'src/request_handlers/analyze_logs.dart'; import 'src/request_handlers/get_engine_artifacts_ready.dart'; import 'src/request_handlers/get_presubmit_guard.dart'; import 'src/request_handlers/get_presubmit_guard_summaries.dart'; @@ -24,6 +25,7 @@ import 'src/service/build_status_service.dart'; import 'src/service/commit_service.dart'; 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'; @@ -48,6 +50,7 @@ Server createServer({ required BuildStatusService buildStatusService, required ContentAwareHashService contentAwareHashService, required AuthenticationProvider presubmitAuthProvider, + required LogAnalyzer logAnalyzer, }) { final githubWebhook = GithubWebhook( config: config, @@ -63,6 +66,13 @@ Server createServer({ ); final handlers = { + '/api/analyze-logs': AnalyzeLogs( + config: config, + authenticationProvider: presubmitAuthProvider, + luciBuildService: luciBuildService, + firestore: firestore, + logAnalyzer: logAnalyzer, + ), '/api/create-branch': CreateBranch( branchService: branchService, config: config, diff --git a/app_dart/lib/src/generated_config.dart b/app_dart/lib/src/generated_config.dart index 6152ab3c3..097be64fd 100644 --- a/app_dart/lib/src/generated_config.dart +++ b/app_dart/lib/src/generated_config.dart @@ -26,6 +26,9 @@ closeMqGuardAfterPresubmit: true # Whether to allow the tree status to be suppressed for specific failed tests. dynamicTestSuppression: true +# The Gemini model to use for log analysis. +geminiModel: gemini-3-flash-preview + # Whether to allow unified check run flow to specific users or to everyone. unifiedCheckRunFlow: useForAll: false diff --git a/app_dart/lib/src/request_handlers/analyze_logs.dart b/app_dart/lib/src/request_handlers/analyze_logs.dart new file mode 100644 index 000000000..51f3eea97 --- /dev/null +++ b/app_dart/lib/src/request_handlers/analyze_logs.dart @@ -0,0 +1,157 @@ +// 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 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_server/logging.dart' show log; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:github/github.dart'; +import 'package:retry/retry.dart'; + +import '../../cocoon_service.dart'; +import '../request_handling/api_request_handler.dart'; +import '../request_handling/exceptions.dart'; +import '../service/firestore/unified_check_run.dart'; +import '../service/log_analyzer.dart'; + +/// Analyzes failed build logs using Genkit. +/// +/// POST: /api/analyze-logs +/// +/// Parameters: +/// owner: (string in body) optional. The GitHub repository owner. Defaults to 'flutter'. +/// repo: (string in body) optional. The GitHub repository name. Defaults to 'flutter'. +/// pr: (int in body) mandatory. The Pull Request number. +/// build_id: (int in body) mandatory. The LUCI build ID. +final class AnalyzeLogs extends ApiRequestHandler { + const AnalyzeLogs({ + required super.config, + required super.authenticationProvider, + required LuciBuildService luciBuildService, + required FirestoreService firestore, + required LogAnalyzer logAnalyzer, + }) : _luciBuildService = luciBuildService, + _firestore = firestore, + _logAnalyzer = logAnalyzer; + + final LuciBuildService _luciBuildService; + final FirestoreService _firestore; + final LogAnalyzer _logAnalyzer; + + static const String kOwnerParam = 'owner'; + static const String kRepoParam = 'repo'; + static const String kPrParam = 'pr'; + static const String kBuildIdParam = 'build_id'; + + @override + Future post(Request request) async { + final requestData = await request.readBodyAsJson(); + checkRequiredParameters(requestData, [kPrParam, kBuildIdParam]); + + final owner = requestData[kOwnerParam] as String? ?? 'flutter'; + final repo = requestData[kRepoParam] as String? ?? 'flutter'; + final prNumber = requestData[kPrParam] as int; + final buildId = requestData[kBuildIdParam] as int; + + final slug = RepositorySlug(owner, repo); + + // 1. Validate that job with provided build_id belongs to latest presubmit guard. + final guard = await UnifiedCheckRun.getLatestPresubmitGuardForPrNum( + firestoreService: _firestore, + slug: slug, + prNum: prNumber, + ); + + if (guard == null) { + throw NotFoundException( + 'No PresubmitGuard found for PR $slug/#$prNumber', + ); + } + + final jobs = await UnifiedCheckRun.queryAllPresubmitJobsForGuard( + firestoreService: _firestore, + checkRunId: guard.checkRunId, + ); + + final job = jobs.firstWhereOrNull((j) => j.buildId == buildId); + if (job == null) { + throw BadRequestException( + 'Job with build_id $buildId does not belong to the latest presubmit guard for PR $slug/#$prNumber', + ); + } + + // 2. Call _luciBuildService.getBuildById providing buildId and BuildMask. + final build = await _luciBuildService.getBuildById( + Int64(buildId), + buildMask: bbv2.BuildMask( + fields: bbv2.FieldMask(paths: ['steps', 'tags']), + ), + ); + + // 3. Extract logs and tags. + final stdoutLogs = []; + for (final step in build.steps) { + if (step.status == bbv2.Status.FAILURE || + step.status == bbv2.Status.INFRA_FAILURE) { + for (final log in step.logs) { + if (log.name == 'stdout') { + stdoutLogs.add(log.url); + } + } + } + } + if (stdoutLogs.isEmpty) { + throw NotFoundException('Logs Not Found for BuildId: $buildId'); + } + String? githubUrl; + for (final tag in build.tags) { + if (tag.key == 'github_link') { + githubUrl = tag.value; + } + } + + // 4. Feed text to genkit. + final prompt = + ''' +You are a Senior Infrastructure Engineer specializing in the Flutter CI ecosystem. + +I will provide you with github pull request and the logs of a failed build step in a LUCI build associated with that change. + +Your task is: + +1. Identify the specific test or command that failed. + +2. Extract the error message or crash log. + +3. Explain the most likely root cause in simple terms. + +4. Suggest a potential fix if possible. + +${githubUrl != null && githubUrl.isNotEmpty ? 'Link to GitHub Pull Request: $githubUrl' : ''} + +Links to Logs: ${stdoutLogs.join('\n')} +'''; + + final analysis = await _logAnalyzer.analyze(prompt: prompt); + + // 5. Store response in log_analysis of a presubmit_jobs. + const r = RetryOptions(maxAttempts: 10, maxDelay: Duration(minutes: 2)); + try { + await r.retry(() { + return UnifiedCheckRun.storeLogAnalysis( + firestoreService: _firestore, + job: job, + analysis: analysis, + ); + }); + } on Exception catch (e, s) { + log.warn('Failed to store log analysis', e, s); + rethrow; + } + + return Response.emptyOk; + } +} 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 fea62cc59..7d621829a 100644 --- a/app_dart/lib/src/service/firestore/unified_check_run.dart +++ b/app_dart/lib/src/service/firestore/unified_check_run.dart @@ -311,6 +311,18 @@ final class UnifiedCheckRun { )).firstOrNull; } + /// Stores the log analysis result for a [PresubmitJob]. + static Future storeLogAnalysis({ + required FirestoreService firestoreService, + required PresubmitJob job, + required String analysis, + }) async { + job.logAnalysis = analysis; + await firestoreService.writeViaTransaction( + documentsToWrites([job], exists: true), + ); + } + /// Returns the latest [PresubmitGuard] for the specified github [checkRunId]. static Future getLatestPresubmitGuardForCheckRun({ required FirestoreService firestoreService, diff --git a/app_dart/lib/src/service/flags/dynamic_config.dart b/app_dart/lib/src/service/flags/dynamic_config.dart index ec2789c8e..6902efa96 100644 --- a/app_dart/lib/src/service/flags/dynamic_config.dart +++ b/app_dart/lib/src/service/flags/dynamic_config.dart @@ -39,6 +39,7 @@ final class DynamicConfig { closeMqGuardAfterPresubmit: false, unifiedCheckRunFlow: UnifiedCheckRunFlow.defaultInstance, dynamicTestSuppression: false, + geminiModel: 'gemini-3-flash-preview', ); /// Upper limit of commit rows to be backfilled in API call. @@ -69,6 +70,10 @@ final class DynamicConfig { @JsonKey() final bool dynamicTestSuppression; + /// The Gemini model to use for log analysis. + @JsonKey() + final String geminiModel; + const DynamicConfig._({ required this.backfillerCommitLimit, required this.ciYaml, @@ -76,6 +81,7 @@ final class DynamicConfig { required this.closeMqGuardAfterPresubmit, required this.unifiedCheckRunFlow, required this.dynamicTestSuppression, + required this.geminiModel, }); /// Creates [DynamicConfig] flags from a [json] object. @@ -88,6 +94,7 @@ final class DynamicConfig { bool? closeMqGuardAfterPresubmit, UnifiedCheckRunFlow? unifiedCheckRunFlow, bool? dynamicTestSuppression, + String? geminiModel, }) { return DynamicConfig._( backfillerCommitLimit: @@ -102,6 +109,7 @@ final class DynamicConfig { unifiedCheckRunFlow ?? defaultInstance.unifiedCheckRunFlow, dynamicTestSuppression: dynamicTestSuppression ?? defaultInstance.dynamicTestSuppression, + geminiModel: geminiModel ?? defaultInstance.geminiModel, ); } diff --git a/app_dart/lib/src/service/flags/dynamic_config.g.dart b/app_dart/lib/src/service/flags/dynamic_config.g.dart index 5b3f13dac..fc81a2c8f 100644 --- a/app_dart/lib/src/service/flags/dynamic_config.g.dart +++ b/app_dart/lib/src/service/flags/dynamic_config.g.dart @@ -26,6 +26,7 @@ DynamicConfig _$DynamicConfigFromJson(Map json) => json['unifiedCheckRunFlow'] as Map?, ), dynamicTestSuppression: json['dynamicTestSuppression'] as bool?, + geminiModel: json['geminiModel'] as String?, ); Map _$DynamicConfigToJson(DynamicConfig instance) => @@ -36,4 +37,5 @@ Map _$DynamicConfigToJson(DynamicConfig instance) => 'closeMqGuardAfterPresubmit': instance.closeMqGuardAfterPresubmit, 'unifiedCheckRunFlow': instance.unifiedCheckRunFlow.toJson(), 'dynamicTestSuppression': instance.dynamicTestSuppression, + 'geminiModel': instance.geminiModel, }; diff --git a/app_dart/lib/src/service/log_analyzer.dart b/app_dart/lib/src/service/log_analyzer.dart new file mode 100644 index 000000000..3e672f6b8 --- /dev/null +++ b/app_dart/lib/src/service/log_analyzer.dart @@ -0,0 +1,40 @@ +// 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:genkit/genkit.dart'; +import 'package:genkit_google_genai/genkit_google_genai.dart'; + +/// Interface for analyzing logs. +abstract class LogAnalyzer { + Future analyze({required String prompt}); +} + +/// Implementation of [LogAnalyzer] using Genkit. +class GenkitLogAnalyzer implements LogAnalyzer { + GenkitLogAnalyzer(this.ai, {required this.modelName}); + + final Genkit ai; + final String modelName; + + @override + Future analyze({required String prompt}) async { + final response = await ai.generate( + model: googleAI.gemini(modelName), + prompt: prompt, + ); + return response.text; + } +} + +/// Fake implementation of [LogAnalyzer] for tests and local server. +class FakeLogAnalyzer implements LogAnalyzer { + FakeLogAnalyzer([this.reply = 'Fake analysis result']); + + final String reply; + + @override + Future analyze({required String prompt}) async { + return reply; + } +} diff --git a/app_dart/pubspec.yaml b/app_dart/pubspec.yaml index b64ecf038..be50d523a 100644 --- a/app_dart/pubspec.yaml +++ b/app_dart/pubspec.yaml @@ -27,10 +27,10 @@ dependencies: crypto: ^3.0.6 dbcrypt: 2.0.0 file: ^7.0.1 - genkit: ^0.13.0 - genkit_google_genai: ^0.2.5 fixnum: 1.1.1 gcloud: 0.8.19 + genkit: ^0.13.0 + genkit_google_genai: ^0.2.5 github: 9.25.0 googleapis: 14.0.0 googleapis_auth: 2.0.0 diff --git a/app_dart/test/model/common/presubmit_completed_check_test.dart b/app_dart/test/model/common/presubmit_completed_check_test.dart index 9b26167b5..f5d53a2db 100644 --- a/app_dart/test/model/common/presubmit_completed_check_test.dart +++ b/app_dart/test/model/common/presubmit_completed_check_test.dart @@ -5,13 +5,13 @@ import 'package:buildbucket/buildbucket_pb.dart'; import 'package:cocoon_common/task_status.dart'; import 'package:cocoon_server_test/test_logging.dart'; -import 'package:fixnum/fixnum.dart'; import 'package:cocoon_service/src/model/commit_ref.dart'; import 'package:cocoon_service/src/model/common/presubmit_completed_check.dart'; import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/github/checks.dart' as cocoon_checks; import 'package:cocoon_service/src/service/config.dart'; import 'package:cocoon_service/src/service/luci_build_service/user_data.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:github/github.dart'; import 'package:test/test.dart'; diff --git a/app_dart/test/request_handlers/analyze_logs_test.dart b/app_dart/test/request_handlers/analyze_logs_test.dart new file mode 100644 index 000000000..327986a1c --- /dev/null +++ b/app_dart/test/request_handlers/analyze_logs_test.dart @@ -0,0 +1,244 @@ +// 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 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/testing.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/cocoon_service.dart'; +import 'package:cocoon_service/src/request_handlers/analyze_logs.dart'; +import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:cocoon_service/src/service/log_analyzer.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../src/request_handling/api_request_handler_tester.dart'; + +class MockLuciBuildService extends Mock implements LuciBuildService { + @override + Future getBuildById(Int64? id, {bbv2.BuildMask? buildMask}) => + super.noSuchMethod( + Invocation.method(#getBuildById, [id], {#buildMask: buildMask}), + returnValue: Future.value(bbv2.Build()), + returnValueForMissingStub: Future.value(bbv2.Build()), + ) + as Future; +} + +void main() { + useTestLoggerPerTest(); + + late AnalyzeLogs handler; + late FakeConfig config; + late MockLuciBuildService mockLuciBuildService; + late FakeFirestoreService firestore; + late ApiRequestHandlerTester tester; + late String analysisResult; + + setUp(() { + final clientContext = FakeClientContext(); + firestore = FakeFirestoreService(); + config = FakeConfig(); + final authContext = FakeAuthenticatedContext( + clientContext: clientContext, + email: 'user@google.com', + ); + tester = ApiRequestHandlerTester(context: authContext); + mockLuciBuildService = MockLuciBuildService(); + analysisResult = 'Analysis result'; + + handler = AnalyzeLogs( + config: config, + authenticationProvider: FakeDashboardAuthentication( + clientContext: clientContext, + ), + luciBuildService: mockLuciBuildService, + firestore: firestore, + logAnalyzer: FakeLogAnalyzer(analysisResult), + ); + }); + + test('Analyze logs successfully', () async { + final checkRun = generateCheckRun(1, name: 'Linux A'); + final guard = generatePresubmitGuard( + checkRun: checkRun, + jobs: {'Linux A': TaskStatus.failed}, + remainingJobs: 0, + ); + firestore.putDocument(guard); + + final pullRequest = generatePullRequest(headSha: guard.commitSha); + await PrCheckRuns.initializeDocument( + firestoreService: firestore, + pullRequest: pullRequest, + checks: [ + generateCheckRun(guard.checkRunId, name: Config.kFlutterPresubmitsName), + ], + ); + + final failedCheck = PresubmitJob( + slug: Config.flutterSlug, + checkRunId: 1, + jobName: 'Linux A', + status: TaskStatus.failed, + attemptNumber: 1, + creationTime: 1, + buildId: 123, + ); + firestore.putDocument(failedCheck); + + final build = bbv2.Build.create() + ..id = Int64(123) + ..steps.addAll([ + bbv2.Step.create() + ..name = 'step1' + ..status = bbv2.Status.FAILURE + ..logs.addAll([ + bbv2.Log.create() + ..name = 'stdout' + ..url = 'http://logs/stdout', + ]), + ]) + ..tags.addAll([ + bbv2.StringPair.create() + ..key = 'github_link' + ..value = 'http://github/pr/1', + ]); + + when( + mockLuciBuildService.getBuildById( + Int64(123), + buildMask: anyNamed('buildMask'), + ), + ).thenAnswer((_) async => build); + + tester.requestData = { + 'owner': 'flutter', + 'repo': 'flutter', + 'pr': pullRequest.number!, + 'build_id': 123, + }; + + final response = await tester.post(handler); + expect(response.statusCode, HttpStatus.ok); + + final updatedCheck = PresubmitJob.fromDocument( + await firestore.getDocument(failedCheck.name!), + ); + expect(updatedCheck.logAnalysis, 'Analysis result'); + }); + + test('Fails for missing guard', () async { + tester.requestData = { + 'owner': 'flutter', + 'repo': 'flutter', + 'pr': 123, + 'build_id': 123, + }; + + await expectLater(tester.post(handler), throwsA(isA())); + }); + + test('Fails for job not belonging to guard', () async { + final checkRun = generateCheckRun(1, name: 'Linux A'); + final guard = generatePresubmitGuard( + checkRun: checkRun, + jobs: {'Linux A': TaskStatus.failed}, + remainingJobs: 0, + ); + firestore.putDocument(guard); + + final pullRequest = generatePullRequest(headSha: guard.commitSha); + await PrCheckRuns.initializeDocument( + firestoreService: firestore, + pullRequest: pullRequest, + checks: [ + generateCheckRun(guard.checkRunId, name: Config.kFlutterPresubmitsName), + ], + ); + + final failedCheck = PresubmitJob( + slug: Config.flutterSlug, + checkRunId: 1, + jobName: 'Linux A', + status: TaskStatus.failed, + attemptNumber: 1, + creationTime: 1, + buildId: 456, // Different build ID + ); + firestore.putDocument(failedCheck); + + tester.requestData = { + 'owner': 'flutter', + 'repo': 'flutter', + 'pr': pullRequest.number!, + 'build_id': 123, // Requested build ID + }; + + await expectLater( + tester.post(handler), + throwsA(isA()), + ); + }); + + test('Fails for missing logs', () async { + final checkRun = generateCheckRun(1, name: 'Linux A'); + final guard = generatePresubmitGuard( + checkRun: checkRun, + jobs: {'Linux A': TaskStatus.failed}, + remainingJobs: 0, + ); + firestore.putDocument(guard); + + final pullRequest = generatePullRequest(headSha: guard.commitSha); + await PrCheckRuns.initializeDocument( + firestoreService: firestore, + pullRequest: pullRequest, + checks: [ + generateCheckRun(guard.checkRunId, name: Config.kFlutterPresubmitsName), + ], + ); + + final failedCheck = PresubmitJob( + slug: Config.flutterSlug, + checkRunId: 1, + jobName: 'Linux A', + status: TaskStatus.failed, + attemptNumber: 1, + creationTime: 1, + buildId: 123, + ); + firestore.putDocument(failedCheck); + + final build = bbv2.Build.create() + ..id = Int64(123) + ..steps.addAll([ + bbv2.Step.create() + ..name = 'step1' + ..status = bbv2.Status.FAILURE + ]); + + when( + mockLuciBuildService.getBuildById( + Int64(123), + buildMask: anyNamed('buildMask'), + ), + ).thenAnswer((_) async => build); + + tester.requestData = { + 'owner': 'flutter', + 'repo': 'flutter', + 'pr': pullRequest.number!, + 'build_id': 123, + }; + + await expectLater( + tester.post(handler), + throwsA(isA()), + ); + }); +} diff --git a/app_dart/test/server_test.dart b/app_dart/test/server_test.dart index 1a3199fef..887e791ac 100644 --- a/app_dart/test/server_test.dart +++ b/app_dart/test/server_test.dart @@ -6,6 +6,7 @@ import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; +import 'package:cocoon_service/src/service/log_analyzer.dart'; import 'package:test/test.dart'; void main() { @@ -42,6 +43,7 @@ void main() { contentAwareHashService: FakeContentAwareHashService( config: FakeConfig(webhookKeyValue: 'fake-secret'), ), + logAnalyzer: FakeLogAnalyzer(), ); }); } diff --git a/app_dart/test/service/log_analyzer_test.dart b/app_dart/test/service/log_analyzer_test.dart new file mode 100644 index 000000000..f78980360 --- /dev/null +++ b/app_dart/test/service/log_analyzer_test.dart @@ -0,0 +1,35 @@ +// 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_service/src/service/log_analyzer.dart'; +import 'package:genkit/genkit.dart'; +import 'package:test/test.dart'; + +void main() { + group('FakeLogAnalyzer', () { + test('returns default reply', () async { + final analyzer = FakeLogAnalyzer(); + expect(await analyzer.analyze(prompt: 'test'), 'Fake analysis result'); + }); + + test('returns custom reply', () async { + final analyzer = FakeLogAnalyzer('custom reply'); + expect(await analyzer.analyze(prompt: 'test'), 'custom reply'); + }); + }); + + group('GenkitLogAnalyzer', () { + test('throws state error when no plugins registered', () async { + final ai = Genkit(plugins: []); + final analyzer = GenkitLogAnalyzer(ai, modelName: 'gemini-3-flash-preview'); + + // Expect a StateError or similar when trying to use a model without the plugin. + // Genkit throws StateError when looking up a model that isn't registered. + expect( + () => analyzer.analyze(prompt: 'test'), + throwsA(isA()), + ); + }); + }); +} diff --git a/app_dart/tool/local_server.dart b/app_dart/tool/local_server.dart index ee27f92ca..f4d17ced9 100644 --- a/app_dart/tool/local_server.dart +++ b/app_dart/tool/local_server.dart @@ -10,21 +10,23 @@ import 'package:cocoon_server/google_auth_provider.dart'; import 'package:cocoon_server_test/fake_secret_manager.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; - import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/service/big_query.dart'; import 'package:cocoon_service/src/service/build_status_service.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; +import 'package:cocoon_service/src/service/log_analyzer.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; import 'package:http/http.dart' as http; Future main() async { final cache = CacheService(inMemory: false); + final secretManager = FakeSecretManager(); + secretManager.putString('APP_DART_GEMINI_LOG_ANALYZER_KEY', 'dummy_key'); final config = Config( cache, - FakeSecretManager(), + secretManager, initialConfig: DynamicConfig.fromJson({}), httpClient: MappingHttpClient(http.Client()), ); @@ -126,6 +128,7 @@ Future main() async { ciYamlFetcher: ciYamlFetcher, buildStatusService: buildStatusService, contentAwareHashService: contentHashService, + logAnalyzer: FakeLogAnalyzer(), ); return runAppEngine( diff --git a/packages/buildbucket-dart/lib/buildbucket_pb.dart b/packages/buildbucket-dart/lib/buildbucket_pb.dart index b174569ca..1df3875ec 100644 --- a/packages/buildbucket-dart/lib/buildbucket_pb.dart +++ b/packages/buildbucket-dart/lib/buildbucket_pb.dart @@ -27,6 +27,8 @@ export 'src/generated/go.chromium.org/luci/buildbucket/proto/build.pb.dart' Build_Input, Build_Output, BuildInfra_Buildbucket_Agent_Source_DataType; +export 'src/generated/go.chromium.org/luci/buildbucket/proto/step.pb.dart' + show Step, Step_MergeBuild; export 'src/generated/go.chromium.org/luci/buildbucket/proto/builds_service.pb.dart' show GetBuildRequest, @@ -77,7 +79,8 @@ export 'src/generated/go.chromium.org/luci/buildbucket/proto/common.pb.dart' CacheEntry, GitilesCommit, GerritChange, - Executable; + Executable, + Log; export 'src/generated/go.chromium.org/luci/common/proto/structmask/structmask.pb.dart' show StructMask; export 'src/generated/go.chromium.org/luci/buildbucket/proto/common.pbenum.dart'; diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart index da8748b2e..a68e53196 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart @@ -179,7 +179,8 @@ class FakeConfig implements Config { Future get githubOAuthToken async => githubOAuthTokenValue ?? 'token'; @override - Future get geminiLogAnalyzerKey async => geminiLogAnalyzerKeyValue ?? 'fake-gemini-key'; + Future get geminiLogAnalyzerKey async => + geminiLogAnalyzerKeyValue ?? 'fake-gemini-key'; @override String get mergeConflictPullRequestMessage => diff --git a/packages/cocoon_integration_test/lib/src/server.dart b/packages/cocoon_integration_test/lib/src/server.dart index 61902634f..8a57607ed 100644 --- a/packages/cocoon_integration_test/lib/src/server.dart +++ b/packages/cocoon_integration_test/lib/src/server.dart @@ -6,6 +6,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; import 'package:cocoon_service/src/service/build_status_service.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; +import 'package:cocoon_service/src/service/log_analyzer.dart'; import 'package:retry/retry.dart'; import '../testing.dart'; @@ -89,6 +90,7 @@ class IntegrationServer { ciYamlFetcher: this.ciYamlFetcher, buildStatusService: this.buildStatusService, contentAwareHashService: this.contentAwareHashService, + logAnalyzer: FakeLogAnalyzer(), ); } From facad8a7ca04c4eb751b3fbbc8ac7cc221a9977b Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 10:43:49 -0700 Subject: [PATCH 06/11] fix error: UseTestLogging --- app_dart/test/service/log_analyzer_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app_dart/test/service/log_analyzer_test.dart b/app_dart/test/service/log_analyzer_test.dart index f78980360..9c7cf861a 100644 --- a/app_dart/test/service/log_analyzer_test.dart +++ b/app_dart/test/service/log_analyzer_test.dart @@ -2,11 +2,13 @@ // 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/service/log_analyzer.dart'; import 'package:genkit/genkit.dart'; import 'package:test/test.dart'; void main() { + useTestLoggerPerTest(); group('FakeLogAnalyzer', () { test('returns default reply', () async { final analyzer = FakeLogAnalyzer(); From 316b59baaf4c1fb0baf178059cdcdee08dcb1420 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 10:46:58 -0700 Subject: [PATCH 07/11] dart format --- app_dart/test/request_handlers/analyze_logs_test.dart | 7 ++----- app_dart/test/service/log_analyzer_test.dart | 7 +++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app_dart/test/request_handlers/analyze_logs_test.dart b/app_dart/test/request_handlers/analyze_logs_test.dart index 327986a1c..4e5fa2ee1 100644 --- a/app_dart/test/request_handlers/analyze_logs_test.dart +++ b/app_dart/test/request_handlers/analyze_logs_test.dart @@ -219,7 +219,7 @@ void main() { ..steps.addAll([ bbv2.Step.create() ..name = 'step1' - ..status = bbv2.Status.FAILURE + ..status = bbv2.Status.FAILURE, ]); when( @@ -236,9 +236,6 @@ void main() { 'build_id': 123, }; - await expectLater( - tester.post(handler), - throwsA(isA()), - ); + await expectLater(tester.post(handler), throwsA(isA())); }); } diff --git a/app_dart/test/service/log_analyzer_test.dart b/app_dart/test/service/log_analyzer_test.dart index 9c7cf861a..b58a697af 100644 --- a/app_dart/test/service/log_analyzer_test.dart +++ b/app_dart/test/service/log_analyzer_test.dart @@ -24,8 +24,11 @@ void main() { group('GenkitLogAnalyzer', () { test('throws state error when no plugins registered', () async { final ai = Genkit(plugins: []); - final analyzer = GenkitLogAnalyzer(ai, modelName: 'gemini-3-flash-preview'); - + final analyzer = GenkitLogAnalyzer( + ai, + modelName: 'gemini-3-flash-preview', + ); + // Expect a StateError or similar when trying to use a model without the plugin. // Genkit throws StateError when looking up a model that isn't registered. expect( From 54961e1a78135733f58d5835fa71fd93c27bd733 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 13:58:08 -0700 Subject: [PATCH 08/11] added log analysis field to response --- app_dart/lib/src/request_handlers/get_presubmit_jobs.dart | 1 + app_dart/test/request_handlers/get_presubmit_checks_test.dart | 2 ++ .../lib/src/rpc_model/presubmit_job_response.dart | 4 ++++ .../lib/src/rpc_model/presubmit_job_response.g.dart | 3 +++ 4 files changed, 10 insertions(+) diff --git a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart index bb16a8eb1..73161a3e1 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_jobs.dart @@ -101,6 +101,7 @@ final class GetPresubmitJobs extends PublicApiRequestHandler { summary: job.summary, buildNumber: job.buildNumber, buildId: job.buildId, + logAnalysis: job.logAnalysis, ), ]; 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 b9712c039..9cd620448 100644 --- a/app_dart/test/request_handlers/get_presubmit_checks_test.dart +++ b/app_dart/test/request_handlers/get_presubmit_checks_test.dart @@ -84,6 +84,7 @@ void main() { summary: 'all good', buildNumber: 456, buildId: 98765, + logAnalysis: 'log analysis result', ); await firestoreService.writeViaTransaction( documentsToWrites([job], exists: false), @@ -102,6 +103,7 @@ void main() { expect(jobs[0].status, TaskStatus.succeeded); expect(jobs[0].buildNumber, 456); expect(jobs[0].buildId, 98765); + expect(jobs[0].logAnalysis, 'log analysis result'); }); test('returns checks when found with owner and repo', () async { diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart index 7f62882f6..4d816058c 100644 --- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.dart @@ -29,6 +29,7 @@ final class PresubmitJobResponse extends Model { this.summary, this.buildNumber, this.buildId, + this.logAnalysis, }); /// Creates a [PresubmitJobResponse] from [json] representation. @@ -67,6 +68,9 @@ final class PresubmitJobResponse extends Model { /// The LUCI build ID. final int? buildId; + /// The log analysis result. + final String? logAnalysis; + @override Map toJson() => _$PresubmitJobResponseToJson(this); } diff --git a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart index a904a923c..b94c152c3 100644 --- a/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_job_response.g.dart @@ -28,6 +28,7 @@ PresubmitJobResponse _$PresubmitJobResponseFromJson( summary: $checkedConvert('summary', (v) => v as String?), buildNumber: $checkedConvert('build_number', (v) => (v as num?)?.toInt()), buildId: $checkedConvert('build_id', (v) => (v as num?)?.toInt()), + logAnalysis: $checkedConvert('log_analysis', (v) => v as String?), ); return val; }, @@ -39,6 +40,7 @@ PresubmitJobResponse _$PresubmitJobResponseFromJson( 'endTime': 'end_time', 'buildNumber': 'build_number', 'buildId': 'build_id', + 'logAnalysis': 'log_analysis', }, ); @@ -54,6 +56,7 @@ Map _$PresubmitJobResponseToJson( 'summary': ?instance.summary, 'build_number': ?instance.buildNumber, 'build_id': ?instance.buildId, + 'log_analysis': ?instance.logAnalysis, }; const _$TaskStatusEnumMap = { From e23c2dc2603aeb554c5bd03fa0e84176ec302045 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 14:26:08 -0700 Subject: [PATCH 09/11] added "Log Analisis" tab --- dashboard/lib/service/data_seeder.dart | 7 ++ dashboard/lib/views/presubmit_view.dart | 75 +++++++++++++--- dashboard/test/views/presubmit_view_test.dart | 87 +++++++++++++++++++ 3 files changed, 157 insertions(+), 12 deletions(-) diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart index 0b0352c2a..bd0f53b27 100644 --- a/dashboard/lib/service/data_seeder.dart +++ b/dashboard/lib/service/data_seeder.dart @@ -501,6 +501,13 @@ class DataSeeder { }, startTime: creationTime + 30000, endTime: creationTime + 60000, + logAnalysis: switch (status) { + .failed => + 'Based on my analysis of the provided LUCI logs and the context of the changes in this PR, here is the breakdown of the build failure:\n\n ### 1. Identify the specific test or command that failed for $jobName \n...', + .infraFailure => + 'Based on my analysis of the provided LUCI logs and the context of the changes in this PR, here is the breakdown of the build failure:\n\n ### 1. Identify the specific infra issue for $jobName \n...', + _ => null, + }, ); } diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index 36aff0651..c98527586 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -328,18 +328,26 @@ class _JobDetailsViewerPane extends StatefulWidget { class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { int _selectedAttemptIndex = 0; + int _selectedDetailTabIndex = 0; + String? _lastJobName; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final presubmitState = Provider.of(context); + final jobName = presubmitState.selectedJob; + + if (_lastJobName != jobName) { + _lastJobName = jobName; + _selectedAttemptIndex = 0; + _selectedDetailTabIndex = 0; + } return AnimatedBuilder( animation: presubmitState, builder: (context, _) { final repo = presubmitState.repo; - final jobName = presubmitState.selectedJob; final jobs = presubmitState.jobs; final isLoading = presubmitState.isLoading; @@ -360,6 +368,7 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { } final selectedJob = jobs[_selectedAttemptIndex]; + final hasLogAnalysis = selectedJob.logAnalysis != null && selectedJob.logAnalysis!.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -448,22 +457,33 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { ], ), ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border(bottom: BorderSide(color: borderColor)), + ), child: Row( children: [ - Text( - 'Execution Details', - style: TextStyle(fontWeight: FontWeight.w600), - ), - Spacer(), - Text( + if (hasLogAnalysis) ...[ + _buildDetailTab('Log Analysis', 0, isDark), + _buildDetailTab('Execution Details', 1, isDark), + ] else + const Text( + 'Execution Details', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const Spacer(), + const Text( 'Raw output', style: TextStyle(fontSize: 12, color: Colors.grey), ), ], ), ), + const SizedBox(height: 12), Expanded( child: Container( margin: const EdgeInsets.symmetric(horizontal: 24), @@ -476,9 +496,11 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { width: double.infinity, child: SingleChildScrollView( child: Text( - selectedJob.summary?.trim().isEmpty ?? true - ? _getDefaultJobDetails(selectedJob) - : selectedJob.summary!, + hasLogAnalysis && _selectedDetailTabIndex == 0 + ? selectedJob.logAnalysis! + : (selectedJob.summary?.trim().isEmpty ?? true + ? _getDefaultJobDetails(selectedJob) + : selectedJob.summary!), style: const TextStyle( fontFamily: 'monospace', fontSize: 13, @@ -534,6 +556,35 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { ); } + Widget _buildDetailTab(String label, int index, bool isDark) { + final isSelected = _selectedDetailTabIndex == index; + return InkWell( + onTap: () => setState(() => _selectedDetailTabIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? const Color(0xFF3B82F6) : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? (isDark ? Colors.white : Colors.black) + : (isDark ? const Color(0xFF8B949E) : const Color(0xFF6B7280)), + ), + ), + ), + ); + } + String _getDefaultJobDetails(PresubmitJobResponse job) { return switch (job.status) { .succeeded => diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart index e3b1e4bdc..732c142f9 100644 --- a/dashboard/test/views/presubmit_view_test.dart +++ b/dashboard/test/views/presubmit_view_test.dart @@ -370,6 +370,93 @@ void main() { }, ); + testWidgets('PreSubmitView displays Log Analysis tab when present', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const mockSha = 'decaf_3_real_sha'; + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'Engine', + createdAt: 0, + builds: {'Mac mac_host_engine 1': TaskStatus.failed}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: mockSha, + ), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + when( + mockCocoonService.fetchPresubmitJobDetails( + checkRunId: anyNamed('checkRunId'), + jobName: argThat(contains('mac_host_engine'), named: 'jobName'), + ), + ).thenAnswer( + (_) async => CocoonResponse.data([ + PresubmitJobResponse( + attemptNumber: 1, + jobName: 'Mac mac_host_engine 1', + creationTime: 0, + status: TaskStatus.failed, + summary: 'Test failed: Unit Tests', + logAnalysis: 'Found 2 flakiness candidates', + ), + ]), + ); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'pr': '123'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + expect(find.textContaining('PR #123'), findsOneWidget); + + await tester.tap(find.textContaining('mac_host_engine').first); + await tester.runAsync(() async { + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('Found 2 flakiness candidates').evaluate().isNotEmpty) { + break; + } + } + }); + await tester.pumpAndSettle(); + + // Verify Log Analysis content is shown by default + expect(find.textContaining('Found 2 flakiness candidates'), findsOneWidget); + expect(find.text('Log Analysis'), findsOneWidget); + expect(find.text('Execution Details'), findsOneWidget); + + // Switch to Execution Details tab + await tester.tap(find.text('Execution Details')); + await tester.pumpAndSettle(); + + // Verify Execution Details content is shown + expect(find.textContaining('Test failed: Unit Tests'), findsOneWidget); + }); + testWidgets( 'PreSubmitView automatically selects latest SHA and updates sidebar when opened with PR only', (WidgetTester tester) async { From 3841ea050386a06f4abf49d864f8bf57fb06cf55 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Thu, 30 Apr 2026 15:35:26 -0700 Subject: [PATCH 10/11] added `Analyze Logs with Gemini` button --- dashboard/lib/service/appengine_cocoon.dart | 37 ++++++ dashboard/lib/service/cocoon.dart | 9 ++ dashboard/lib/service/data_seeder.dart | 3 +- dashboard/lib/state/presubmit.dart | 27 ++++ dashboard/lib/views/presubmit_view.dart | 121 ++++++++++++------ dashboard/test/utils/mocks.mocks.dart | 31 +++++ dashboard/test/views/presubmit_view_test.dart | 108 +++++++++++++++- 7 files changed, 297 insertions(+), 39 deletions(-) diff --git a/dashboard/lib/service/appengine_cocoon.dart b/dashboard/lib/service/appengine_cocoon.dart index 38a63059c..de21d61ae 100644 --- a/dashboard/lib/service/appengine_cocoon.dart +++ b/dashboard/lib/service/appengine_cocoon.dart @@ -553,6 +553,43 @@ class AppEngineCocoonService implements CocoonService { ); } + @override + Future> analyzeLogs({ + required String? idToken, + required String repo, + required int pr, + required int buildId, + String owner = 'flutter', + }) async { + if (idToken == null || idToken.isEmpty) { + return const CocoonResponse.error( + 'Sign in to analyze logs', + statusCode: HttpStatus.unauthorized, + ); + } + + final analyzeUrl = apiEndpoint('/api/analyze-logs'); + final response = await _client.post( + analyzeUrl, + headers: {'X-Flutter-IdToken': idToken}, + body: jsonEncode({ + 'owner': owner, + 'repo': repo, + 'pr': pr, + 'build_id': buildId, + }), + ); + + if (response.statusCode == HttpStatus.ok) { + return const CocoonResponse.data(null); + } + + return CocoonResponse.error( + 'HTTP Code: ${response.statusCode}, ${response.body}', + statusCode: response.statusCode, + ); + } + @override Future> rerunAllFailedJobs({ required String? idToken, diff --git a/dashboard/lib/service/cocoon.dart b/dashboard/lib/service/cocoon.dart index 11e1c5db6..5ebcf1e93 100644 --- a/dashboard/lib/service/cocoon.dart +++ b/dashboard/lib/service/cocoon.dart @@ -82,6 +82,15 @@ abstract class CocoonService { String owner = 'flutter', }); + /// Analyze logs for the given failed job. + Future> analyzeLogs({ + required String? idToken, + required String repo, + required int pr, + required int buildId, + String owner = 'flutter', + }); + /// Schedule all failed tasks for the given [pr] to be re-run. Future> rerunAllFailedJobs({ required String? idToken, diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart index bd0f53b27..f22ebfbec 100644 --- a/dashboard/lib/service/data_seeder.dart +++ b/dashboard/lib/service/data_seeder.dart @@ -483,6 +483,7 @@ class DataSeeder { attemptNumber: attemptNumber, creationTime: creationTime, buildNumber: 1337 + attemptNumber, + buildId: 24567 + attemptNumber, summary: switch (status) { .succeeded => '[INFO] Starting task $jobName...\n[SUCCESS] All tests passed (452/452)', @@ -504,8 +505,6 @@ class DataSeeder { logAnalysis: switch (status) { .failed => 'Based on my analysis of the provided LUCI logs and the context of the changes in this PR, here is the breakdown of the build failure:\n\n ### 1. Identify the specific test or command that failed for $jobName \n...', - .infraFailure => - 'Based on my analysis of the provided LUCI logs and the context of the changes in this PR, here is the breakdown of the build failure:\n\n ### 1. Identify the specific infra issue for $jobName \n...', _ => null, }, ); diff --git a/dashboard/lib/state/presubmit.dart b/dashboard/lib/state/presubmit.dart index 769887489..4be793060 100644 --- a/dashboard/lib/state/presubmit.dart +++ b/dashboard/lib/state/presubmit.dart @@ -535,6 +535,33 @@ class PresubmitState extends ChangeNotifier { false; } + /// Whether the user can trigger log analysis for a specific job. + bool canAnalyzeLog(PresubmitJobResponse job) { + if (!authService.isAuthenticated || isLoading) { + return false; + } + if (job.status != TaskStatus.failed && + job.status != TaskStatus.infraFailure) { + return false; + } + return job.buildId != null && + (job.logAnalysis == null || job.logAnalysis!.trim().isEmpty); + } + + /// Triggers log analysis for a job. + Future analyzeLogs(PresubmitJobResponse job) async { + if (pr == null) return 'No PR selected'; + + final response = await cocoonService.analyzeLogs( + idToken: await authService.idToken, + repo: repo, + pr: int.parse(pr!), + buildId: job.buildId!, + ); + + return response.error; + } + void resume() { if (!_active) return; _startTimer(); diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index c98527586..799b40a7f 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -305,7 +305,9 @@ class _PreSubmitViewState extends State 'Select a job to view execution details.', ), ) - : const _JobDetailsViewerPane(), + : _JobDetailsViewerPane( + onError: _showErrorDialog, + ), ), ], ), @@ -320,7 +322,9 @@ class _PreSubmitViewState extends State } class _JobDetailsViewerPane extends StatefulWidget { - const _JobDetailsViewerPane(); + const _JobDetailsViewerPane({required this.onError}); + + final ValueChanged onError; @override State<_JobDetailsViewerPane> createState() => _JobDetailsViewerPaneState(); @@ -330,6 +334,7 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { int _selectedAttemptIndex = 0; int _selectedDetailTabIndex = 0; String? _lastJobName; + bool _isAnalyzing = false; @override Widget build(BuildContext context) { @@ -368,7 +373,9 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { } final selectedJob = jobs[_selectedAttemptIndex]; - final hasLogAnalysis = selectedJob.logAnalysis != null && selectedJob.logAnalysis!.isNotEmpty; + final hasLogAnalysis = + selectedJob.logAnalysis != null && + selectedJob.logAnalysis!.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -499,8 +506,8 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { hasLogAnalysis && _selectedDetailTabIndex == 0 ? selectedJob.logAnalysis! : (selectedJob.summary?.trim().isEmpty ?? true - ? _getDefaultJobDetails(selectedJob) - : selectedJob.summary!), + ? _getDefaultJobDetails(selectedJob) + : selectedJob.summary!), style: const TextStyle( fontFamily: 'monospace', fontSize: 13, @@ -511,43 +518,85 @@ class _JobDetailsViewerPaneState extends State<_JobDetailsViewerPane> { ), Padding( padding: const EdgeInsets.all(24.0), - child: ElevatedButton( - onPressed: selectedJob.buildNumber == null - ? null - : () async => await launchUrl( - Uri.parse( - generatePreSubmitBuildLogUrl( - buildName: selectedJob.jobName, - buildNumber: selectedJob.buildNumber!, + child: Row( + children: [ + ElevatedButton( + onPressed: selectedJob.buildNumber == null + ? null + : () async => await launchUrl( + Uri.parse( + generatePreSubmitBuildLogUrl( + buildName: selectedJob.jobName, + buildNumber: selectedJob.buildNumber!, + ), + ), ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_new, + size: 18, + color: selectedJob.buildNumber == null + ? Colors.grey + : (isDark + ? const Color(0xFF58A6FF) + : const Color(0xFF0969DA)), ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_new, - size: 18, - color: selectedJob.buildNumber == null - ? Colors.grey - : (isDark - ? const Color(0xFF58A6FF) - : const Color(0xFF0969DA)), + const SizedBox(width: 8), + Text( + 'View more details on LUCI UI', + style: TextStyle( + color: selectedJob.buildNumber == null + ? Colors.grey + : (isDark + ? const Color(0xFF58A6FF) + : const Color(0xFF0969DA)), + fontSize: 14, + ), + ), + ], ), - const SizedBox(width: 8), - Text( - 'View more details on LUCI UI', - style: TextStyle( - color: selectedJob.buildNumber == null - ? Colors.grey - : (isDark - ? const Color(0xFF58A6FF) - : const Color(0xFF0969DA)), - fontSize: 14, + ), + if (presubmitState.canAnalyzeLog(selectedJob)) ...[ + const SizedBox(width: 16), + ElevatedButton( + onPressed: _isAnalyzing + ? null + : () async { + setState(() => _isAnalyzing = true); + try { + final error = await presubmitState.analyzeLogs( + selectedJob, + ); + if (error == null) { + await presubmitState.fetchJobDetails(); + } else { + widget.onError(error); + } + } finally { + if (mounted) { + setState(() => _isAnalyzing = false); + } + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isAnalyzing) ...[ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + ], + const Text('Analize Logs with Gemini'), + ], ), ), ], - ), + ], ), ), ], diff --git a/dashboard/test/utils/mocks.mocks.dart b/dashboard/test/utils/mocks.mocks.dart index 25cfec539..990233c02 100644 --- a/dashboard/test/utils/mocks.mocks.dart +++ b/dashboard/test/utils/mocks.mocks.dart @@ -475,6 +475,37 @@ class MockCocoonService extends _i1.Mock implements _i3.CocoonService { ) as _i8.Future<_i3.CocoonResponse>); + @override + _i8.Future<_i3.CocoonResponse> analyzeLogs({ + required String? idToken, + required String? repo, + required int? pr, + required int? buildId, + String? owner = 'flutter', + }) => + (super.noSuchMethod( + Invocation.method(#analyzeLogs, [], { + #idToken: idToken, + #repo: repo, + #pr: pr, + #buildId: buildId, + #owner: owner, + }), + returnValue: _i8.Future<_i3.CocoonResponse>.value( + _FakeCocoonResponse_2( + this, + Invocation.method(#analyzeLogs, [], { + #idToken: idToken, + #repo: repo, + #pr: pr, + #buildId: buildId, + #owner: owner, + }), + ), + ), + ) + as _i8.Future<_i3.CocoonResponse>); + @override _i8.Future<_i3.CocoonResponse> rerunAllFailedJobs({ required String? idToken, diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart index 732c142f9..4dfd29e5d 100644 --- a/dashboard/test/views/presubmit_view_test.dart +++ b/dashboard/test/views/presubmit_view_test.dart @@ -437,7 +437,10 @@ void main() { for (var i = 0; i < 50; i++) { await tester.pump(); await Future.delayed(const Duration(milliseconds: 50)); - if (find.textContaining('Found 2 flakiness candidates').evaluate().isNotEmpty) { + if (find + .textContaining('Found 2 flakiness candidates') + .evaluate() + .isNotEmpty) { break; } } @@ -457,6 +460,109 @@ void main() { expect(find.textContaining('Test failed: Unit Tests'), findsOneWidget); }); + testWidgets( + 'PreSubmitView displays Analyze Logs button when conditions are met', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + when(mockAuthService.isAuthenticated).thenReturn(true); + + const mockSha = 'decaf_3_real_sha'; + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'Engine', + createdAt: 0, + builds: {'Mac mac_host_engine 1': TaskStatus.failed}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: mockSha, + ), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + when( + mockCocoonService.fetchPresubmitJobDetails( + checkRunId: anyNamed('checkRunId'), + jobName: argThat(contains('mac_host_engine'), named: 'jobName'), + ), + ).thenAnswer( + (_) async => CocoonResponse.data([ + PresubmitJobResponse( + attemptNumber: 1, + jobName: 'Mac mac_host_engine 1', + creationTime: 0, + status: TaskStatus.failed, + summary: 'Test failed: Unit Tests', + buildId: 789, + // logAnalysis is null by default + ), + ]), + ); + + final analyzeCompleter = Completer>(); + when( + mockCocoonService.analyzeLogs( + idToken: anyNamed('idToken'), + repo: anyNamed('repo'), + pr: anyNamed('pr'), + buildId: 789, + ), + ).thenAnswer((_) => analyzeCompleter.future); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'pr': '123'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + await tester.tap(find.textContaining('mac_host_engine').first); + await tester.pumpAndSettle(); + + final analyzeButton = find.widgetWithText( + ElevatedButton, + 'Analize Logs with Gemini', + ); + expect(analyzeButton, findsOneWidget); + + // Click button + await tester.tap(analyzeButton); + await tester.pump(); // Trigger rebuild to show loading + + // Verify button is disabled + expect(tester.widget(analyzeButton).onPressed, isNull); + + // Complete API call successfully + analyzeCompleter.complete(const CocoonResponse.data(null)); + await tester.pumpAndSettle(); + + // Verify fetchJobDetails was called + verify( + mockCocoonService.fetchPresubmitJobDetails( + checkRunId: 456, + jobName: 'Mac mac_host_engine 1', + ), + ).called(greaterThan(1)); + }, + ); + testWidgets( 'PreSubmitView automatically selects latest SHA and updates sidebar when opened with PR only', (WidgetTester tester) async { From cfdf4090d132839877ebebcd9ad20405027fadc8 Mon Sep 17 00:00:00 2001 From: Dmitry Grand Date: Fri, 1 May 2026 10:43:41 -0700 Subject: [PATCH 11/11] improved promt --- .../src/request_handlers/analyze_logs.dart | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/app_dart/lib/src/request_handlers/analyze_logs.dart b/app_dart/lib/src/request_handlers/analyze_logs.dart index 51f3eea97..906000057 100644 --- a/app_dart/lib/src/request_handlers/analyze_logs.dart +++ b/app_dart/lib/src/request_handlers/analyze_logs.dart @@ -106,32 +106,81 @@ final class AnalyzeLogs extends ApiRequestHandler { if (stdoutLogs.isEmpty) { throw NotFoundException('Logs Not Found for BuildId: $buildId'); } - String? githubUrl; - for (final tag in build.tags) { - if (tag.key == 'github_link') { - githubUrl = tag.value; - } + String githubUrl; + final githubLinkTag = build.tags.firstWhereOrNull( + (tag) => tag.key == 'github_link', + ); + if (githubLinkTag == null) { + log.warn('Could not find github_link tag in build $buildId'); + githubUrl = 'http://github.com/$owner/$repo/pull/$prNumber'; + } else { + githubUrl = githubLinkTag.value; } // 4. Feed text to genkit. final prompt = - ''' -You are a Senior Infrastructure Engineer specializing in the Flutter CI ecosystem. + '''You are a Senior Infrastructure Engineer specializing in the Flutter CI ecosystem. +I will provide you with a link to github pull request and the logs of a failed build step in a LUCI build associated with that change. + +## Your task -I will provide you with github pull request and the logs of a failed build step in a LUCI build associated with that change. +### 1. Identify the specific test or command that failed. -Your task is: +### 2. Extract the error message or crash log. -1. Identify the specific test or command that failed. +### 3. Explain the most likely root cause in simple terms. -2. Extract the error message or crash log. +### 4. Suggest a potential fix if possible. -3. Explain the most likely root cause in simple terms. +## Workflow -4. Suggest a potential fix if possible. +### 1. Analyze Raw Log Output -${githubUrl != null && githubUrl.isNotEmpty ? 'Link to GitHub Pull Request: $githubUrl' : ''} +Analyze the raw log output for failure details. Do not skim the output; check the entire log. **The description of findings should include specific details for the failures (e.g., unformatted files, specific test names), not just the top-level command that failed.** +### 2. Look for Failure Patterns + +#### Pattern A: Error Blocks (e.g., Linux Analyze) +Search for blocks starting with `╡ERROR #`. +Example: +``` +╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════ +║ Command: bin/cache/dart-sdk/bin/dart --enable-asserts /b/s/w/ir/x/w/flutter/dev/bots/analyze_snippet_code.dart --verbose +║ Command exited with exit code 255 but expected zero exit code. +║ Working directory: /b/s/w/ir/x/w/flutter +╚═══════════════════════════════════════════════════════════════════════════════ +``` + +#### Pattern B: Task Result JSON +Search for "Task result:" followed by a JSON object. +Example: +```json +Task result: +{ + "success": false, + "reason": "Task failed: PathNotFoundException: Cannot open file..." +} +``` + +#### Pattern C: Failing Tests List +For general Dart tests, look for a list at the end of the log starting with "Failing tests:". +Example: +``` +Failing tests: + test/general.shard/cache_test.dart: FontSubset artifacts for all platforms on arm64 hosts + test/general.shard/cache_test.dart: FontSubset artifacts on arm64 linux +``` + +#### Pattern D: Build Failures +For build failures (e.g., engine tests failing at compile time), look for the following indicators in the logs or API summaries: +- Lines starting with `FAILED:` (indicates a Ninja target failed). +- Compiler error messages (e.g., `error:`, `fatal error:`). +- Linker error messages (e.g., `undefined reference to`). +- Summary messages in the check-runs API output like `1 build failed: []`. + +## Links + +Link to GitHub Pull Request: $githubUrl Links to Logs: ${stdoutLogs.join('\n')} ''';