code_work_spawner/test/issue_assistant_app_test.dart

1702 lines
61 KiB
Dart

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