diff --git a/examples/README.md b/examples/README.md index b073b8e..7b33ca9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,11 @@ event source. GitHub projects default to `gh/polling`, and Gitea projects default to `tea/polling`. GitHub projects can switch their long-running runtime to -`gh/webhook-forward`, and Gitea projects can switch to `tea/gosmee`. +`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to +`tea/gosmee`. +`gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee +URL, then keeps the same polling reconciliation path active for startup sync +and missed-event recovery. `gh/webhook-forward` starts `gh webhook forward` for `issues` and `issue_comment` events while keeping periodic polling reconciliation for startup sync and missed-event recovery. diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index e10164b..7cc89cb 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -7,8 +7,8 @@ worktreeDir: ~/.worktrees defaults: issueAssistant: enabled: true - # Use gh/webhook-forward for long-running runs while keeping --once on polling. - # eventSource: gh/webhook-forward + # Use gh/gosmee for long-running runs while keeping --once on polling. + # eventSource: gh/gosmee pollInterval: 30s maxConcurrentIssues: 3 mentionTriggers: ["@codex", "@helper"] diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index 9c44c5a..c67d03a 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -1,8 +1,8 @@ export 'src/comment_templates.dart'; export 'src/config.dart'; export 'src/database.dart'; +export 'src/issue_event_source/base.dart'; export 'src/issue_tracker_client.dart'; -export 'src/issue_event_source.dart'; export 'src/issue_assistant_app.dart'; export 'src/notifier.dart'; export 'src/workspace_manager.dart'; diff --git a/lib/src/config.dart b/lib/src/config.dart index 7e3347f..3a9eb20 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -202,6 +202,7 @@ class ProjectConfig { } switch (issueAssistant.eventSource) { case IssueAssistantEventSourceKind.ghPolling: + case IssueAssistantEventSourceKind.ghGosmee: case IssueAssistantEventSourceKind.ghWebhookForward: if (provider != IssueTrackerProvider.github) { throw FormatException( @@ -462,6 +463,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => 'gh/polling', IssueAssistantEventSourceKind.teaPolling => 'tea/polling', + IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee', IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward', IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee', }; @@ -471,6 +473,7 @@ bool _isPollingEventSource(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => true, IssueAssistantEventSourceKind.teaPolling => true, + IssueAssistantEventSourceKind.ghGosmee => false, IssueAssistantEventSourceKind.ghWebhookForward => false, IssueAssistantEventSourceKind.teaGosmee => false, }; @@ -724,7 +727,7 @@ ${_YamlEmitter().write({'projects': projectsSection})} # issueAssistant: # enabled: ${assistant?.enabled ?? true} - # eventSource: gh/webhook-forward + # eventSource: gh/gosmee # mentionTriggers: ["@helper"] # sessionPrefix: sample ''' diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index 709eeb4..de5ebac 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -186,6 +186,8 @@ enum IssueAssistantEventSourceKind { ghPolling, @JsonValue('tea/polling') teaPolling, + @JsonValue('gh/gosmee') + ghGosmee, @JsonValue('gh/webhook-forward') ghWebhookForward, @JsonValue('tea/gosmee') diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index ae031de..628afbc 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -7,9 +7,10 @@ import 'package:logging/logging.dart'; import 'cli_responder.dart'; import 'config.dart'; import 'database.dart'; -import 'issue_event_source.dart'; -import 'issue_event_source_gitea_gosmee.dart'; -import 'issue_event_source_github_webhook_forward.dart'; +import 'issue_event_source/base.dart'; +import 'issue_event_source/gitea_gosmee.dart'; +import 'issue_event_source/github_gosmee.dart'; +import 'issue_event_source/github_webhook_forward.dart'; import 'issue_tracker_client.dart'; import 'notifier.dart'; import 'workspace_manager.dart'; @@ -62,6 +63,11 @@ class IssueAssistantApp { sources: { IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, + IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( + issueTrackerClient: issueTrackerClient, + gosmeeCommand: gosmeeCommand, + logger: _logger, + ), IssueAssistantEventSourceKind.ghWebhookForward: GitHubWebhookForwardIssueEventSource( issueTrackerClient: issueTrackerClient, diff --git a/lib/src/issue_event_source.dart b/lib/src/issue_event_source/base.dart similarity index 96% rename from lib/src/issue_event_source.dart rename to lib/src/issue_event_source/base.dart index 90eda17..d228365 100644 --- a/lib/src/issue_event_source.dart +++ b/lib/src/issue_event_source/base.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:github/github.dart'; import 'package:logging/logging.dart'; -import 'config.dart'; -import 'database.dart'; -import 'issue_tracker_client.dart'; +import '../config.dart'; +import '../database.dart'; +import '../issue_tracker_client.dart'; typedef IssueEventBatchHandler = Future Function(IssueEventBatch batch); @@ -103,6 +103,12 @@ class RoutedIssueEventSource implements IssueEventSource { yield IssueAssistantEventSourceKind.ghPolling; case IssueAssistantEventSourceKind.teaPolling: yield IssueAssistantEventSourceKind.teaPolling; + case IssueAssistantEventSourceKind.ghGosmee: + // Keep reconciliation polling active for startup sync and missed + // webhook recovery while gosmee handles low-latency issue/comment + // events for GitHub projects. + yield IssueAssistantEventSourceKind.ghPolling; + yield IssueAssistantEventSourceKind.ghGosmee; case IssueAssistantEventSourceKind.ghWebhookForward: // Keep reconciliation polling active for startup sync and missed // webhook recovery while the forwarded channel handles low-latency diff --git a/lib/src/issue_event_source_gitea_gosmee.dart b/lib/src/issue_event_source/gitea_gosmee.dart similarity index 99% rename from lib/src/issue_event_source_gitea_gosmee.dart rename to lib/src/issue_event_source/gitea_gosmee.dart index 7408f6f..b82783b 100644 --- a/lib/src/issue_event_source_gitea_gosmee.dart +++ b/lib/src/issue_event_source/gitea_gosmee.dart @@ -5,9 +5,9 @@ import 'dart:io'; import 'package:github/github.dart'; import 'package:logging/logging.dart'; -import 'config.dart'; -import 'issue_event_source.dart'; -import 'issue_tracker_client.dart'; +import '../config.dart'; +import '../issue_tracker_client.dart'; +import 'base.dart'; class GiteaGosmeeIssueEventSource implements IssueEventSource { GiteaGosmeeIssueEventSource({ diff --git a/lib/src/issue_event_source/github_gosmee.dart b/lib/src/issue_event_source/github_gosmee.dart new file mode 100644 index 0000000..59ff444 --- /dev/null +++ b/lib/src/issue_event_source/github_gosmee.dart @@ -0,0 +1,398 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; + +import '../config.dart'; +import '../issue_tracker_client.dart'; +import 'base.dart'; + +class GitHubGosmeeIssueEventSource implements IssueEventSource { + GitHubGosmeeIssueEventSource({ + required this.issueTrackerClient, + required this.gosmeeCommand, + required Logger logger, + }) : _logger = logger; + + static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); + + final IssueTrackerClient issueTrackerClient; + final String gosmeeCommand; + final Logger _logger; + final Map _queuedIssues = {}; + final Set> _activeDispatches = >{}; + final Map _gosmeeProcesses = {}; + final Map _webhookIdsByProject = {}; + final Map _repoSlugsByProject = {}; + + HttpServer? _server; + Timer? _flushTimer; + Completer? _runCompleter; + bool _isClosing = false; + bool _isClosed = false; + + @override + Future run({ + required List projects, + required IssueEventBatchHandler onBatch, + }) async { + if (_isClosed) { + throw StateError('IssueEventSource is already closed.'); + } + if (_runCompleter != null) { + return _runCompleter!.future; + } + + final githubProjects = projects + .where((project) => project.provider == IssueTrackerProvider.github) + .toList(growable: false); + if (githubProjects.isEmpty) { + return; + } + + final completer = Completer(); + _runCompleter = completer; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _server = server; + final repoToProject = { + for (final project in githubProjects) project.repo: project, + }; + for (final project in githubProjects) { + _repoSlugsByProject[project.key] = project.repoSlug; + } + server.listen( + (request) => unawaited( + _handleWebhookRequest( + request: request, + repoToProject: repoToProject, + onBatch: onBatch, + ), + ), + onError: (Object error, StackTrace stackTrace) { + _logError('github/gosmee server error=$error'); + if (!_isClosing && !completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + ); + + final localUrl = + 'http://${server.address.address}:${server.port}/github/webhooks'; + for (final project in githubProjects) { + final publicUrl = await _createGosmeeChannelUrl(localUrl); + final webhookId = await issueTrackerClient.createGitHubIssueWebhook( + repo: project.repoSlug, + url: publicUrl, + ); + _webhookIdsByProject[project.key] = webhookId; + final process = await Process.start(gosmeeCommand, [ + 'client', + publicUrl, + localUrl, + ]); + _gosmeeProcesses[project.key] = process; + _pipeProcessLogs( + project: project, + stream: process.stdout, + level: 'stdout', + ); + _pipeProcessLogs( + project: project, + stream: process.stderr, + level: 'stderr', + ); + unawaited( + process.exitCode.then((exitCode) { + _gosmeeProcesses.remove(project.key); + _log( + 'github/gosmee process project=${project.key} exit_code=$exitCode', + ); + if (!_isClosing && !completer.isCompleted) { + completer.completeError( + StateError( + 'gosmee client exited for project=${project.key} ' + 'with code $exitCode.', + ), + ); + } + }), + ); + } + + try { + await completer.future; + } finally { + await close(); + } + } + + @override + Future runOnce({ + required List projects, + required IssueEventBatchHandler onBatch, + }) async { + throw UnsupportedError( + 'github/gosmee does not support runOnce; use polling instead.', + ); + } + + @override + Future close() async { + if (_isClosed || _isClosing) { + return; + } + + _isClosing = true; + _flushTimer?.cancel(); + _flushTimer = null; + final completer = _runCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + _runCompleter = null; + + final server = _server; + _server = null; + await server?.close(force: true); + + final processes = _gosmeeProcesses.values.toList(growable: false); + _gosmeeProcesses.clear(); + for (final process in processes) { + process.kill(); + } + for (final process in processes) { + await process.exitCode.catchError((_) => 0); + } + + final webhookIds = Map.from(_webhookIdsByProject); + _webhookIdsByProject.clear(); + for (final entry in webhookIds.entries) { + try { + await issueTrackerClient.deleteGitHubWebhook( + repo: _repoSlugsByProject[entry.key]!, + webhookId: entry.value, + ); + } catch (error) { + _logError( + 'github/gosmee delete webhook project=${entry.key} ' + 'webhook_id=${entry.value} error=$error', + ); + } + } + _repoSlugsByProject.clear(); + + await _flushQueuedIssues(); + await Future.wait(_activeDispatches.toList(growable: false)); + + _isClosed = true; + _isClosing = false; + } + + Future _handleWebhookRequest({ + required HttpRequest request, + required Map repoToProject, + required IssueEventBatchHandler onBatch, + }) async { + if (request.method != 'POST') { + request.response + ..statusCode = HttpStatus.methodNotAllowed + ..write('Only POST is supported.'); + await request.response.close(); + return; + } + + try { + final payloadText = await utf8.decoder.bind(request).join(); + final payload = jsonDecode(payloadText); + if (payload is! Map) { + throw const FormatException('Webhook payload must be a JSON object.'); + } + final repository = payload['repository']; + final fullName = repository is Map + ? repository['full_name']?.toString() + : null; + final project = fullName == null ? null : repoToProject[fullName]; + final issueNode = payload['issue']; + if (project != null && + issueNode is Map && + (issueNode['pull_request'] == null)) { + final issue = GitHubIssueSummary.fromRepositoryJson( + project.repoSlug, + issueNode, + ); + if (issue.state == 'open') { + _queuedIssues['${project.key}#${issue.number}'] = _QueuedGitHubIssue( + project: project, + issue: issue, + onBatch: onBatch, + ); + _scheduleFlush(); + } + } + + request.response.statusCode = HttpStatus.accepted; + await request.response.close(); + } catch (error) { + _logError('github/gosmee payload error=$error'); + request.response + ..statusCode = HttpStatus.badRequest + ..write('Invalid webhook payload.'); + await request.response.close(); + } + } + + Future _createGosmeeChannelUrl(String localUrl) async { + final result = await Process.run(gosmeeCommand, [ + '--output', + 'json', + 'client', + '--new-url', + localUrl, + ]); + if (result.exitCode != 0) { + throw ProcessException( + gosmeeCommand, + ['--output', 'json', 'client', '--new-url', localUrl], + '${result.stdout}\n${result.stderr}', + result.exitCode, + ); + } + + final stdoutText = result.stdout.toString().trim(); + if (stdoutText.isEmpty) { + throw const FormatException('gosmee did not print a public URL.'); + } + + try { + final decoded = jsonDecode(stdoutText); + if (decoded is Map) { + final url = decoded['url']?.toString().trim(); + if (url == null || url.isEmpty) { + throw const FormatException( + 'gosmee JSON output did not contain a valid public URL.', + ); + } + return url; + } + if (decoded is String) { + final url = decoded.trim(); + if (url.isEmpty) { + throw const FormatException('gosmee did not print a public URL.'); + } + return url; + } + } on FormatException { + if (Uri.tryParse(stdoutText)?.hasScheme ?? false) { + return stdoutText; + } + } + + throw const FormatException('gosmee did not print a valid public URL.'); + } + + void _scheduleFlush() { + _flushTimer ??= Timer(_webhookCoalescingWindow, () { + _flushTimer = null; + unawaited(_flushQueuedIssues()); + }); + } + + Future _flushQueuedIssues() async { + if (_queuedIssues.isEmpty) { + return; + } + + final queuedIssues = _queuedIssues.values.toList(growable: false); + _queuedIssues.clear(); + final groupedIssues = {}; + for (final queuedIssue in queuedIssues) { + groupedIssues.update( + queuedIssue.project.key, + (existing) => existing.add(queuedIssue.issue), + ifAbsent: () => _QueuedGitHubBatch( + project: queuedIssue.project, + onBatch: queuedIssue.onBatch, + issues: [queuedIssue.issue], + ), + ); + } + + late final Future dispatchFuture; + dispatchFuture = () async { + for (final batch in groupedIssues.values) { + batch.issues.sort( + (left, right) => left.updatedAt.compareTo(right.updatedAt), + ); + _log( + 'github/gosmee project=${batch.project.key} ' + 'fetched_issues=${batch.issues.length}', + ); + await batch.onBatch( + IssueEventBatch( + project: batch.project, + issues: batch.issues, + latestSeenUpdatedAt: batch.issues.last.updatedAt, + sourceKind: 'github/gosmee', + ), + ); + } + }(); + _activeDispatches.add(dispatchFuture); + try { + await dispatchFuture; + } finally { + _activeDispatches.remove(dispatchFuture); + } + } + + void _pipeProcessLogs({ + required ProjectConfig project, + required Stream> stream, + required String level, + }) { + utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) { + final message = 'github/gosmee project=${project.key} $level=$line'; + if (level == 'stderr') { + _logger.severe(message); + } else { + _logger.info(message); + } + }); + } + + void _log(String message) => _logger.info(message); + + void _logError(String message) => _logger.severe(message); +} + +class _QueuedGitHubIssue { + _QueuedGitHubIssue({ + required this.project, + required this.issue, + required this.onBatch, + }); + + final ProjectConfig project; + final GitHubIssueSummary issue; + final IssueEventBatchHandler onBatch; +} + +class _QueuedGitHubBatch { + _QueuedGitHubBatch({ + required this.project, + required this.onBatch, + required List issues, + }) : issues = List.from(issues); + + final ProjectConfig project; + final IssueEventBatchHandler onBatch; + final List issues; + + _QueuedGitHubBatch add(GitHubIssueSummary issue) { + issues.removeWhere((existing) => existing.number == issue.number); + issues.add(issue); + return this; + } +} diff --git a/lib/src/issue_event_source_github_webhook_forward.dart b/lib/src/issue_event_source/github_webhook_forward.dart similarity index 98% rename from lib/src/issue_event_source_github_webhook_forward.dart rename to lib/src/issue_event_source/github_webhook_forward.dart index bfe39d1..a1347c3 100644 --- a/lib/src/issue_event_source_github_webhook_forward.dart +++ b/lib/src/issue_event_source/github_webhook_forward.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:logging/logging.dart'; -import 'config.dart'; -import 'issue_event_source.dart'; -import 'issue_tracker_client.dart'; +import '../config.dart'; +import '../issue_tracker_client.dart'; +import 'base.dart'; class GitHubWebhookForwardIssueEventSource implements IssueEventSource { GitHubWebhookForwardIssueEventSource({ diff --git a/lib/src/issue_tracker_client.dart b/lib/src/issue_tracker_client.dart index 06b6ddd..3eb6038 100644 --- a/lib/src/issue_tracker_client.dart +++ b/lib/src/issue_tracker_client.dart @@ -147,6 +147,48 @@ class IssueTrackerClient { return _readInt(json, 'id'); } + Future createGitHubIssueWebhook({ + required RepositorySlug repo, + required String url, + }) async { + final json = await _runGhJson([ + 'api', + '-X', + 'POST', + _buildApiPath(repo, ['hooks']), + '-f', + 'name=web', + '-f', + 'active=true', + '-f', + 'events[]=issues', + '-f', + 'events[]=issue_comment', + '-f', + 'config[url]=$url', + '-f', + 'config[content_type]=json', + ]); + if (json is! Map) { + throw const FormatException( + 'gh api webhook create returned an unexpected response.', + ); + } + return _readInt(json, 'id'); + } + + Future deleteGitHubWebhook({ + required RepositorySlug repo, + required int webhookId, + }) async { + await _runGhJson([ + 'api', + '-X', + 'DELETE', + _buildApiPath(repo, ['hooks', '$webhookId']), + ]); + } + Future deleteGiteaWebhook({ required RepositorySlug repo, required int webhookId, diff --git a/test/app_config_test.dart b/test/app_config_test.dart index eea6cba..aecb5c3 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -356,6 +356,78 @@ projects: ); }); + /// ```gherkin + /// Scenario: Load gh/gosmee as the issue event source + /// Given a GitHub project config that opts into gh/gosmee + /// When loading the app config from disk + /// Then the project keeps gh/gosmee as its event source + /// And the GitHub provider remains valid for that project + /// ``` + test('Load gh/gosmee as the issue event source', () async { + // Given a GitHub project config that opts into gh/gosmee. + final tempDir = await Directory.systemTemp.createTemp('cws-gh-gosmee-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + provider: github + repo: owner/sample + path: ${tempDir.path} + issueAssistant: + eventSource: gh/gosmee +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the project keeps gh/gosmee as its event source. + expect( + config.projects['sample']!.issueAssistant.eventSource, + IssueAssistantEventSourceKind.ghGosmee, + ); + + // And the GitHub provider remains valid for that project. + expect(config.projects['sample']!.provider, IssueTrackerProvider.github); + }); + + /// ```gherkin + /// Scenario: Reject gh/gosmee for non-GitHub providers + /// Given a Gitea project config that opts into gh/gosmee + /// When loading the app config from disk + /// Then loading fails with a clear provider compatibility error + /// ``` + test('Reject gh/gosmee for non-GitHub providers', () async { + // Given a Gitea project config that opts into gh/gosmee. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-gh-gosmee-gitea-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + provider: gitea + repo: owner/sample + path: ${tempDir.path} + issueAssistant: + eventSource: gh/gosmee +'''); + + // When loading the app config from disk. + final load = AppConfig.load(configFile.path); + + // Then loading fails with a clear provider compatibility error. + await expectLater( + load, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('gh/gosmee requires provider: github'), + ), + ), + ); + }); + /// ```gherkin /// Scenario: Load gh/webhook-forward as the issue event source /// Given a GitHub project config that opts into gh/webhook-forward diff --git a/test/issue_assistant_app_run/github_gosmee_test.dart b/test/issue_assistant_app_run/github_gosmee_test.dart new file mode 100644 index 0000000..ae93e2b --- /dev/null +++ b/test/issue_assistant_app_run/github_gosmee_test.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../test_support.dart'; + +void registerIssueAssistantAppRunGitHubGosmeeTests() { + /// ```gherkin + /// Feature: GitHub gosmee issue events + /// + /// As a maintainer running the long-lived assistant for GitHub + /// I want gh/gosmee events to trigger issue evaluation without relying only on polling + /// So that multiple GitHub repositories can use channel-based issue triggers + /// ``` + group('IssueAssistantApp.run github gosmee', () { + /// ```gherkin + /// Scenario: Reply to a mention received through gh/gosmee + /// Given a temporary project checkout that can be used as the local repo path + /// And a gosmee script that forwards one GitHub issue_comment webhook event + /// And a gh script that can manage webhooks and post issue comments + /// And a responder that emits a planning reply + /// And an orchestrator-style config that uses gh/gosmee + /// When the long-running app starts and receives the forwarded webhook + /// Then it reads issue comments and posts exactly one reply for that issue + /// And the GitHub webhook lifecycle commands are invoked for the project + /// ``` + test('Reply to a mention received through gh/gosmee', () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-gh-gosmee-run-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gosmee script that forwards one GitHub issue_comment webhook event. + final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); + final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl')); + final issue = { + 'number': 12, + 'title': 'Need channel-based trigger handling', + 'body': 'Please react to gh/gosmee.', + 'state': 'open', + 'html_url': 'https://example.test/issues/12', + 'updated_at': '2026-04-08T04:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': const >[], + }; + final comments = [ + { + 'id': 51, + 'body': '@helper can you handle gh/gosmee comments?', + 'html_url': 'https://example.test/issues/12#issuecomment-51', + 'created_at': '2026-04-08T04:00:00Z', + 'updated_at': '2026-04-08T04:00:00Z', + 'user': {'login': 'reporter'}, + }, + ]; + await writeGosmeeForwardScript( + gosmeeScript: gosmeeScript, + publicUrl: 'https://gosmee.example.test/github-sample', + payload: { + 'action': 'created', + 'issue': issue, + 'comment': comments.last, + 'repository': {'full_name': 'owner/sample'}, + }, + gosmeeLog: gosmeeLog, + ); + + // And a gh script that can manage webhooks and post issue comments. + final ghScript = File(p.join(sandbox.path, 'gh')); + final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); + final postedBody = File(p.join(sandbox.path, 'posted-body.md')); + await writeGitHubGosmeeGhScript( + ghScript: ghScript, + repo: 'owner/sample', + webhookId: 81, + issueNumber: 12, + comments: comments, + ghLog: ghLog, + postedBodyFile: postedBody, + viewerLogin: 'octocat', + ); + + // And a responder that emits a planning reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'Plan:\n- consume gh/gosmee issue_comment events', + 'summary': 'posted GitHub channel-based planning advice', + }); + + // And an orchestrator-style config that uses gh/gosmee. + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 5m + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + provider: github + repo: owner/sample + path: ${repoDir.path} + issueAssistant: + eventSource: gh/gosmee +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + gosmeeCommand: gosmeeScript.path, + ); + + // When the long-running app starts and receives the forwarded webhook. + final runFuture = app.run(); + for (var attempt = 0; attempt < 50; attempt += 1) { + if (await postedBody.exists()) { + break; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + await app.close(); + await runFuture; + + // Then it reads issue comments and posts exactly one reply for that issue. + expect(await postedBody.exists(), isTrue); + final postedComment = await postedBody.readAsString(); + expect( + postedComment, + contains('Plan:\n- consume gh/gosmee issue_comment events'), + ); + expect(postedComment, contains('@octocat')); + + // And the GitHub webhook lifecycle commands are invoked for the project. + final ghLogLines = await ghLog.readAsLines(); + expect( + ghLogLines.any( + (line) => line.contains('api -X POST repos/owner/sample/hooks'), + ), + isTrue, + ); + expect( + ghLogLines.any( + (line) => line.contains('api -X DELETE repos/owner/sample/hooks/81'), + ), + isTrue, + ); + final gosmeeLogLines = await gosmeeLog.readAsLines(); + expect( + gosmeeLogLines.any( + (line) => + line.contains('client https://gosmee.example.test/github-sample'), + ), + isTrue, + ); + }); + }); +} + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + registerIssueAssistantAppRunGitHubGosmeeTests(); +} diff --git a/test/issue_assistant_app_run_gosmee_test.dart b/test/issue_assistant_app_run/gosmee_test.dart similarity index 99% rename from test/issue_assistant_app_run_gosmee_test.dart rename to test/issue_assistant_app_run/gosmee_test.dart index c88f249..4498bfb 100644 --- a/test/issue_assistant_app_run_gosmee_test.dart +++ b/test/issue_assistant_app_run/gosmee_test.dart @@ -6,7 +6,7 @@ import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -import 'test_support.dart'; +import '../test_support.dart'; void registerIssueAssistantAppRunGosmeeTests() { /// ```gherkin diff --git a/test/issue_assistant_app_run_webhook_forward_test.dart b/test/issue_assistant_app_run/webhook_forward_test.dart similarity index 99% rename from test/issue_assistant_app_run_webhook_forward_test.dart rename to test/issue_assistant_app_run/webhook_forward_test.dart index 9ac98d8..b010b93 100644 --- a/test/issue_assistant_app_run_webhook_forward_test.dart +++ b/test/issue_assistant_app_run/webhook_forward_test.dart @@ -7,7 +7,7 @@ import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -import 'test_support.dart'; +import '../test_support.dart'; void registerIssueAssistantAppRunWebhookForwardTests() { /// ```gherkin diff --git a/test/test_support.dart b/test/test_support.dart index fbbba2d..5956e25 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -570,6 +570,99 @@ Future writeWebhookForwardGhScript({ await Process.run('chmod', ['+x', ghScript.path]); } +Future writeGitHubGosmeeGhScript({ + required File ghScript, + required String repo, + required int webhookId, + required int issueNumber, + required List> comments, + File? ghLog, + File? postedBodyFile, + String? viewerLogin, +}) async { + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${ghLog.path}"', + ); + } + + buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); + buffer + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(comments)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln('if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks" ]]; then') + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'id': webhookId})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/$issueNumber/comments" ]]; then', + ) + ..writeln(r''' for arg in "$@"; do''') + ..writeln(r''' :'''); + if (postedBodyFile != null) { + buffer + ..writeln(r''' if [[ "$arg" == body=* ]]; then''') + ..writeln( + r''' printf '%s' "${arg#body=}" > ''' + '"${postedBodyFile.path}"', + ) + ..writeln(r''' fi'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln( + jsonEncode({ + 'id': 703, + 'html_url': 'https://example.test/comment/703', + 'body': 'posted', + }), + ) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks/$webhookId" ]]; then', + ) + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln(r'''echo "unexpected gh args: $*" >&2''') + ..writeln('exit 1'); + + await ghScript.writeAsString(buffer.toString()); + await Process.run('chmod', ['+x', ghScript.path]); +} + Future writeGosmeeForwardScript({ required File gosmeeScript, required String publicUrl,