import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'test_support.dart'; void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; /// ```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: Reply once when a user explicitly mentions the assistant /// Given a temporary project checkout that can be used as the local repo path /// And GitHub issue data containing a comment with an explicit assistant mention /// And a responder that emits a planning reply /// And an orchestrator-style config pointing to the fake repo and responder /// When the app processes one polling cycle /// Then it reads issue comments and posts exactly one reply for that issue /// ``` test('Reply once when a user explicitly mentions the assistant', () async { // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp('cws-run-'); final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); await initGitRepo(repoDir); // And GitHub issue data containing a comment with an explicit assistant mention. final ghScript = File(p.join(sandbox.path, 'gh')); final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); final postedBody = File(p.join(sandbox.path, 'posted-body.md')); final issueData = [ { 'number': 12, 'title': 'Need architecture help', 'body': 'Please review the API layering.', 'state': 'open', 'html_url': 'https://example.test/issues/12', 'updated_at': '2026-04-04T12:00:00Z', 'user': {'login': 'reporter'}, 'labels': [ {'name': 'help'}, ], }, ]; final commentsData = [ { 'id': 50, 'body': '@helper can you plan the architecture?', 'html_url': 'https://example.test/issues/12#issuecomment-50', 'created_at': '2026-04-04T12:00:00Z', 'updated_at': '2026-04-04T12:00:00Z', 'user': {'login': 'reporter'}, }, ]; await writeFakeGhScript( ghScript: ghScript, issueListResponse: issueData, commentResponses: {12: commentsData}, postCommentIssueNumber: 12, ghLog: ghLog, postedBodyFile: postedBody, viewerLogin: 'octocat', ); // 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': 'Plan:\n- separate service and persistence', 'summary': 'posted planning advice', }); // 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} timeout: 1m projects: sample: repo: owner/sample path: ${repoDir.path} defaultBranch: main '''); 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 reads issue comments and posts exactly one reply for that issue. final logLines = await ghLog.readAsLines(); expect( logLines .where( (line) => line.contains('repos/owner/sample/issues/12/comments'), ) .length, 2, ); final postedComment = await postedBody.readAsString(); expect(postedComment, contains('> [!NOTE]')); expect(postedComment, contains('Automated reply generated by')); expect(postedComment, contains('@octocat')); expect( postedComment, contains('Plan:\n- separate service and persistence'), ); expect(postedComment, contains('_Automation note:')); expect(postedComment, contains('