forked from bkinnightskytw/code_work_spawner
1292 lines
46 KiB
Dart
1292 lines
46 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);
|
|
});
|
|
|
|
/// ```gherkin
|
|
/// Scenario: Aggregate one search request across multiple enabled projects
|
|
/// Given two temporary project checkouts that can be used as local repo paths
|
|
/// And GitHub issue data containing an explicit assistant mention in each repository
|
|
/// And a responder that emits a planning reply
|
|
/// And an orchestrator-style config with both repositories enabled
|
|
/// When the app processes one polling cycle
|
|
/// Then it fetches issue updates through one aggregate GitHub search request
|
|
/// And it still posts one reply in each repository
|
|
/// ```
|
|
test('Aggregate one search request across multiple enabled projects', () async {
|
|
// Given two temporary project checkouts that can be used as local repo paths.
|
|
final sandbox = await Directory.systemTemp.createTemp(
|
|
'cws-aggregate-search-',
|
|
);
|
|
final repoOneDir = await Directory(
|
|
p.join(sandbox.path, 'repo-one'),
|
|
).create();
|
|
final repoTwoDir = await Directory(
|
|
p.join(sandbox.path, 'repo-two'),
|
|
).create();
|
|
await initGitRepo(repoOneDir);
|
|
await initGitRepo(repoTwoDir);
|
|
|
|
// And GitHub issue data containing an explicit assistant mention in each repository.
|
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
|
await writeFakeGhScript(
|
|
ghScript: ghScript,
|
|
issueListResponse: const [],
|
|
commentResponses: const {},
|
|
issueListResponsesByRepo: {
|
|
'owner/repo-one': [
|
|
{
|
|
'number': 11,
|
|
'title': 'Repo one mention',
|
|
'body': 'first repo',
|
|
'state': 'open',
|
|
'html_url': 'https://example.test/owner/repo-one/issues/11',
|
|
'updated_at': '2026-04-04T17:00:00Z',
|
|
'user': {'login': 'reporter-one'},
|
|
'labels': const [],
|
|
},
|
|
],
|
|
'owner/repo-two': [
|
|
{
|
|
'number': 22,
|
|
'title': 'Repo two mention',
|
|
'body': 'second repo',
|
|
'state': 'open',
|
|
'html_url': 'https://example.test/owner/repo-two/issues/22',
|
|
'updated_at': '2026-04-04T17:01:00Z',
|
|
'user': {'login': 'reporter-two'},
|
|
'labels': const [],
|
|
},
|
|
],
|
|
},
|
|
commentResponsesByRepo: {
|
|
'owner/repo-one': {
|
|
11: [
|
|
{
|
|
'id': 110,
|
|
'body': '@helper please review repo one',
|
|
'html_url':
|
|
'https://example.test/owner/repo-one/issues/11#issuecomment-110',
|
|
'created_at': '2026-04-04T17:00:00Z',
|
|
'updated_at': '2026-04-04T17:00:00Z',
|
|
'user': {'login': 'reporter-one'},
|
|
},
|
|
],
|
|
},
|
|
'owner/repo-two': {
|
|
22: [
|
|
{
|
|
'id': 220,
|
|
'body': '@helper please review repo two',
|
|
'html_url':
|
|
'https://example.test/owner/repo-two/issues/22#issuecomment-220',
|
|
'created_at': '2026-04-04T17:01:00Z',
|
|
'updated_at': '2026-04-04T17:01:00Z',
|
|
'user': {'login': 'reporter-two'},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
postCommentIssueNumbersByRepo: {
|
|
'owner/repo-one': {11},
|
|
'owner/repo-two': {22},
|
|
},
|
|
ghLog: ghLog,
|
|
);
|
|
|
|
// 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': 'aggregate reply',
|
|
'summary': 'posted planning advice',
|
|
});
|
|
|
|
// And an orchestrator-style config with both repositories enabled.
|
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
|
await configFile.writeAsString('''
|
|
defaults:
|
|
issueAssistant:
|
|
enabled: true
|
|
pollInterval: 30s
|
|
mentionTriggers: ["@helper"]
|
|
responders:
|
|
- id: primary
|
|
command: ${responderScript.path}
|
|
projects:
|
|
repoOne:
|
|
repo: owner/repo-one
|
|
path: ${repoOneDir.path}
|
|
repoTwo:
|
|
repo: owner/repo-two
|
|
path: ${repoTwoDir.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 fetches issue updates through one aggregate GitHub search request.
|
|
final logLines = await ghLog.readAsLines();
|
|
expect(
|
|
logLines.where((line) => line.contains('search/issues')).length,
|
|
1,
|
|
);
|
|
|
|
// And it still posts one reply in each repository.
|
|
expect(
|
|
logLines
|
|
.where(
|
|
(line) =>
|
|
line.contains('repos/owner/repo-one/issues/11/comments'),
|
|
)
|
|
.length,
|
|
2,
|
|
);
|
|
expect(
|
|
logLines
|
|
.where(
|
|
(line) =>
|
|
line.contains('repos/owner/repo-two/issues/22/comments'),
|
|
)
|
|
.length,
|
|
2,
|
|
);
|
|
});
|
|
});
|
|
}
|