Merge pull request #20 from existedinnettw/feat/19

feat: add gh/gosmee event source
This commit is contained in:
existedinnettw 2026-04-08 20:42:11 +08:00 committed by GitHub
commit 64901a348a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 818 additions and 19 deletions

View File

@ -8,7 +8,11 @@ event source.
GitHub projects default to `gh/polling`, and Gitea projects default to GitHub projects default to `gh/polling`, and Gitea projects default to
`tea/polling`. GitHub projects can switch their long-running runtime 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` starts
`gh webhook forward` for `issues` and `issue_comment` events while keeping `gh webhook forward` for `issues` and `issue_comment` events while keeping
periodic polling reconciliation for startup sync and missed-event recovery. periodic polling reconciliation for startup sync and missed-event recovery.

View File

@ -7,8 +7,8 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# Use gh/webhook-forward for long-running runs while keeping --once on polling. # Use gh/gosmee for long-running runs while keeping --once on polling.
# eventSource: gh/webhook-forward # eventSource: gh/gosmee
pollInterval: 30s pollInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]

View File

@ -1,8 +1,8 @@
export 'src/comment_templates.dart'; export 'src/comment_templates.dart';
export 'src/config.dart'; export 'src/config.dart';
export 'src/database.dart'; export 'src/database.dart';
export 'src/issue_event_source/base.dart';
export 'src/issue_tracker_client.dart'; export 'src/issue_tracker_client.dart';
export 'src/issue_event_source.dart';
export 'src/issue_assistant_app.dart'; export 'src/issue_assistant_app.dart';
export 'src/notifier.dart'; export 'src/notifier.dart';
export 'src/workspace_manager.dart'; export 'src/workspace_manager.dart';

View File

@ -202,6 +202,7 @@ class ProjectConfig {
} }
switch (issueAssistant.eventSource) { switch (issueAssistant.eventSource) {
case IssueAssistantEventSourceKind.ghPolling: case IssueAssistantEventSourceKind.ghPolling:
case IssueAssistantEventSourceKind.ghGosmee:
case IssueAssistantEventSourceKind.ghWebhookForward: case IssueAssistantEventSourceKind.ghWebhookForward:
if (provider != IssueTrackerProvider.github) { if (provider != IssueTrackerProvider.github) {
throw FormatException( throw FormatException(
@ -462,6 +463,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => 'gh/polling', IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
IssueAssistantEventSourceKind.teaPolling => 'tea/polling', IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee',
IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward', IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee', IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee',
}; };
@ -471,6 +473,7 @@ bool _isPollingEventSource(IssueAssistantEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => true, IssueAssistantEventSourceKind.ghPolling => true,
IssueAssistantEventSourceKind.teaPolling => true, IssueAssistantEventSourceKind.teaPolling => true,
IssueAssistantEventSourceKind.ghGosmee => false,
IssueAssistantEventSourceKind.ghWebhookForward => false, IssueAssistantEventSourceKind.ghWebhookForward => false,
IssueAssistantEventSourceKind.teaGosmee => false, IssueAssistantEventSourceKind.teaGosmee => false,
}; };
@ -724,7 +727,7 @@ ${_YamlEmitter().write({'projects': projectsSection})}
# issueAssistant: # issueAssistant:
# enabled: ${assistant?.enabled ?? true} # enabled: ${assistant?.enabled ?? true}
# eventSource: gh/webhook-forward # eventSource: gh/gosmee
# mentionTriggers: ["@helper"] # mentionTriggers: ["@helper"]
# sessionPrefix: sample # sessionPrefix: sample
''' '''

View File

@ -186,6 +186,8 @@ enum IssueAssistantEventSourceKind {
ghPolling, ghPolling,
@JsonValue('tea/polling') @JsonValue('tea/polling')
teaPolling, teaPolling,
@JsonValue('gh/gosmee')
ghGosmee,
@JsonValue('gh/webhook-forward') @JsonValue('gh/webhook-forward')
ghWebhookForward, ghWebhookForward,
@JsonValue('tea/gosmee') @JsonValue('tea/gosmee')

View File

@ -7,9 +7,10 @@ import 'package:logging/logging.dart';
import 'cli_responder.dart'; import 'cli_responder.dart';
import 'config.dart'; import 'config.dart';
import 'database.dart'; import 'database.dart';
import 'issue_event_source.dart'; import 'issue_event_source/base.dart';
import 'issue_event_source_gitea_gosmee.dart'; import 'issue_event_source/gitea_gosmee.dart';
import 'issue_event_source_github_webhook_forward.dart'; import 'issue_event_source/github_gosmee.dart';
import 'issue_event_source/github_webhook_forward.dart';
import 'issue_tracker_client.dart'; import 'issue_tracker_client.dart';
import 'notifier.dart'; import 'notifier.dart';
import 'workspace_manager.dart'; import 'workspace_manager.dart';
@ -62,6 +63,11 @@ class IssueAssistantApp {
sources: { sources: {
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand,
logger: _logger,
),
IssueAssistantEventSourceKind.ghWebhookForward: IssueAssistantEventSourceKind.ghWebhookForward:
GitHubWebhookForwardIssueEventSource( GitHubWebhookForwardIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,

View File

@ -4,9 +4,9 @@ import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'config.dart'; import '../config.dart';
import 'database.dart'; import '../database.dart';
import 'issue_tracker_client.dart'; import '../issue_tracker_client.dart';
typedef IssueEventBatchHandler = Future<void> Function(IssueEventBatch batch); typedef IssueEventBatchHandler = Future<void> Function(IssueEventBatch batch);
@ -103,6 +103,12 @@ class RoutedIssueEventSource implements IssueEventSource {
yield IssueAssistantEventSourceKind.ghPolling; yield IssueAssistantEventSourceKind.ghPolling;
case IssueAssistantEventSourceKind.teaPolling: case IssueAssistantEventSourceKind.teaPolling:
yield 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: case IssueAssistantEventSourceKind.ghWebhookForward:
// Keep reconciliation polling active for startup sync and missed // Keep reconciliation polling active for startup sync and missed
// webhook recovery while the forwarded channel handles low-latency // webhook recovery while the forwarded channel handles low-latency

View File

@ -5,9 +5,9 @@ import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'config.dart'; import '../config.dart';
import 'issue_event_source.dart'; import '../issue_tracker_client.dart';
import 'issue_tracker_client.dart'; import 'base.dart';
class GiteaGosmeeIssueEventSource implements IssueEventSource { class GiteaGosmeeIssueEventSource implements IssueEventSource {
GiteaGosmeeIssueEventSource({ GiteaGosmeeIssueEventSource({

View File

@ -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<String, _QueuedGitHubIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {};
final Map<String, int> _webhookIdsByProject = {};
final Map<String, RepositorySlug> _repoSlugsByProject = {};
HttpServer? _server;
Timer? _flushTimer;
Completer<void>? _runCompleter;
bool _isClosing = false;
bool _isClosed = false;
@override
Future<void> run({
required List<ProjectConfig> 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<void>();
_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<void> runOnce({
required List<ProjectConfig> projects,
required IssueEventBatchHandler onBatch,
}) async {
throw UnsupportedError(
'github/gosmee does not support runOnce; use polling instead.',
);
}
@override
Future<void> 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<String, int>.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<void> _handleWebhookRequest({
required HttpRequest request,
required Map<String, ProjectConfig> 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<String, dynamic>) {
throw const FormatException('Webhook payload must be a JSON object.');
}
final repository = payload['repository'];
final fullName = repository is Map<String, dynamic>
? repository['full_name']?.toString()
: null;
final project = fullName == null ? null : repoToProject[fullName];
final issueNode = payload['issue'];
if (project != null &&
issueNode is Map<String, dynamic> &&
(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<String> _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<String, dynamic>) {
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<void> _flushQueuedIssues() async {
if (_queuedIssues.isEmpty) {
return;
}
final queuedIssues = _queuedIssues.values.toList(growable: false);
_queuedIssues.clear();
final groupedIssues = <String, _QueuedGitHubBatch>{};
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<void> 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<List<int>> 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<GitHubIssueSummary> issues,
}) : issues = List<GitHubIssueSummary>.from(issues);
final ProjectConfig project;
final IssueEventBatchHandler onBatch;
final List<GitHubIssueSummary> issues;
_QueuedGitHubBatch add(GitHubIssueSummary issue) {
issues.removeWhere((existing) => existing.number == issue.number);
issues.add(issue);
return this;
}
}

View File

@ -4,9 +4,9 @@ import 'dart:io';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'config.dart'; import '../config.dart';
import 'issue_event_source.dart'; import '../issue_tracker_client.dart';
import 'issue_tracker_client.dart'; import 'base.dart';
class GitHubWebhookForwardIssueEventSource implements IssueEventSource { class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
GitHubWebhookForwardIssueEventSource({ GitHubWebhookForwardIssueEventSource({

View File

@ -147,6 +147,48 @@ class IssueTrackerClient {
return _readInt(json, 'id'); return _readInt(json, 'id');
} }
Future<int> 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<String, dynamic>) {
throw const FormatException(
'gh api webhook create returned an unexpected response.',
);
}
return _readInt(json, 'id');
}
Future<void> deleteGitHubWebhook({
required RepositorySlug repo,
required int webhookId,
}) async {
await _runGhJson([
'api',
'-X',
'DELETE',
_buildApiPath(repo, ['hooks', '$webhookId']),
]);
}
Future<void> deleteGiteaWebhook({ Future<void> deleteGiteaWebhook({
required RepositorySlug repo, required RepositorySlug repo,
required int webhookId, required int webhookId,

View File

@ -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<FormatException>().having(
(error) => error.message,
'message',
contains('gh/gosmee requires provider: github'),
),
),
);
});
/// ```gherkin /// ```gherkin
/// Scenario: Load gh/webhook-forward as the issue event source /// Scenario: Load gh/webhook-forward as the issue event source
/// Given a GitHub project config that opts into gh/webhook-forward /// Given a GitHub project config that opts into gh/webhook-forward

View File

@ -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 <Map<String, Object?>>[],
};
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<void>.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();
}

View File

@ -6,7 +6,7 @@ import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'test_support.dart'; import '../test_support.dart';
void registerIssueAssistantAppRunGosmeeTests() { void registerIssueAssistantAppRunGosmeeTests() {
/// ```gherkin /// ```gherkin

View File

@ -7,7 +7,7 @@ import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'test_support.dart'; import '../test_support.dart';
void registerIssueAssistantAppRunWebhookForwardTests() { void registerIssueAssistantAppRunWebhookForwardTests() {
/// ```gherkin /// ```gherkin

View File

@ -570,6 +570,99 @@ Future<void> writeWebhookForwardGhScript({
await Process.run('chmod', ['+x', ghScript.path]); await Process.run('chmod', ['+x', ghScript.path]);
} }
Future<void> writeGitHubGosmeeGhScript({
required File ghScript,
required String repo,
required int webhookId,
required int issueNumber,
required List<Map<String, Object?>> 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<void> writeGosmeeForwardScript({ Future<void> writeGosmeeForwardScript({
required File gosmeeScript, required File gosmeeScript,
required String publicUrl, required String publicUrl,