fix: log gh webhook-forward stderr as errors (#17)
* fix: log webhook-forward stderr as errors (#16) * refactor: split issue assistant tests into focused files (#16)
This commit is contained in:
parent
eb066aa914
commit
089b7bffc1
|
|
@ -279,7 +279,13 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
stream.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
stream.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
||||||
line,
|
line,
|
||||||
) {
|
) {
|
||||||
_log('github/webhook-forward project=${project.key} $level=$line');
|
final message =
|
||||||
|
'github/webhook-forward project=${project.key} $level=$line';
|
||||||
|
if (level == 'stderr') {
|
||||||
|
_logError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_log(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
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 registerIssueAssistantAppRunOnceConcurrencyTests() {
|
||||||
|
/// ```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: 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 all responder executions start before the first responder finishes
|
||||||
|
/// 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.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then all responder executions start before the first responder finishes.
|
||||||
|
final eventLines = await eventLog.readAsLines();
|
||||||
|
final startLines = eventLines
|
||||||
|
.where((line) => line.startsWith('start:'))
|
||||||
|
.toList(growable: false);
|
||||||
|
final endLines = eventLines
|
||||||
|
.where((line) => line.startsWith('end:'))
|
||||||
|
.toList(growable: false);
|
||||||
|
final startTimes =
|
||||||
|
startLines
|
||||||
|
.map((line) => int.parse(line.split(':').last))
|
||||||
|
.toList(growable: false)
|
||||||
|
..sort();
|
||||||
|
final endTimes =
|
||||||
|
endLines
|
||||||
|
.map((line) => int.parse(line.split(':').last))
|
||||||
|
.toList(growable: false)
|
||||||
|
..sort();
|
||||||
|
expect(startLines, hasLength(3));
|
||||||
|
expect(endLines, hasLength(3));
|
||||||
|
expect(endTimes.first, greaterThanOrEqualTo(startTimes.last));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunOnceConcurrencyTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
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 registerIssueAssistantAppRunOnceCoreTests() {
|
||||||
|
/// ```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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunOnceCoreTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
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 registerIssueAssistantAppRunOnceMultiProjectTests() {
|
||||||
|
/// ```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: Aggregate one search request across multiple enabled projects while ignoring closed issues
|
||||||
|
/// 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 one of the search results is a closed issue
|
||||||
|
/// 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 for open issues only
|
||||||
|
/// And it still posts one reply in each repository
|
||||||
|
/// ```
|
||||||
|
test(
|
||||||
|
'Aggregate one search request across multiple enabled projects while ignoring closed issues',
|
||||||
|
() 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 [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'number': 23,
|
||||||
|
'title': 'Repo two closed mention',
|
||||||
|
'body': 'closed repo issue',
|
||||||
|
'state': 'closed',
|
||||||
|
'html_url': 'https://example.test/owner/repo-two/issues/23',
|
||||||
|
'updated_at': '2026-04-04T17:02:00Z',
|
||||||
|
'user': {'login': 'reporter-three'},
|
||||||
|
'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'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
23: [
|
||||||
|
{
|
||||||
|
'id': 230,
|
||||||
|
'body': '@helper please review closed repo two',
|
||||||
|
'html_url':
|
||||||
|
'https://example.test/owner/repo-two/issues/23#issuecomment-230',
|
||||||
|
'created_at': '2026-04-04T17:02:00Z',
|
||||||
|
'updated_at': '2026-04-04T17:02:00Z',
|
||||||
|
'user': {'login': 'reporter-three'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 for open issues only.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
final searchLogs = logLines
|
||||||
|
.where((line) => line.contains('search/issues'))
|
||||||
|
.toList();
|
||||||
|
expect(searchLogs.length, 1);
|
||||||
|
expect(searchLogs.single, contains('state:open'));
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) =>
|
||||||
|
line.contains('repos/owner/repo-two/issues/23/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reply through Gitea when a user explicitly mentions the assistant
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And Gitea issue data containing a comment with an explicit assistant mention
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that marks the project provider as gitea
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then it reads issue comments and posts exactly one reply through tea for that issue
|
||||||
|
/// ```
|
||||||
|
test('Reply through Gitea 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-gitea-run-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And Gitea issue data containing a comment with an explicit assistant mention.
|
||||||
|
final teaScript = File(p.join(sandbox.path, 'tea'));
|
||||||
|
final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
|
await writeFakeTeaScript(
|
||||||
|
teaScript: teaScript,
|
||||||
|
issueListResponsesByRepo: {
|
||||||
|
'owner/sample': [
|
||||||
|
{
|
||||||
|
'index': 12,
|
||||||
|
'title': 'Need architecture help',
|
||||||
|
'body': 'Please review the API layering.',
|
||||||
|
'state': 'open',
|
||||||
|
'url': 'https://gitea.example.test/owner/sample/issues/12',
|
||||||
|
'updated_at': '2026-04-04T12:00:00Z',
|
||||||
|
'poster': {'login': 'reporter'},
|
||||||
|
'labels': [
|
||||||
|
{'name': 'help'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
commentResponsesByRepo: {
|
||||||
|
'owner/sample': {
|
||||||
|
12: [
|
||||||
|
{
|
||||||
|
'id': 50,
|
||||||
|
'body': '@helper can you plan the architecture?',
|
||||||
|
'url':
|
||||||
|
'https://gitea.example.test/owner/sample/issues/12#issuecomment-50',
|
||||||
|
'created_at': '2026-04-04T12:00:00Z',
|
||||||
|
'updated_at': '2026-04-04T12:00:00Z',
|
||||||
|
'poster': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
postCommentIssueNumbersByRepo: {
|
||||||
|
'owner/sample': {12},
|
||||||
|
},
|
||||||
|
teaLog: teaLog,
|
||||||
|
postedBodyFile: postedBody,
|
||||||
|
viewerLogin: 'tea-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- keep provider-specific commands isolated',
|
||||||
|
'summary': 'posted planning advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that marks the project provider as gitea.
|
||||||
|
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:
|
||||||
|
provider: gitea
|
||||||
|
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'),
|
||||||
|
teaCommand: teaScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the app processes one polling cycle.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then it reads issue comments and posts exactly one reply through tea for that issue.
|
||||||
|
final logLines = await teaLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues?state=open'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues/12/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(postedComment, contains('via `tea`'));
|
||||||
|
expect(postedComment, contains('@tea-octocat'));
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- keep provider-specific commands isolated'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunOnceMultiProjectTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
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 registerIssueAssistantAppRunOnceNotificationTests() {
|
||||||
|
/// ```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: 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: Send Discord webhook notifications for mention and reply events
|
||||||
|
/// 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 local webhook server that records notification requests
|
||||||
|
/// And an orchestrator-style config with a discord webhook notifier
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then the webhook server receives one mention_detected notification
|
||||||
|
/// And the webhook server receives one reply_posted notification
|
||||||
|
/// ```
|
||||||
|
test(
|
||||||
|
'Send Discord webhook notifications for mention and reply events',
|
||||||
|
() async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-discord-notify-',
|
||||||
|
);
|
||||||
|
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': 61,
|
||||||
|
'title': 'Need architecture help',
|
||||||
|
'body': 'Please review the notifier design.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/61',
|
||||||
|
'updated_at': '2026-04-04T17:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 610,
|
||||||
|
'body': '@helper please review the notifier design',
|
||||||
|
'html_url': 'https://example.test/issues/61#issuecomment-610',
|
||||||
|
'created_at': '2026-04-04T17:10:00Z',
|
||||||
|
'updated_at': '2026-04-04T17:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {61: commentsData},
|
||||||
|
postCommentIssueNumber: 61,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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': 'discord notification reply',
|
||||||
|
'summary': 'posted planning advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And a local webhook server that records notification requests.
|
||||||
|
final requests = <Map<String, dynamic>>[];
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
unawaited(() async {
|
||||||
|
await for (final request in server) {
|
||||||
|
final body = await utf8.decoder.bind(request).join();
|
||||||
|
requests.add(jsonDecode(body) as Map<String, dynamic>);
|
||||||
|
request.response.statusCode = HttpStatus.noContent;
|
||||||
|
await request.response.close();
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|
||||||
|
// And an orchestrator-style config with a discord webhook notifier.
|
||||||
|
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}
|
||||||
|
notifications:
|
||||||
|
- type: discordWebhook
|
||||||
|
enabled: true
|
||||||
|
events: ["mention_detected", "reply_posted"]
|
||||||
|
webhookUrl: http://${server.address.host}:${server.port}/discord
|
||||||
|
username: code-work-spawner
|
||||||
|
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();
|
||||||
|
await server.close(force: true);
|
||||||
|
|
||||||
|
// Then the webhook server receives one mention_detected notification.
|
||||||
|
expect(requests, hasLength(2));
|
||||||
|
expect(requests.first['username'], 'code-work-spawner');
|
||||||
|
expect(
|
||||||
|
requests.first['content'] as String,
|
||||||
|
contains('**Mention detected**'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the webhook server receives one reply_posted notification.
|
||||||
|
expect(
|
||||||
|
requests.last['content'] as String,
|
||||||
|
contains('**Reply posted**'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
requests.last['content'] as String,
|
||||||
|
contains('summary: posted planning advice'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunOnceNotificationTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
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 > "${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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunOnceWorktreeTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import 'dart:async';
|
||||||
|
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 registerIssueAssistantAppRunRetryTests() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: Continuous issue polling
|
||||||
|
///
|
||||||
|
/// As a maintainer running the long-lived poller
|
||||||
|
/// I want transient tracker network failures to be logged without stopping the process
|
||||||
|
/// So that the assistant can recover automatically when connectivity returns
|
||||||
|
/// ```
|
||||||
|
group('IssueAssistantApp.run', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Continue polling after a transient GitHub search failure
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that fails the first search request and succeeds afterwards
|
||||||
|
/// And an orchestrator-style config with a short poll interval
|
||||||
|
/// When the long-running app starts and enough time passes for a retry
|
||||||
|
/// Then the app logs the failed poll without stopping entirely
|
||||||
|
/// And it performs another search request after the transient failure
|
||||||
|
/// ```
|
||||||
|
test('Continue polling after a transient GitHub search failure', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-run-retry-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that fails the first search request and succeeds afterwards.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final failureState = File(p.join(sandbox.path, 'search.failed.once'));
|
||||||
|
await writeFlakySearchGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
failureStateFile: failureState,
|
||||||
|
issueListResponse: [
|
||||||
|
{
|
||||||
|
'number': 81,
|
||||||
|
'title': 'Recovered issue polling',
|
||||||
|
'body': 'Retry should succeed.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/81',
|
||||||
|
'updated_at': '2026-04-05T13:45:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
commentResponses: {
|
||||||
|
81: [
|
||||||
|
{
|
||||||
|
'id': 810,
|
||||||
|
'body': 'connectivity recovered',
|
||||||
|
'html_url': 'https://example.test/issues/81#issuecomment-810',
|
||||||
|
'created_at': '2026-04-05T13:45:00Z',
|
||||||
|
'updated_at': '2026-04-05T13:45:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ghLog: ghLog,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And an orchestrator-style config with a short poll interval.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 1s
|
||||||
|
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 long-running app starts and enough time passes for a retry.
|
||||||
|
final runFuture = app.run();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 2600));
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
await subscription.cancel();
|
||||||
|
Logger.root.level = previousLevel;
|
||||||
|
|
||||||
|
// Then the app logs the failed poll without stopping entirely.
|
||||||
|
expect(
|
||||||
|
logMessages.any(
|
||||||
|
(message) =>
|
||||||
|
message.contains('poll tick source=startup') &&
|
||||||
|
message.contains('error=ProcessException'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logMessages.any((message) => message.contains('will_retry=true')),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it performs another search request after the transient failure.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) =>
|
||||||
|
line ==
|
||||||
|
'api -X GET search/issues -f q=repo:owner/sample is:issue state:open -f sort=updated -f order=asc -f per_page=100 -f page=1',
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
greaterThanOrEqualTo(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunRetryTests();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
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 registerIssueAssistantAppRunWebhookForwardTests() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: GitHub webhook forward issue events
|
||||||
|
///
|
||||||
|
/// As a maintainer running the long-lived assistant
|
||||||
|
/// I want GitHub webhook-forward events to trigger issue evaluation without polling
|
||||||
|
/// So that comment mentions can be processed through a channel-based source
|
||||||
|
/// ```
|
||||||
|
group('IssueAssistantApp.run webhook-forward', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reply to a mention received through gh/webhook-forward
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that forwards one issue_comment webhook event
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that uses gh/webhook-forward
|
||||||
|
/// When the long-running app starts and receives the forwarded webhook
|
||||||
|
/// Then it reads issue comments and posts exactly one reply for that issue
|
||||||
|
/// And the gh webhook forward command is invoked for the GitHub project
|
||||||
|
/// ```
|
||||||
|
test('Reply to a mention received through gh/webhook-forward', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-webhook-run-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that forwards one issue_comment webhook event.
|
||||||
|
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 issue = {
|
||||||
|
'number': 12,
|
||||||
|
'title': 'Need channel-based trigger handling',
|
||||||
|
'body': 'Please react to webhook-forward.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/12',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const <Map<String, Object?>>[],
|
||||||
|
};
|
||||||
|
final comments = [
|
||||||
|
{
|
||||||
|
'id': 51,
|
||||||
|
'body': '@helper can you handle forwarded comments?',
|
||||||
|
'html_url': 'https://example.test/issues/12#issuecomment-51',
|
||||||
|
'created_at': '2026-04-08T04:00:00Z',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeWebhookForwardGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
repo: 'owner/sample',
|
||||||
|
issue: issue,
|
||||||
|
comments: comments,
|
||||||
|
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- consume forwarded issue_comment events',
|
||||||
|
'summary': 'posted channel-based planning advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses gh/webhook-forward.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (await postedBody.exists()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
|
||||||
|
// Then it reads issue comments and posts exactly one reply for that issue.
|
||||||
|
expect(await postedBody.exists(), isTrue);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- consume forwarded issue_comment events'),
|
||||||
|
);
|
||||||
|
expect(postedComment, contains('@octocat'));
|
||||||
|
|
||||||
|
// And the gh webhook forward command is invoked for the GitHub project.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines.any(
|
||||||
|
(line) => line.contains(
|
||||||
|
'webhook forward --events=issues,issue_comment --repo=owner/sample',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Log webhook forward stderr at error level
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that writes a webhook creation failure to stderr while forwarding
|
||||||
|
/// And an orchestrator-style config that uses gh/webhook-forward
|
||||||
|
/// When the long-running app starts and webhook-forward emits that stderr line
|
||||||
|
/// Then the app logs the webhook-forward stderr message at severe level
|
||||||
|
/// ```
|
||||||
|
test('Log webhook forward stderr at error level', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-webhook-stderr-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that writes a webhook creation failure to stderr while forwarding.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then
|
||||||
|
echo "Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)" >&2
|
||||||
|
while true; do
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({'login': 'octocat'})}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected gh args: \$*" >&2
|
||||||
|
exit 1
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses gh/webhook-forward.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
final records = <LogRecord>[];
|
||||||
|
final previousLevel = Logger.root.level;
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
final subscription = Logger.root.onRecord.listen(records.add);
|
||||||
|
|
||||||
|
// When the long-running app starts and webhook-forward emits that stderr line.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (records.any(
|
||||||
|
(record) => record.message.contains('error creating webhook'),
|
||||||
|
)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
await subscription.cancel();
|
||||||
|
Logger.root.level = previousLevel;
|
||||||
|
|
||||||
|
// Then the app logs the webhook-forward stderr message at severe level.
|
||||||
|
expect(
|
||||||
|
records.any(
|
||||||
|
(record) =>
|
||||||
|
record.level == Level.SEVERE &&
|
||||||
|
record.message.contains(
|
||||||
|
'stderr=Error: error creating webhook: HTTP 422: Validation Failed',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reconcile a missed GitHub webhook event through polling
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that starts webhook forwarding without delivering an event
|
||||||
|
/// And the same gh script returns a new issue only on a later reconciliation poll
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval
|
||||||
|
/// When the long-running app starts and enough time passes for reconciliation polling
|
||||||
|
/// Then it posts exactly one reply for the issue discovered by polling
|
||||||
|
/// And the gh webhook forward command is still invoked for the GitHub project
|
||||||
|
/// And the app performs more than one GitHub search request while running
|
||||||
|
/// ```
|
||||||
|
test('Reconcile a missed GitHub webhook event through polling', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-webhook-reconcile-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that starts webhook forwarding without delivering an event.
|
||||||
|
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 searchCountFile = File(p.join(sandbox.path, 'search-count'));
|
||||||
|
final issue = {
|
||||||
|
'number': 18,
|
||||||
|
'title': 'Need reconciliation after a missed webhook',
|
||||||
|
'body': 'Please recover this mention from polling.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/18',
|
||||||
|
'updated_at': '2026-04-08T05:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const <Map<String, Object?>>[],
|
||||||
|
};
|
||||||
|
final searchItem = {
|
||||||
|
...issue,
|
||||||
|
'repository_url': 'https://api.github.com/repos/owner/sample',
|
||||||
|
};
|
||||||
|
final comments = [
|
||||||
|
{
|
||||||
|
'id': 181,
|
||||||
|
'body': '@helper please recover this even if the webhook was missed',
|
||||||
|
'html_url': 'https://example.test/issues/18#issuecomment-181',
|
||||||
|
'created_at': '2026-04-08T05:00:00Z',
|
||||||
|
'updated_at': '2026-04-08T05:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "\$*" >> "${ghLog.path}"
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({'login': 'octocat'})}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
|
||||||
|
count=0
|
||||||
|
if [[ -f "${searchCountFile.path}" ]]; then
|
||||||
|
count="\$(cat "${searchCountFile.path}")"
|
||||||
|
fi
|
||||||
|
count=\$((count + 1))
|
||||||
|
printf '%s' "\$count" > "${searchCountFile.path}"
|
||||||
|
if [[ "\$count" -eq 1 ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
{"total_count":0,"incomplete_results":false,"items":[]}
|
||||||
|
JSON
|
||||||
|
else
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({
|
||||||
|
'total_count': 1,
|
||||||
|
'incomplete_results': false,
|
||||||
|
'items': [searchItem],
|
||||||
|
})}
|
||||||
|
JSON
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?per_page=100" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(comments)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then
|
||||||
|
for arg in "\$@"; do
|
||||||
|
if [[ "\$arg" == body=* ]]; then
|
||||||
|
printf '%s' "\${arg#body=}" > "${postedBody.path}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({'id': 703, 'html_url': 'https://example.test/comment/703', 'body': 'posted'})}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then
|
||||||
|
while true; do
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected gh args: \$*" >&2
|
||||||
|
exit 1
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
|
||||||
|
// 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- reconcile missed webhook events through polling',
|
||||||
|
'summary': 'posted reconciliation advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 1s
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (await postedBody.exists()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
|
||||||
|
// Then it posts exactly one reply for the issue discovered by polling.
|
||||||
|
expect(await postedBody.exists(), isTrue);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- reconcile missed webhook events through polling'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the gh webhook forward command is still invoked for the GitHub project.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines.any(
|
||||||
|
(line) => line.contains(
|
||||||
|
'webhook forward --events=issues,issue_comment --repo=owner/sample',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the app performs more than one GitHub search request while running.
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) =>
|
||||||
|
line ==
|
||||||
|
'api -X GET search/issues -f q=repo:owner/sample is:issue state:open -f sort=updated -f order=asc -f per_page=100 -f page=1',
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
greaterThanOrEqualTo(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunWebhookForwardTests();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue