import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../test_support.dart'; void registerIssueAssistantAppRunOnceNotificationTests() { /// ```gherkin /// Feature: Issue thread assistant polling /// /// As a maintainer using the issue assistant /// I want issue thread events to be evaluated through configured responders /// So that users get help only when the thread actually needs a response /// ``` group('IssueAssistantApp.runOnce', () { /// ```gherkin /// Scenario: Cache authenticated gh login across multiple replies /// Given a temporary project checkout that can be used as the local repo path /// And GitHub issue data containing two issues that explicitly mention the assistant /// And a responder that replies to both issues /// And an orchestrator-style config pointing to the fake repo and responder /// When the app processes one polling cycle /// Then it looks up the authenticated gh login only once /// And it still posts one comment per issue /// ``` test('Cache authenticated gh login across multiple replies', () async { // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp('cws-gh-cache-'); final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); await initGitRepo(repoDir); // And GitHub issue data containing two issues that explicitly mention the assistant. final ghScript = File(p.join(sandbox.path, 'gh')); final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); final issueData = [ { 'number': 31, 'title': 'First mention', 'body': 'first', 'state': 'open', 'html_url': 'https://example.test/issues/31', 'updated_at': '2026-04-04T16:00:00Z', 'user': {'login': 'reporter'}, 'labels': const [], }, { 'number': 32, 'title': 'Second mention', 'body': 'second', 'state': 'open', 'html_url': 'https://example.test/issues/32', 'updated_at': '2026-04-04T16:01:00Z', 'user': {'login': 'reporter'}, 'labels': const [], }, ]; await writeFakeGhScript( ghScript: ghScript, issueListResponse: issueData, commentResponses: { 31: [ { 'id': 310, 'body': '@helper first', 'html_url': 'https://example.test/issues/31#issuecomment-310', 'created_at': '2026-04-04T16:00:00Z', 'updated_at': '2026-04-04T16:00:00Z', 'user': {'login': 'reporter'}, }, ], 32: [ { 'id': 320, 'body': '@helper second', 'html_url': 'https://example.test/issues/32#issuecomment-320', 'created_at': '2026-04-04T16:01:00Z', 'updated_at': '2026-04-04T16:01:00Z', 'user': {'login': 'reporter'}, }, ], }, postCommentIssueNumbers: {31, 32}, ghLog: ghLog, viewerLogin: 'octocat', ); // And a responder that replies to both issues. final responderScript = File(p.join(sandbox.path, 'responder.sh')); await writeResponderScript(responderScript, { 'decision': 'reply', 'mode': 'planning', 'markdown': 'cached login reply', 'summary': 'posted', }); // And an orchestrator-style config pointing to the fake repo and responder. final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); await configFile.writeAsString(''' defaults: issueAssistant: enabled: true pollInterval: 5m mentionTriggers: ["@helper"] responders: - id: primary command: ${responderScript.path} projects: sample: repo: owner/sample path: ${repoDir.path} '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, ); // When the app processes one polling cycle. await app.runOnce(); await app.close(); // Then it looks up the authenticated gh login only once. final logLines = await ghLog.readAsLines(); expect(logLines.where((line) => line == 'api user').length, 1); // And it still posts one comment per issue. expect( logLines .where( (line) => line.contains('repos/owner/sample/issues/31/comments'), ) .length, 2, ); expect( logLines .where( (line) => line.contains('repos/owner/sample/issues/32/comments'), ) .length, 2, ); }); /// ```gherkin /// Scenario: Send Discord webhook notifications for mention and reply events /// Given a temporary project checkout that can be used as the local repo path /// And GitHub issue data containing an explicit assistant mention /// And a responder that emits a planning reply /// And a local webhook server that records notification requests /// And an orchestrator-style config with a discord webhook notifier /// When the app processes one polling cycle /// Then the webhook server receives one mention_detected notification /// And the webhook server receives one reply_posted notification /// ``` test( 'Send Discord webhook notifications for mention and reply events', () async { // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp( 'cws-discord-notify-', ); final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); await initGitRepo(repoDir); // And GitHub issue data containing an explicit assistant mention. final ghScript = File(p.join(sandbox.path, 'gh')); final issueData = [ { 'number': 61, 'title': 'Need architecture help', 'body': 'Please review the notifier design.', 'state': 'open', 'html_url': 'https://example.test/issues/61', 'updated_at': '2026-04-04T17:10:00Z', 'user': {'login': 'reporter'}, 'labels': const [], }, ]; final commentsData = [ { 'id': 610, 'body': '@helper please review the notifier design', 'html_url': 'https://example.test/issues/61#issuecomment-610', 'created_at': '2026-04-04T17:10:00Z', 'updated_at': '2026-04-04T17:10:00Z', 'user': {'login': 'reporter'}, }, ]; await writeFakeGhScript( ghScript: ghScript, issueListResponse: issueData, commentResponses: {61: commentsData}, postCommentIssueNumber: 61, ); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); await writeResponderScript(responderScript, { 'decision': 'reply', 'mode': 'planning', 'markdown': 'discord notification reply', 'summary': 'posted planning advice', }); // And a local webhook server that records notification requests. final requests = >[]; final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); unawaited(() async { await for (final request in server) { final body = await utf8.decoder.bind(request).join(); requests.add(jsonDecode(body) as Map); request.response.statusCode = HttpStatus.noContent; await request.response.close(); } }()); // And an orchestrator-style config with a discord webhook notifier. final configFile = File( p.join(sandbox.path, 'agent-orchestrator.yaml'), ); await configFile.writeAsString(''' defaults: issueAssistant: enabled: true pollInterval: 5m mentionTriggers: ["@helper"] responders: - id: primary command: ${responderScript.path} notifications: - type: discordWebhook enabled: true events: ["mention_detected", "reply_posted"] webhookUrl: http://${server.address.host}:${server.port}/discord username: code-work-spawner projects: sample: repo: owner/sample path: ${repoDir.path} '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), ghCommand: ghScript.path, ); // When the app processes one polling cycle. await app.runOnce(); await app.close(); await server.close(force: true); // Then the webhook server receives one mention_detected notification. expect(requests, hasLength(2)); expect(requests.first['username'], 'code-work-spawner'); expect( requests.first['content'] as String, contains('**Mention detected**'), ); // And the webhook server receives one reply_posted notification. expect( requests.last['content'] as String, contains('**Reply posted**'), ); expect( requests.last['content'] as String, contains('summary: posted planning advice'), ); }, ); }); } void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; registerIssueAssistantAppRunOnceNotificationTests(); }