diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 317ef01..fdd86f3 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -8,7 +8,8 @@ worktreeDir: ~/.worktrees defaults: issueAssistant: enabled: true - pollInterval: 5m + pollInterval: 30s + maxConcurrentIssues: 3 mentionTriggers: ["@codex", "@helper"] commentPrefixTemplate: | > [!NOTE] diff --git a/lib/src/config.dart b/lib/src/config.dart index 37b2a3b..b0a7781 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -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 mentionTriggers; final String prompt; final List 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. diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index b1b3d53..f2c88dd 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -27,6 +27,11 @@ class IssueAssistantApp { final GitHubClient githubClient; final CliResponderRunner responderRunner; final WorkspaceManager workspaceManager; + final Map _projectPollStates = {}; + final Set> _activePolls = >{}; + Completer? _runCompleter; + bool _isClosing = false; + bool _isClosed = false; static Future open({ required AppConfig config, @@ -44,9 +49,39 @@ class IssueAssistantApp { } Future run() async { - while (true) { - await runOnce(); - await Future.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(); + _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 close() => database.close(); + Future close() async { + if (_isClosed || _isClosing) { + return; + } - Duration _smallestPollInterval() { - return config.projects.values - .where((project) => project.issueAssistant.enabled) - .map((project) => project.issueAssistant.pollInterval) - .fold( - 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 _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 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 _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( + 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 _processIssuesConcurrently( + ProjectConfig project, + List issues, + ) async { + if (issues.isEmpty) { + return; + } + + var nextIndex = 0; + Future 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 _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; +} diff --git a/test/app_config_test.dart b/test/app_config_test.dart index b7f0680..07915dc 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -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 diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index a7e7d33..fa4ae4f 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -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 diff --git a/test/test_support.dart b/test/test_support.dart index 1e1b8c4..5221153 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -142,6 +142,25 @@ JSON await Process.run('chmod', ['+x', responderScript.path]); } +Future writeDelayedLoggingResponderScript( + File responderScript, { + required File eventLog, + required Duration delay, + required Map 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 initGitRepo(Directory directory) async { await runGit(['init'], workingDirectory: directory.path); await runGit([