code_work_spawner/test/issue_assistant_app_test.dart

1134 lines
41 KiB
Dart

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('<!-- code-work-spawner:'));
});
/// ```gherkin
/// Scenario: Record no reply when the responder decides the discussion is already sufficient
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data with a new comment that does not require another response
/// And a responder that explicitly returns no_reply
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then it records the evaluation result as no_reply
/// And it does not store a posted GitHub comment for that thread version
/// ```
test(
'Record no reply when the responder decides the discussion is already sufficient',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data with a new comment that does not require another response.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 42,
'title': 'Existing discussion',
'body': 'Initial context',
'state': 'open',
'html_url': 'https://example.test/issues/42',
'updated_at': '2026-04-04T13:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 99,
'body': 'I think this is already covered.',
'html_url': 'https://example.test/issues/42#issuecomment-99',
'created_at': '2026-04-04T13:00:00Z',
'updated_at': '2026-04-04T13:00:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {42: commentsData},
);
// And a responder that explicitly returns no_reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'thread already resolved',
});
// 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();
final thread = await app.database.findIssueThread('sample', 42);
await app.close();
// Then it records the evaluation result as no_reply.
expect(thread, isNotNull);
expect(thread!.lastDecision, 'no_reply');
// And it does not store a posted GitHub comment for that thread version.
expect(thread.lastCommentId, isNull);
},
);
/// ```gherkin
/// Scenario: Log raw responder output for debugging
/// 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 writes stderr diagnostics alongside a no_reply JSON payload
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle while app logs are captured
/// Then the application logs include the responder stdout payload
/// And the application logs include the responder stderr diagnostics
/// ```
test('Log raw responder output for debugging', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-log-output-');
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': 21,
'title': 'Need responder diagnostics',
'body': 'Inspect the responder output.',
'state': 'open',
'html_url': 'https://example.test/issues/21',
'updated_at': '2026-04-04T14:10:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 210,
'body': '@helper please explain why you skipped this',
'html_url': 'https://example.test/issues/21#issuecomment-210',
'created_at': '2026-04-04T14:10:00Z',
'updated_at': '2026-04-04T14:10:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {21: commentsData},
postCommentIssueNumber: 21,
);
// And a responder that writes stderr diagnostics alongside a no_reply JSON payload.
final responderScript = File(p.join(sandbox.path, 'responder-debug.sh'));
await writeResponderScript(responderScript, {
'decision': 'no_reply',
'mode': 'planning',
'summary': 'diagnostic skip',
}, stderr: 'codex diagnostic trace');
// 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,
);
final logMessages = <String>[];
final previousLevel = Logger.root.level;
Logger.root.level = Level.INFO;
final subscription = Logger.root.onRecord.listen((record) {
logMessages.add(record.message);
});
// When the app processes one polling cycle while app logs are captured.
await app.runOnce();
await subscription.cancel();
Logger.root.level = previousLevel;
await app.close();
// Then the application logs include the responder stdout payload.
expect(logMessages.join('\n'), contains('"decision":"no_reply"'));
// And the application logs include the responder stderr diagnostics.
expect(logMessages.join('\n'), contains('codex diagnostic trace'));
});
/// ```gherkin
/// Scenario: Run bug verification mention replies from an ephemeral worktree with issue context
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing a bug verification mention
/// And a responder that records its working directory and stdin before returning no_reply
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then the responder runs from an ephemeral worktree instead of the source checkout
/// And the responder stdin includes the latest issue comment text
/// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant
/// ```
test(
'Run bug verification mention replies from an ephemeral worktree with issue context',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-mention-worktree-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data containing a bug verification mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 24,
'title': 'Need help verifying a bug',
'body': 'Please verify and reproduce this bug in the CLI workflow.',
'state': 'open',
'html_url': 'https://example.test/issues/24',
'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 240,
'body': '@helper please verify and reproduce this bug',
'html_url': 'https://example.test/issues/24#issuecomment-240',
'created_at': '2026-04-04T15:30:00Z',
'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {24: commentsData},
ghLog: ghLog,
postCommentIssueNumber: 24,
);
// And a responder that records its working directory and stdin before returning no_reply.
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
final responderScript = File(
p.join(sandbox.path, 'responder-capture.sh'),
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
cat > "${stdinCapture.path}"
cat <<'JSON'
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
// 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 the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
expect(
p.normalize(capturedCwd.trim()),
isNot(p.normalize(repoDir.path)),
);
// And the responder stdin includes the latest issue comment text.
final capturedStdin = await stdinCapture.readAsString();
expect(
capturedStdin,
contains('@helper please verify and reproduce this bug'),
);
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) =>
line.contains('repos/owner/sample/issues/24/comments'),
)
.length,
2,
);
},
);
/// ```gherkin
/// Scenario: Run planning replies from an ephemeral worktree
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing a planning mention
/// And a responder that records its working directory before returning a reply
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then the responder runs from an ephemeral worktree instead of the source checkout
/// And the ephemeral worktree directory is cleaned up afterwards
/// ```
test('Run planning replies from an ephemeral worktree', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-planning-project-path-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
final worktreeRoot = await Directory(
p.join(sandbox.path, 'worktrees'),
).create();
await initGitRepo(repoDir);
// And GitHub issue data containing a planning mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 25,
'title': 'Need planning help',
'body': 'Please review the service layout.',
'state': 'open',
'html_url': 'https://example.test/issues/25',
'updated_at': '2026-04-04T15:35:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 250,
'body': '@helper can you plan the service layout?',
'html_url': 'https://example.test/issues/25#issuecomment-250',
'created_at': '2026-04-04T15:35:00Z',
'updated_at': '2026-04-04T15:35:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {25: commentsData},
postCommentIssueNumber: 25,
);
// And a responder that records its working directory before returning a reply.
final cwdCapture = File(p.join(sandbox.path, 'planning.cwd'));
final responderScript = File(
p.join(sandbox.path, 'planning-responder.sh'),
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
cat >/dev/null
cat <<'JSON'
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
// 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('''
worktreeDir: ${worktreeRoot.path}
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 the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
expect(
p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
isTrue,
);
// And the ephemeral worktree directory is cleaned up afterwards.
expect(await worktreeRoot.list().isEmpty, isTrue);
});
/// ```gherkin
/// Scenario: Accept codex style jsonl responder output
/// 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 codex style jsonl events instead of a single json object
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then it extracts the final agent message and posts one reply
/// ```
test('Accept codex style jsonl responder output', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
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 ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 18,
'title': 'Need help',
'body': 'Please review the plan.',
'state': 'open',
'html_url': 'https://example.test/issues/18',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 180,
'body': '@helper can you respond?',
'html_url': 'https://example.test/issues/18#issuecomment-180',
'created_at': '2026-04-04T15:00:00Z',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {18: commentsData},
postCommentIssueNumber: 18,
ghLog: ghLog,
);
// And a responder that emits codex style jsonl events instead of a single json object.
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
await writeJsonlResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown': 'Codex JSONL reply',
'summary': 'parsed event stream',
});
// 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 extracts the final agent message and posts one reply.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/18/comments'),
)
.length,
2,
);
});
/// ```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: Process multiple issues concurrently up to the configured limit
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing three planning mentions
/// And a responder that sleeps for one second while logging each issue number
/// And an orchestrator-style config with maxConcurrentIssues set to three
/// When the app processes one polling cycle
/// Then the cycle completes in under three seconds
/// And all three issues still receive replies
/// ```
test('Process multiple issues concurrently up to the configured limit', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-concurrent-issues-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data containing three planning mentions.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 41,
'title': 'First planning request',
'body': 'first',
'state': 'open',
'html_url': 'https://example.test/issues/41',
'updated_at': '2026-04-04T16:20:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
{
'number': 42,
'title': 'Second planning request',
'body': 'second',
'state': 'open',
'html_url': 'https://example.test/issues/42',
'updated_at': '2026-04-04T16:21:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
{
'number': 43,
'title': 'Third planning request',
'body': 'third',
'state': 'open',
'html_url': 'https://example.test/issues/43',
'updated_at': '2026-04-04T16:22:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {
41: [
{
'id': 410,
'body': '@helper first',
'html_url': 'https://example.test/issues/41#issuecomment-410',
'created_at': '2026-04-04T16:20:00Z',
'updated_at': '2026-04-04T16:20:00Z',
'user': {'login': 'reporter'},
},
],
42: [
{
'id': 420,
'body': '@helper second',
'html_url': 'https://example.test/issues/42#issuecomment-420',
'created_at': '2026-04-04T16:21:00Z',
'updated_at': '2026-04-04T16:21:00Z',
'user': {'login': 'reporter'},
},
],
43: [
{
'id': 430,
'body': '@helper third',
'html_url': 'https://example.test/issues/43#issuecomment-430',
'created_at': '2026-04-04T16:22:00Z',
'updated_at': '2026-04-04T16:22:00Z',
'user': {'login': 'reporter'},
},
],
},
postCommentIssueNumbers: {41, 42, 43},
ghLog: ghLog,
);
// And a responder that sleeps for one second while logging each issue number.
final eventLog = File(p.join(sandbox.path, 'responder-events.log'));
final responderScript = File(p.join(sandbox.path, 'responder-slow.sh'));
await writeDelayedLoggingResponderScript(
responderScript,
eventLog: eventLog,
delay: const Duration(seconds: 1),
response: {
'decision': 'reply',
'mode': 'planning',
'markdown': 'parallel reply',
'summary': 'completed after delay',
},
);
// And an orchestrator-style config with maxConcurrentIssues set to three.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
maxConcurrentIssues: 3
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.
final stopwatch = Stopwatch()..start();
await app.runOnce();
stopwatch.stop();
await app.close();
// Then the cycle completes in under three seconds.
expect(stopwatch.elapsed, lessThan(const Duration(seconds: 3)));
final eventLines = await eventLog.readAsLines();
expect(eventLines.where((line) => line.startsWith('start:')).length, 3);
expect(eventLines.where((line) => line.startsWith('end:')).length, 3);
// And all three issues still receive replies.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/41/comments'),
)
.length,
2,
);
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/42/comments'),
)
.length,
2,
);
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/43/comments'),
)
.length,
2,
);
});
/// ```gherkin
/// Scenario: Fall back to unknown identity when gh user lookup fails
/// 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 fake gh command that fails the authenticated user lookup
/// When the app processes one polling cycle
/// Then it still posts one comment
/// And the visible automation markers use the unknown identity fallback
/// ```
test('Fall back to unknown identity when gh user lookup fails', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-gh-fallback-');
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 ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
final issueData = [
{
'number': 33,
'title': 'Fallback identity',
'body': 'identity',
'state': 'open',
'html_url': 'https://example.test/issues/33',
'updated_at': '2026-04-04T16:10:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 330,
'body': '@helper who are you?',
'html_url': 'https://example.test/issues/33#issuecomment-330',
'created_at': '2026-04-04T16:10:00Z',
'updated_at': '2026-04-04T16:10:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {33: commentsData},
postCommentIssueNumber: 33,
ghLog: ghLog,
postedBodyFile: postedBody,
failViewerLookup: true,
);
// 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': 'fallback reply',
'summary': 'posted',
});
// And a fake gh command that fails the authenticated user lookup.
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 still posts one comment.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/33/comments'),
)
.length,
2,
);
// And the visible automation markers use the unknown identity fallback.
final postedComment = await postedBody.readAsString();
expect(postedComment, contains('@unknown'));
});
/// ```gherkin
/// Scenario: Skip issue evaluation when no responders are configured
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data with a comment that would otherwise be evaluated
/// And an AO subset config that enables the assistant without defining responders
/// When the app processes one polling cycle
/// Then it completes without throwing an error
/// And it records the issue thread as evaluated without a responder decision
/// And it stores the job status as skipped
/// ```
test('Skip issue evaluation when no responders are configured', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-no-responders-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data with a comment that would otherwise be evaluated.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 7,
'title': 'Need help',
'body': 'Please review this thread.',
'state': 'open',
'html_url': 'https://example.test/issues/7',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 70,
'body': '@helper can you take a look?',
'html_url': 'https://example.test/issues/7#issuecomment-70',
'created_at': '2026-04-04T14:00:00Z',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {7: commentsData},
);
// And an AO subset config that enables the assistant without defining responders.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
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();
final thread = await app.database.findIssueThread('sample', 7);
final jobs = await app.database.select(app.database.jobs).get();
await app.close();
// Then it completes without throwing an error.
expect(thread, isNotNull);
// And it records the issue thread as evaluated without a responder decision.
expect(thread!.lastDecision, isNull);
expect(thread.lastEvaluatedFingerprint, thread.fingerprint);
// And it stores the job status as skipped.
expect(jobs, hasLength(1));
expect(jobs.single.status, JobStatus.skipped);
});
});
}