forked from bkinnightskytw/code_work_spawner
feat: add gh gosmee event source for #19
Add a GitHub gosmee transport so long-running sessions can use temporary webhooks without the single-forward limitation of gh webhook forward.
This commit is contained in:
parent
2b6c1f9b11
commit
0d538b962e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ enum IssueAssistantEventSourceKind {
|
|||
ghPolling,
|
||||
@JsonValue('tea/polling')
|
||||
teaPolling,
|
||||
@JsonValue('gh/gosmee')
|
||||
ghGosmee,
|
||||
@JsonValue('gh/webhook-forward')
|
||||
ghWebhookForward,
|
||||
@JsonValue('tea/gosmee')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'config.dart';
|
|||
import 'database.dart';
|
||||
import 'issue_event_source.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';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_event_source.dart';
|
||||
import 'issue_tracker_client.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,48 @@ class IssueTrackerClient {
|
|||
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({
|
||||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// Scenario: Load gh/webhook-forward as the issue event source
|
||||
/// Given a GitHub project config that opts into gh/webhook-forward
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -570,6 +570,99 @@ Future<void> writeWebhookForwardGhScript({
|
|||
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({
|
||||
required File gosmeeScript,
|
||||
required String publicUrl,
|
||||
|
|
|
|||
Loading…
Reference in New Issue