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:
|
defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
enabled: true
|
enabled: true
|
||||||
pollInterval: 5m
|
pollInterval: 30s
|
||||||
|
maxConcurrentIssues: 3
|
||||||
mentionTriggers: ["@codex", "@helper"]
|
mentionTriggers: ["@codex", "@helper"]
|
||||||
commentPrefixTemplate: |
|
commentPrefixTemplate: |
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ class IssueAssistantConfig {
|
||||||
IssueAssistantConfig({
|
IssueAssistantConfig({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.pollInterval,
|
required this.pollInterval,
|
||||||
|
required this.maxConcurrentIssues,
|
||||||
required this.mentionTriggers,
|
required this.mentionTriggers,
|
||||||
required this.prompt,
|
required this.prompt,
|
||||||
required this.responders,
|
required this.responders,
|
||||||
|
|
@ -265,6 +266,7 @@ class IssueAssistantConfig {
|
||||||
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final Duration pollInterval;
|
final Duration pollInterval;
|
||||||
|
final int maxConcurrentIssues;
|
||||||
final List<String> mentionTriggers;
|
final List<String> mentionTriggers;
|
||||||
final String prompt;
|
final String prompt;
|
||||||
final List<CliResponderConfig> responders;
|
final List<CliResponderConfig> responders;
|
||||||
|
|
@ -287,10 +289,16 @@ class IssueAssistantConfig {
|
||||||
.map((item) => AssistantCapability.parse(item.toString()))
|
.map((item) => AssistantCapability.parse(item.toString()))
|
||||||
.toSet()
|
.toSet()
|
||||||
: {AssistantCapability.planning, AssistantCapability.bugVerification};
|
: {AssistantCapability.planning, AssistantCapability.bugVerification};
|
||||||
|
final maxConcurrentIssues = _parsePositiveInt(
|
||||||
|
map['maxConcurrentIssues'],
|
||||||
|
fieldName: 'maxConcurrentIssues',
|
||||||
|
defaultValue: 3,
|
||||||
|
);
|
||||||
|
|
||||||
return IssueAssistantConfig(
|
return IssueAssistantConfig(
|
||||||
enabled: map['enabled'] as bool? ?? true,
|
enabled: map['enabled'] as bool? ?? true,
|
||||||
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '5m'),
|
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '30s'),
|
||||||
|
maxConcurrentIssues: maxConcurrentIssues,
|
||||||
mentionTriggers:
|
mentionTriggers:
|
||||||
(map['mentionTriggers'] as List?)
|
(map['mentionTriggers'] as List?)
|
||||||
?.map((item) => item.toString())
|
?.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 = '''
|
static const String _defaultPrompt = '''
|
||||||
You are a GitHub issue thread assistant.
|
You are a GitHub issue thread assistant.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ class IssueAssistantApp {
|
||||||
final GitHubClient githubClient;
|
final GitHubClient githubClient;
|
||||||
final CliResponderRunner responderRunner;
|
final CliResponderRunner responderRunner;
|
||||||
final WorkspaceManager workspaceManager;
|
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({
|
static Future<IssueAssistantApp> open({
|
||||||
required AppConfig config,
|
required AppConfig config,
|
||||||
|
|
@ -44,9 +49,39 @@ class IssueAssistantApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
while (true) {
|
if (_isClosed) {
|
||||||
await runOnce();
|
throw StateError('IssueAssistantApp is already closed.');
|
||||||
await Future<void>.delayed(_smallestPollInterval());
|
}
|
||||||
|
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');
|
_log('poll cycle completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> close() => database.close();
|
Future<void> close() async {
|
||||||
|
if (_isClosed || _isClosing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Duration _smallestPollInterval() {
|
_isClosing = true;
|
||||||
return config.projects.values
|
_cancelProjectPollTimers();
|
||||||
.where((project) => project.issueAssistant.enabled)
|
_completeRunIfPending();
|
||||||
.map((project) => project.issueAssistant.pollInterval)
|
await Future.wait(_activePolls.toList(growable: false));
|
||||||
.fold<Duration>(
|
await database.close();
|
||||||
const Duration(minutes: 5),
|
_isClosed = true;
|
||||||
(current, next) => next < current ? next : current,
|
_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 {
|
Future<void> _pollProject(ProjectConfig project) async {
|
||||||
|
|
@ -94,13 +196,21 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
_log('project=${project.key} fetched_issues=${issues.length}');
|
_log('project=${project.key} fetched_issues=${issues.length}');
|
||||||
|
|
||||||
DateTime? latestSeen = since;
|
final latestSeen = issues.map((issue) => issue.updatedAt).fold<DateTime?>(
|
||||||
for (final issue in issues) {
|
since,
|
||||||
if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) {
|
(current, next) {
|
||||||
latestSeen = issue.updatedAt;
|
if (current == null || next.isAfter(current)) {
|
||||||
}
|
return next;
|
||||||
await _processIssue(project, issue);
|
}
|
||||||
}
|
return current;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_log(
|
||||||
|
'project=${project.key} issue_queue=${issues.length} '
|
||||||
|
'max_concurrent=${project.issueAssistant.maxConcurrentIssues}',
|
||||||
|
);
|
||||||
|
await _processIssuesConcurrently(project, issues);
|
||||||
|
|
||||||
await database.upsertProjectState(
|
await database.upsertProjectState(
|
||||||
projectKey: project.key,
|
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(
|
Future<void> _processIssue(
|
||||||
ProjectConfig project,
|
ProjectConfig project,
|
||||||
GitHubIssueSummary issue,
|
GitHubIssueSummary issue,
|
||||||
|
|
@ -192,93 +337,98 @@ class IssueAssistantApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponderResult? selectedResult;
|
ResponderResult? selectedResult;
|
||||||
String? workspacePath;
|
|
||||||
Object? lastError;
|
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) {
|
try {
|
||||||
final needsWorktree = _looksLikeBugVerification(thread);
|
for (final responder in project.issueAssistant.responders) {
|
||||||
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,
|
|
||||||
);
|
|
||||||
_log(
|
_log(
|
||||||
'responder=${responder.id} issue=${project.key}#${issue.number}, '
|
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
'title="${thread.issue.title}", '
|
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||||
'decision=${result.decision}, mode=${result.mode}',
|
'workspace=$workspacePath',
|
||||||
);
|
);
|
||||||
selectedResult = forceReply && !result.shouldReply
|
|
||||||
? result.forceReply()
|
try {
|
||||||
: result;
|
final result = await responderRunner.run(
|
||||||
if (forceReply && !result.shouldReply) {
|
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(
|
_log(
|
||||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
'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) {
|
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,
|
config.projects['sample']!.issueAssistant.commentFooterTemplate,
|
||||||
defaultCommentFooterTemplate,
|
defaultCommentFooterTemplate,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.pollInterval,
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
|
|
|
||||||
|
|
@ -322,9 +322,9 @@ projects:
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```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
|
/// 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 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
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
/// When the app processes one polling cycle
|
/// When the app processes one polling cycle
|
||||||
|
|
@ -332,66 +332,192 @@ projects:
|
||||||
/// And the responder stdin includes the latest issue comment text
|
/// 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
|
/// 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 {
|
test(
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
'Run bug verification mention replies from an ephemeral worktree with issue context',
|
||||||
final sandbox = await Directory.systemTemp.createTemp(
|
() async {
|
||||||
'cws-mention-worktree-',
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
);
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
'cws-mention-worktree-',
|
||||||
await initGitRepo(repoDir);
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
// And GitHub issue data containing an explicit assistant mention.
|
// And GitHub issue data containing a bug verification mention.
|
||||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
final issueData = [
|
final issueData = [
|
||||||
{
|
{
|
||||||
'number': 24,
|
'number': 24,
|
||||||
'title': 'Need your thoughts',
|
'title': 'Need help verifying a bug',
|
||||||
'body': 'Please review the CLI workflow.',
|
'body': 'Please verify and reproduce this bug in the CLI workflow.',
|
||||||
'state': 'open',
|
'state': 'open',
|
||||||
'html_url': 'https://example.test/issues/24',
|
'html_url': 'https://example.test/issues/24',
|
||||||
'updated_at': '2026-04-04T15:30:00Z',
|
'updated_at': '2026-04-04T15:30:00Z',
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
'labels': const [],
|
'labels': const [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
final commentsData = [
|
final commentsData = [
|
||||||
{
|
{
|
||||||
'id': 240,
|
'id': 240,
|
||||||
'body': '@helper give me your though about this CLI flow',
|
'body': '@helper please verify and reproduce this bug',
|
||||||
'html_url': 'https://example.test/issues/24#issuecomment-240',
|
'html_url': 'https://example.test/issues/24#issuecomment-240',
|
||||||
'created_at': '2026-04-04T15:30:00Z',
|
'created_at': '2026-04-04T15:30:00Z',
|
||||||
'updated_at': '2026-04-04T15:30:00Z',
|
'updated_at': '2026-04-04T15:30:00Z',
|
||||||
'user': {'login': 'reporter'},
|
'user': {'login': 'reporter'},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await writeFakeGhScript(
|
await writeFakeGhScript(
|
||||||
ghScript: ghScript,
|
ghScript: ghScript,
|
||||||
issueListResponse: issueData,
|
issueListResponse: issueData,
|
||||||
commentResponses: {24: commentsData},
|
commentResponses: {24: commentsData},
|
||||||
ghLog: ghLog,
|
ghLog: ghLog,
|
||||||
postCommentIssueNumber: 24,
|
postCommentIssueNumber: 24,
|
||||||
);
|
);
|
||||||
|
|
||||||
// And a responder that records its working directory and stdin before returning no_reply.
|
// And a responder that records its working directory and stdin before returning no_reply.
|
||||||
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
|
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
|
||||||
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
|
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
|
||||||
final responderScript = File(
|
final responderScript = File(
|
||||||
p.join(sandbox.path, 'responder-capture.sh'),
|
p.join(sandbox.path, 'responder-capture.sh'),
|
||||||
);
|
);
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
pwd > "${cwdCapture.path}"
|
pwd > "${cwdCapture.path}"
|
||||||
cat > "${stdinCapture.path}"
|
cat > "${stdinCapture.path}"
|
||||||
cat <<'JSON'
|
cat <<'JSON'
|
||||||
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
|
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
|
||||||
JSON
|
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]);
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
|
||||||
// And an orchestrator-style config pointing to the fake repo and responder.
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
await configFile.writeAsString('''
|
await configFile.writeAsString('''
|
||||||
|
worktreeDir: ${worktreeRoot.path}
|
||||||
defaults:
|
defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
@ -418,24 +544,13 @@ projects:
|
||||||
// Then the responder runs from an ephemeral worktree instead of the source checkout.
|
// Then the responder runs from an ephemeral worktree instead of the source checkout.
|
||||||
final capturedCwd = await cwdCapture.readAsString();
|
final capturedCwd = await cwdCapture.readAsString();
|
||||||
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
|
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(
|
expect(
|
||||||
capturedStdin,
|
p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
|
||||||
contains('@helper give me your though about this CLI flow'),
|
isTrue,
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
|
// And the ephemeral worktree directory is cleaned up afterwards.
|
||||||
final logLines = await ghLog.readAsLines();
|
expect(await worktreeRoot.list().isEmpty, isTrue);
|
||||||
expect(
|
|
||||||
logLines
|
|
||||||
.where(
|
|
||||||
(line) => line.contains('repos/owner/sample/issues/24/comments'),
|
|
||||||
)
|
|
||||||
.length,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```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
|
/// ```gherkin
|
||||||
/// Scenario: Fall back to unknown identity when gh user lookup fails
|
/// 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
|
/// 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]);
|
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 {
|
Future<void> initGitRepo(Directory directory) async {
|
||||||
await runGit(['init'], workingDirectory: directory.path);
|
await runGit(['init'], workingDirectory: directory.path);
|
||||||
await runGit([
|
await runGit([
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue