forked from bkinnightskytw/code_work_spawner
352 lines
13 KiB
Dart
352 lines
13 KiB
Dart
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 registerIssueAssistantAppRunOnceWorktreeTests() {
|
|
/// ```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: 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 > "${bashScriptPath(cwdCapture.path)}"
|
|
cat > "${bashScriptPath(stdinCapture.path)}"
|
|
cat <<'JSON'
|
|
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
|
|
JSON
|
|
''');
|
|
await makeScriptExecutable(responderScript);
|
|
|
|
// 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 = hostPathFromScript(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 > "${bashScriptPath(cwdCapture.path)}"
|
|
cat >/dev/null
|
|
cat <<'JSON'
|
|
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"}
|
|
JSON
|
|
''');
|
|
await makeScriptExecutable(responderScript);
|
|
|
|
// 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 = hostPathFromScript(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,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
void main() {
|
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
|
registerIssueAssistantAppRunOnceWorktreeTests();
|
|
}
|