code_work_spawner/test/worktree_test.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();
}