forked from bkinnightskytw/code_work_spawner
feat: update issue assistant configuration to support max concurrent issues and reduce poll interval
This commit is contained in:
parent
8469ed00e0
commit
eab1a3ffe8
|
|
@ -8,7 +8,8 @@ worktreeDir: ~/.worktrees
|
|||
defaults:
|
||||
issueAssistant:
|
||||
enabled: true
|
||||
pollInterval: 5m
|
||||
pollInterval: 30s
|
||||
maxConcurrentIssues: 3
|
||||
mentionTriggers: ["@codex", "@helper"]
|
||||
commentPrefixTemplate: |
|
||||
> [!NOTE]
|
||||
|
|
|
|||
|
|
@ -254,6 +254,7 @@ class IssueAssistantConfig {
|
|||
IssueAssistantConfig({
|
||||
required this.enabled,
|
||||
required this.pollInterval,
|
||||
required this.maxConcurrentIssues,
|
||||
required this.mentionTriggers,
|
||||
required this.prompt,
|
||||
required this.responders,
|
||||
|
|
@ -265,6 +266,7 @@ class IssueAssistantConfig {
|
|||
|
||||
final bool enabled;
|
||||
final Duration pollInterval;
|
||||
final int maxConcurrentIssues;
|
||||
final List<String> mentionTriggers;
|
||||
final String prompt;
|
||||
final List<CliResponderConfig> responders;
|
||||
|
|
@ -287,10 +289,16 @@ class IssueAssistantConfig {
|
|||
.map((item) => AssistantCapability.parse(item.toString()))
|
||||
.toSet()
|
||||
: {AssistantCapability.planning, AssistantCapability.bugVerification};
|
||||
final maxConcurrentIssues = _parsePositiveInt(
|
||||
map['maxConcurrentIssues'],
|
||||
fieldName: 'maxConcurrentIssues',
|
||||
defaultValue: 3,
|
||||
);
|
||||
|
||||
return IssueAssistantConfig(
|
||||
enabled: map['enabled'] as bool? ?? true,
|
||||
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '5m'),
|
||||
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '30s'),
|
||||
maxConcurrentIssues: maxConcurrentIssues,
|
||||
mentionTriggers:
|
||||
(map['mentionTriggers'] as List?)
|
||||
?.map((item) => item.toString())
|
||||
|
|
@ -324,6 +332,23 @@ class IssueAssistantConfig {
|
|||
};
|
||||
}
|
||||
|
||||
static int _parsePositiveInt(
|
||||
Object? value, {
|
||||
required String fieldName,
|
||||
required int defaultValue,
|
||||
}) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed < 1) {
|
||||
throw FormatException(
|
||||
'$fieldName must be an integer greater than or equal to 1.',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
static const String _defaultPrompt = '''
|
||||
You are a GitHub issue thread assistant.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ class IssueAssistantApp {
|
|||
final GitHubClient githubClient;
|
||||
final CliResponderRunner responderRunner;
|
||||
final WorkspaceManager workspaceManager;
|
||||
final Map<String, _ProjectPollState> _projectPollStates = {};
|
||||
final Set<Future<void>> _activePolls = <Future<void>>{};
|
||||
Completer<void>? _runCompleter;
|
||||
bool _isClosing = false;
|
||||
bool _isClosed = false;
|
||||
|
||||
static Future<IssueAssistantApp> open({
|
||||
required AppConfig config,
|
||||
|
|
@ -44,9 +49,39 @@ class IssueAssistantApp {
|
|||
}
|
||||
|
||||
Future<void> run() async {
|
||||
while (true) {
|
||||
await runOnce();
|
||||
await Future<void>.delayed(_smallestPollInterval());
|
||||
if (_isClosed) {
|
||||
throw StateError('IssueAssistantApp is already closed.');
|
||||
}
|
||||
if (_runCompleter != null) {
|
||||
return _runCompleter!.future;
|
||||
}
|
||||
|
||||
final enabledProjects = config.projects.values
|
||||
.where((project) => project.issueAssistant.enabled)
|
||||
.toList(growable: false);
|
||||
if (enabledProjects.isEmpty) {
|
||||
_log('no enabled projects configured');
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_runCompleter = completer;
|
||||
|
||||
for (final project in enabledProjects) {
|
||||
final state = _projectPollStates.putIfAbsent(
|
||||
project.key,
|
||||
() => _ProjectPollState(project: project),
|
||||
);
|
||||
unawaited(_triggerProjectPoll(project, source: 'startup'));
|
||||
state.timer = Timer.periodic(project.issueAssistant.pollInterval, (_) {
|
||||
unawaited(_triggerProjectPoll(project, source: 'interval'));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await completer.future;
|
||||
} finally {
|
||||
_cancelProjectPollTimers();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,16 +97,83 @@ class IssueAssistantApp {
|
|||
_log('poll cycle completed');
|
||||
}
|
||||
|
||||
Future<void> close() => database.close();
|
||||
Future<void> close() async {
|
||||
if (_isClosed || _isClosing) {
|
||||
return;
|
||||
}
|
||||
|
||||
Duration _smallestPollInterval() {
|
||||
return config.projects.values
|
||||
.where((project) => project.issueAssistant.enabled)
|
||||
.map((project) => project.issueAssistant.pollInterval)
|
||||
.fold<Duration>(
|
||||
const Duration(minutes: 5),
|
||||
(current, next) => next < current ? next : current,
|
||||
_isClosing = true;
|
||||
_cancelProjectPollTimers();
|
||||
_completeRunIfPending();
|
||||
await Future.wait(_activePolls.toList(growable: false));
|
||||
await database.close();
|
||||
_isClosed = true;
|
||||
_isClosing = false;
|
||||
}
|
||||
|
||||
Future<void> _triggerProjectPoll(
|
||||
ProjectConfig project, {
|
||||
required String source,
|
||||
}) async {
|
||||
if (_isClosing || _isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _projectPollStates.putIfAbsent(
|
||||
project.key,
|
||||
() => _ProjectPollState(project: project),
|
||||
);
|
||||
if (state.isRunning) {
|
||||
_log(
|
||||
'skip poll project=${project.key} reason=already_running source=$source',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
late final Future<void> pollFuture;
|
||||
pollFuture = () async {
|
||||
state.isRunning = true;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_log('poll tick project=${project.key} source=$source');
|
||||
try {
|
||||
await _pollProject(project);
|
||||
stopwatch.stop();
|
||||
_log(
|
||||
'poll tick project=${project.key} source=$source '
|
||||
'duration_ms=${stopwatch.elapsedMilliseconds}',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
stopwatch.stop();
|
||||
_logError(
|
||||
'poll tick project=${project.key} source=$source '
|
||||
'duration_ms=${stopwatch.elapsedMilliseconds} error=$error',
|
||||
);
|
||||
_cancelProjectPollTimers();
|
||||
if (!(_runCompleter?.isCompleted ?? true)) {
|
||||
_runCompleter!.completeError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
state.isRunning = false;
|
||||
_activePolls.remove(pollFuture);
|
||||
}
|
||||
}();
|
||||
|
||||
_activePolls.add(pollFuture);
|
||||
await pollFuture;
|
||||
}
|
||||
|
||||
void _cancelProjectPollTimers() {
|
||||
for (final state in _projectPollStates.values) {
|
||||
state.timer?.cancel();
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _completeRunIfPending() {
|
||||
final completer = _runCompleter;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollProject(ProjectConfig project) async {
|
||||
|
|
@ -94,13 +196,21 @@ class IssueAssistantApp {
|
|||
);
|
||||
_log('project=${project.key} fetched_issues=${issues.length}');
|
||||
|
||||
DateTime? latestSeen = since;
|
||||
for (final issue in issues) {
|
||||
if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) {
|
||||
latestSeen = issue.updatedAt;
|
||||
}
|
||||
await _processIssue(project, issue);
|
||||
}
|
||||
final latestSeen = issues.map((issue) => issue.updatedAt).fold<DateTime?>(
|
||||
since,
|
||||
(current, next) {
|
||||
if (current == null || next.isAfter(current)) {
|
||||
return next;
|
||||
}
|
||||
return current;
|
||||
},
|
||||
);
|
||||
|
||||
_log(
|
||||
'project=${project.key} issue_queue=${issues.length} '
|
||||
'max_concurrent=${project.issueAssistant.maxConcurrentIssues}',
|
||||
);
|
||||
await _processIssuesConcurrently(project, issues);
|
||||
|
||||
await database.upsertProjectState(
|
||||
projectKey: project.key,
|
||||
|
|
@ -123,6 +233,41 @@ class IssueAssistantApp {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _processIssuesConcurrently(
|
||||
ProjectConfig project,
|
||||
List<GitHubIssueSummary> issues,
|
||||
) async {
|
||||
if (issues.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextIndex = 0;
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final currentIndex = nextIndex;
|
||||
if (currentIndex >= issues.length) {
|
||||
return;
|
||||
}
|
||||
nextIndex += 1;
|
||||
final activeCount =
|
||||
currentIndex + 1 <= project.issueAssistant.maxConcurrentIssues
|
||||
? currentIndex + 1
|
||||
: project.issueAssistant.maxConcurrentIssues;
|
||||
_log(
|
||||
'project=${project.key} process_issue issue=${issues[currentIndex].number} '
|
||||
'queue_index=${currentIndex + 1}/${issues.length} active_limit=$activeCount',
|
||||
);
|
||||
await _processIssue(project, issues[currentIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount =
|
||||
issues.length < project.issueAssistant.maxConcurrentIssues
|
||||
? issues.length
|
||||
: project.issueAssistant.maxConcurrentIssues;
|
||||
await Future.wait([for (var i = 0; i < workerCount; i += 1) worker()]);
|
||||
}
|
||||
|
||||
Future<void> _processIssue(
|
||||
ProjectConfig project,
|
||||
GitHubIssueSummary issue,
|
||||
|
|
@ -192,93 +337,98 @@ class IssueAssistantApp {
|
|||
}
|
||||
|
||||
ResponderResult? selectedResult;
|
||||
String? workspacePath;
|
||||
Object? lastError;
|
||||
final needsWorktree = _looksLikeBugVerification(thread);
|
||||
final workspacePath = await workspaceManager.createEphemeralWorktree(
|
||||
project.path,
|
||||
);
|
||||
_log(
|
||||
'issue=${project.key}#${issue.number} workspace_mode='
|
||||
'worktree workspace=$workspacePath',
|
||||
);
|
||||
|
||||
for (final responder in project.issueAssistant.responders) {
|
||||
final needsWorktree = _looksLikeBugVerification(thread);
|
||||
workspacePath = await workspaceManager.createEphemeralWorktree(
|
||||
project.path,
|
||||
);
|
||||
_log(
|
||||
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||
'workspace=$workspacePath',
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await responderRunner.run(
|
||||
responder: responder,
|
||||
project: project,
|
||||
thread: thread,
|
||||
prompt: prompt,
|
||||
forceReply: forceReply,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
jobId: jobId,
|
||||
responderId: result.responderId,
|
||||
mode: result.mode,
|
||||
decision: result.decision,
|
||||
success: true,
|
||||
workspacePath: workspacePath,
|
||||
stdout: result.rawStdout,
|
||||
stderr: result.rawStderr,
|
||||
);
|
||||
_logResponderOutput(
|
||||
responderId: responder.id,
|
||||
issueKey: '${project.key}#${issue.number}',
|
||||
stdout: result.rawStdout,
|
||||
stderr: result.rawStderr,
|
||||
);
|
||||
try {
|
||||
for (final responder in project.issueAssistant.responders) {
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number}, '
|
||||
'title="${thread.issue.title}", '
|
||||
'decision=${result.decision}, mode=${result.mode}',
|
||||
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||
'workspace=$workspacePath',
|
||||
);
|
||||
selectedResult = forceReply && !result.shouldReply
|
||||
? result.forceReply()
|
||||
: result;
|
||||
if (forceReply && !result.shouldReply) {
|
||||
|
||||
try {
|
||||
final result = await responderRunner.run(
|
||||
responder: responder,
|
||||
project: project,
|
||||
thread: thread,
|
||||
prompt: prompt,
|
||||
forceReply: forceReply,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
jobId: jobId,
|
||||
responderId: result.responderId,
|
||||
mode: result.mode,
|
||||
decision: result.decision,
|
||||
success: true,
|
||||
workspacePath: workspacePath,
|
||||
stdout: result.rawStdout,
|
||||
stderr: result.rawStderr,
|
||||
);
|
||||
_logResponderOutput(
|
||||
responderId: responder.id,
|
||||
issueKey: '${project.key}#${issue.number}',
|
||||
stdout: result.rawStdout,
|
||||
stderr: result.rawStderr,
|
||||
);
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number}, '
|
||||
'title="${thread.issue.title}", '
|
||||
'decision=${result.decision}, mode=${result.mode}',
|
||||
);
|
||||
selectedResult = forceReply && !result.shouldReply
|
||||
? result.forceReply()
|
||||
: result;
|
||||
if (forceReply && !result.shouldReply) {
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'override=force_reply original_decision=${result.decision}',
|
||||
);
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
final rawStdout = switch (error) {
|
||||
CliResponderExecutionException(:final rawStdout) => rawStdout,
|
||||
_ => '',
|
||||
};
|
||||
final rawStderr = switch (error) {
|
||||
CliResponderExecutionException(:final rawStderr) => rawStderr,
|
||||
_ => error.toString(),
|
||||
};
|
||||
_logResponderOutput(
|
||||
responderId: responder.id,
|
||||
issueKey: '${project.key}#${issue.number}',
|
||||
stdout: rawStdout,
|
||||
stderr: rawStderr,
|
||||
);
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'override=force_reply original_decision=${result.decision}',
|
||||
'failed error=$error',
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
jobId: jobId,
|
||||
responderId: responder.id,
|
||||
mode: 'error',
|
||||
decision: 'no_reply',
|
||||
success: false,
|
||||
workspacePath: workspacePath,
|
||||
stdout: rawStdout,
|
||||
stderr: rawStderr,
|
||||
);
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
final rawStdout = switch (error) {
|
||||
CliResponderExecutionException(:final rawStdout) => rawStdout,
|
||||
_ => '',
|
||||
};
|
||||
final rawStderr = switch (error) {
|
||||
CliResponderExecutionException(:final rawStderr) => rawStderr,
|
||||
_ => error.toString(),
|
||||
};
|
||||
_logResponderOutput(
|
||||
responderId: responder.id,
|
||||
issueKey: '${project.key}#${issue.number}',
|
||||
stdout: rawStdout,
|
||||
stderr: rawStderr,
|
||||
);
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'failed error=$error',
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
jobId: jobId,
|
||||
responderId: responder.id,
|
||||
mode: 'error',
|
||||
decision: 'no_reply',
|
||||
success: false,
|
||||
workspacePath: workspacePath,
|
||||
stdout: rawStdout,
|
||||
stderr: rawStderr,
|
||||
);
|
||||
} finally {
|
||||
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||
}
|
||||
} finally {
|
||||
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||
}
|
||||
|
||||
if (selectedResult == null) {
|
||||
|
|
@ -472,3 +622,11 @@ class IssueAssistantApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectPollState {
|
||||
_ProjectPollState({required this.project});
|
||||
|
||||
final ProjectConfig project;
|
||||
Timer? timer;
|
||||
bool isRunning = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ projects:
|
|||
config.projects['sample']!.issueAssistant.commentFooterTemplate,
|
||||
defaultCommentFooterTemplate,
|
||||
);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.pollInterval,
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
|
|
|
|||
|
|
@ -322,9 +322,9 @@ projects:
|
|||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Run mention replies from an ephemeral worktree with issue context
|
||||
/// 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 an explicit assistant mention
|
||||
/// 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
|
||||
|
|
@ -332,66 +332,192 @@ projects:
|
|||
/// 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 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);
|
||||
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 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': 24,
|
||||
'title': 'Need your thoughts',
|
||||
'body': 'Please review 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 give me your though about this CLI flow',
|
||||
'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 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
|
||||
// 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
|
||||
|
|
@ -418,24 +544,13 @@ projects:
|
|||
// 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 give me your though about this CLI flow'),
|
||||
p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
// And the ephemeral worktree directory is cleaned up afterwards.
|
||||
expect(await worktreeRoot.list().isEmpty, isTrue);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
|
|
@ -662,6 +777,176 @@ projects:
|
|||
);
|
||||
});
|
||||
|
||||
/// ```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
|
||||
|
|
|
|||
|
|
@ -142,6 +142,25 @@ JSON
|
|||
await Process.run('chmod', ['+x', responderScript.path]);
|
||||
}
|
||||
|
||||
Future<void> writeDelayedLoggingResponderScript(
|
||||
File responderScript, {
|
||||
required File eventLog,
|
||||
required Duration delay,
|
||||
required Map<String, Object?> response,
|
||||
}) async {
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}"
|
||||
cat >/dev/null
|
||||
sleep ${delay.inSeconds}
|
||||
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}"
|
||||
cat <<'JSON'
|
||||
${jsonEncode(response)}
|
||||
JSON
|
||||
''');
|
||||
await Process.run('chmod', ['+x', responderScript.path]);
|
||||
}
|
||||
|
||||
Future<void> initGitRepo(Directory directory) async {
|
||||
await runGit(['init'], workingDirectory: directory.path);
|
||||
await runGit([
|
||||
|
|
|
|||
Loading…
Reference in New Issue