diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index 0a54efe..c5dff75 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -26,4 +26,4 @@ jobs: run: dart run tool/validate_gherkin_test_format.dart - name: Run tests - run: dart test + run: dart test -j 1 diff --git a/README.md b/README.md index a1e723d..a6de167 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ dart run bin/code_work_spawner.dart \ ```bash dart run tool/validate_gherkin_test_format.dart dart analyze -dart test +dart test -j 1 ``` ## ref diff --git a/build.yaml b/build.yaml index ac563c3..2262ae4 100644 --- a/build.yaml +++ b/build.yaml @@ -3,7 +3,7 @@ targets: builders: drift_dev: generate_for: - - lib/src/database.dart + - lib/src/core/database.dart json_serializable: generate_for: - - lib/src/config_schema.dart + - lib/src/config/config_schema.dart diff --git a/examples/README.md b/examples/README.md index ab335ea..73bf2f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,18 +16,20 @@ matching provider for existing GitHub, GitLab, and Gitea configs. GitHub projects default to `gh/polling`, GitLab projects default to `glab/polling`, and Gitea projects default to `tea/polling`. OpenProject -projects default to `op/polling` and currently only support polling. GitLab and -OpenProject currently support polling only. Because OpenProject issue tracking +projects default to `op/polling` and currently only support polling. Because +OpenProject issue tracking is not tied to a repository host, OpenProject projects must set `scm.provider` explicitly. GitHub projects can switch their long-running runtime to -`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to -`tea/gosmee`. +`gh/gosmee` or `gh/webhook-forward`, GitLab projects can switch to +`glab/gosmee`, and Gitea projects can switch to `tea/gosmee`. For OpenProject, `repo` may be either a numeric project id or an exact project name. `gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and -`issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that -targets a generated gosmee URL. Channel-based event sources always run one +`issue_comment` events. `glab/gosmee` creates a temporary GitLab webhook that +targets a generated gosmee URL for issue and note events. `tea/gosmee` creates +a temporary Gitea webhook that targets a generated gosmee URL. Channel-based +event sources always run one startup reconciliation poll. Set `reconciliation: continuous` plus `reconcileInterval` to keep periodic reconciliation active for missed-event recovery, or use `reconciliation: startup-only` to skip background polling diff --git a/examples/simple-gitlab.yaml b/examples/simple-gitlab.yaml index 2d6fa73..3c9b391 100644 --- a/examples/simple-gitlab.yaml +++ b/examples/simple-gitlab.yaml @@ -19,10 +19,14 @@ projects: sample-gitlab: issueTracker: provider: gitlab - # GitLab currently supports polling. + # Use glab/gosmee for long-running runs. eventSource: type: glab/polling pollInterval: 30s + # eventSource: + # type: glab/gosmee + # reconciliation: continuous + # reconcileInterval: 30s scm: provider: gitlab repo: group/subgroup/repo diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 7ffb4bc..24e5b00 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -685,6 +685,7 @@ String _eventSourceConfigValue(IssueTrackerEventSourceKind value) { IssueTrackerEventSourceKind.teaPolling => 'tea/polling', IssueTrackerEventSourceKind.opPolling => 'op/polling', IssueTrackerEventSourceKind.ghGosmee => 'gh/gosmee', + IssueTrackerEventSourceKind.glabGosmee => 'glab/gosmee', IssueTrackerEventSourceKind.ghWebhookForward => 'gh/webhook-forward', IssueTrackerEventSourceKind.teaGosmee => 'tea/gosmee', }; @@ -697,6 +698,7 @@ bool _isPollingEventSource(IssueTrackerEventSourceKind value) { IssueTrackerEventSourceKind.teaPolling => true, IssueTrackerEventSourceKind.opPolling => true, IssueTrackerEventSourceKind.ghGosmee => false, + IssueTrackerEventSourceKind.glabGosmee => false, IssueTrackerEventSourceKind.ghWebhookForward => false, IssueTrackerEventSourceKind.teaGosmee => false, }; @@ -959,6 +961,7 @@ IssueTrackerProvider _issueTrackerProviderForEventSource( IssueTrackerEventSourceKind.teaPolling => IssueTrackerProvider.gitea, IssueTrackerEventSourceKind.opPolling => IssueTrackerProvider.openproject, IssueTrackerEventSourceKind.ghGosmee => IssueTrackerProvider.github, + IssueTrackerEventSourceKind.glabGosmee => IssueTrackerProvider.gitlab, IssueTrackerEventSourceKind.ghWebhookForward => IssueTrackerProvider.github, IssueTrackerEventSourceKind.teaGosmee => IssueTrackerProvider.gitea, }; diff --git a/lib/src/config/config_schema.dart b/lib/src/config/config_schema.dart index 7b69ff8..6bddd47 100644 --- a/lib/src/config/config_schema.dart +++ b/lib/src/config/config_schema.dart @@ -259,6 +259,8 @@ enum IssueTrackerEventSourceKind { opPolling, @JsonValue('gh/gosmee') ghGosmee, + @JsonValue('glab/gosmee') + glabGosmee, @JsonValue('gh/webhook-forward') ghWebhookForward, @JsonValue('tea/gosmee') diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index 8756203..c6f8ea3 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -9,6 +9,7 @@ import '../config/config.dart'; import 'database.dart'; import '../issue_tracker/issue_event_source/base.dart'; import '../issue_tracker/issue_event_source/gitea_gosmee.dart'; +import '../issue_tracker/issue_event_source/gitlab_gosmee.dart'; import '../issue_tracker/issue_event_source/github_gosmee.dart'; import '../issue_tracker/issue_event_source/github_webhook_forward.dart'; import '../issue_tracker/issue_tracker_client.dart'; @@ -72,6 +73,11 @@ class IssueAssistantApp { gosmeeCommand: gosmeeCommand, logger: _logger, ), + IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource( + issueTrackerClient: issueTrackerClient, + gosmeeCommand: gosmeeCommand, + logger: _logger, + ), IssueTrackerEventSourceKind.ghWebhookForward: GitHubWebhookForwardIssueEventSource( issueTrackerClient: issueTrackerClient, diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 9d47888..a25582f 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -272,6 +272,32 @@ class IssueTrackerClient { return readIntField(json, 'id'); } + Future createGitLabIssueWebhook({ + required String trackerProject, + required String url, + }) async { + final json = await _runGlabJson([ + 'api', + '-X', + 'POST', + _buildGitLabApiPath(trackerProject, ['hooks']), + '-f', + 'url=$url', + '-f', + 'issues_events=true', + '-f', + 'note_events=true', + '-f', + 'push_events=false', + ]); + if (json is! Map) { + throw const FormatException( + 'glab api webhook create returned an unexpected response.', + ); + } + return readIntField(json, 'id'); + } + Future deleteGitHubWebhook({ required RepositorySlug repo, required int webhookId, @@ -298,6 +324,18 @@ class IssueTrackerClient { ]); } + Future deleteGitLabWebhook({ + required String trackerProject, + required int webhookId, + }) async { + await _runGlabJson([ + 'api', + '-X', + 'DELETE', + _buildGitLabApiPath(trackerProject, ['hooks', '$webhookId']), + ]); + } + Future> _fetchUpdatedGitHubIssues({ required Iterable trackerProjects, required DateTime? since, diff --git a/lib/src/issue_tracker/issue_event_source/base.dart b/lib/src/issue_tracker/issue_event_source/base.dart index e9444bd..e91ca8e 100644 --- a/lib/src/issue_tracker/issue_event_source/base.dart +++ b/lib/src/issue_tracker/issue_event_source/base.dart @@ -119,6 +119,11 @@ class RoutedIssueEventSource implements IssueEventSource { yield IssueTrackerEventSourceKind.ghPolling; } yield IssueTrackerEventSourceKind.ghGosmee; + case IssueTrackerEventSourceKind.glabGosmee: + if (_shouldRunContinuousReconciliation(project)) { + yield IssueTrackerEventSourceKind.glabPolling; + } + yield IssueTrackerEventSourceKind.glabGosmee; case IssueTrackerEventSourceKind.ghWebhookForward: if (_shouldRunContinuousReconciliation(project)) { yield IssueTrackerEventSourceKind.ghPolling; diff --git a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart new file mode 100644 index 0000000..7561b60 --- /dev/null +++ b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart @@ -0,0 +1,482 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../config/app_logger.dart'; +import '../../config/config.dart'; +import '../../core/process_launcher.dart'; +import '../client.dart'; +import '../models.dart'; +import 'base.dart'; + +class GitLabGosmeeIssueEventSource implements IssueEventSource { + GitLabGosmeeIssueEventSource({ + required this.issueTrackerClient, + required this.gosmeeCommand, + required AppLogger logger, + }) : _logger = logger; + + static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); + + final IssueTrackerClient issueTrackerClient; + final String gosmeeCommand; + final AppLogger _logger; + final Map _queuedIssues = {}; + final Set> _activeDispatches = >{}; + final Map _gosmeeProcesses = {}; + final Map _webhookIdsByProject = {}; + final Map _trackerProjectsByProject = {}; + + 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 gitLabProjects = projects + .where((project) => project.provider == IssueTrackerProvider.gitlab) + .toList(growable: false); + if (gitLabProjects.isEmpty) { + return; + } + + final completer = Completer(); + _runCompleter = completer; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _server = server; + final repoToProject = { + for (final project in gitLabProjects) project.repo: project, + }; + for (final project in gitLabProjects) { + _trackerProjectsByProject[project.key] = project.repo; + } + server.listen( + (request) => unawaited( + _handleWebhookRequest( + request: request, + repoToProject: repoToProject, + onBatch: onBatch, + ), + ), + onError: (Object error, StackTrace stackTrace) { + _logError('gitlab/gosmee server error=$error'); + if (!_isClosing && !completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + ); + + final localUrl = + 'http://${server.address.address}:${server.port}/gitlab/webhooks'; + _log( + 'gitlab/gosmee listening local_url=$localUrl ' + 'projects=${gitLabProjects.map((project) => project.key).join(",")}', + ); + for (final project in gitLabProjects) { + _log( + 'gitlab/gosmee project=${project.key} create_channel ' + 'command="$gosmeeCommand --output json client --new-url $localUrl"', + ); + final publicUrl = await _createGosmeeChannelUrl(localUrl); + _log( + 'gitlab/gosmee project=${project.key} channel_ready ' + 'public_url=$publicUrl local_url=$localUrl', + ); + final webhookId = await issueTrackerClient.createGitLabIssueWebhook( + trackerProject: project.repo, + url: publicUrl, + ); + _webhookIdsByProject[project.key] = webhookId; + _log( + 'gitlab/gosmee project=${project.key} webhook_registered ' + 'webhook_id=$webhookId repo=${project.repo}', + ); + _log( + 'gitlab/gosmee project=${project.key} start_client ' + 'command="$gosmeeCommand client $publicUrl $localUrl"', + ); + final process = await startExternalCommand(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( + 'gitlab/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( + 'gitlab/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 { + _log( + 'gitlab/gosmee project=${entry.key} webhook_delete ' + 'webhook_id=${entry.value}', + ); + await issueTrackerClient.deleteGitLabWebhook( + trackerProject: _trackerProjectsByProject[entry.key]!, + webhookId: entry.value, + ); + } catch (error) { + _logError( + 'gitlab/gosmee delete webhook project=${entry.key} ' + 'webhook_id=${entry.value} error=$error', + ); + } + } + _trackerProjectsByProject.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') { + _log( + 'gitlab/gosmee webhook ignored method=${request.method} ' + 'path=${request.uri.path}', + ); + 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 projectNode = payload['project']; + final fullName = projectNode is Map + ? projectNode['path_with_namespace']?.toString() + : null; + final project = fullName == null ? null : repoToProject[fullName]; + final issue = _parseIssueFromPayload(payload, trackerProject: fullName); + final objectKind = payload['object_kind']?.toString() ?? 'unknown'; + final action = _webhookAction(payload); + if (project != null && issue != null) { + _log( + 'gitlab/gosmee webhook accepted project=${project.key} ' + 'repo=$fullName object_kind=$objectKind action=$action ' + 'issue=${issue.number} state=${issue.state}', + ); + if (issue.state == 'open' || issue.state == 'opened') { + _queuedIssues['${project.key}#${issue.number}'] = _QueuedGitLabIssue( + project: project, + issue: issue, + onBatch: onBatch, + ); + _scheduleFlush(); + } + } else { + _log( + 'gitlab/gosmee webhook ignored repo=${fullName ?? "(missing)"} ' + 'object_kind=$objectKind action=$action matched_project=${project != null} ' + 'has_issue=${issue != null}', + ); + } + + request.response.statusCode = HttpStatus.accepted; + await request.response.close(); + } catch (error) { + _logError('gitlab/gosmee payload error=$error'); + request.response + ..statusCode = HttpStatus.badRequest + ..write('Invalid webhook payload.'); + await request.response.close(); + } + } + + GitHubIssueSummary? _parseIssueFromPayload( + Map payload, { + required String? trackerProject, + }) { + if (trackerProject == null || trackerProject.isEmpty) { + return null; + } + + final issueNode = payload['issue']; + if (issueNode is Map) { + return GitHubIssueSummary.fromGitLabJson(trackerProject, issueNode); + } + + final objectAttributes = payload['object_attributes']; + if (objectAttributes is! Map) { + return null; + } + final issueJson = { + ...objectAttributes, + if (!objectAttributes.containsKey('description') && + payload['description'] != null) + 'description': payload['description'], + if (!objectAttributes.containsKey('author') && payload['user'] != null) + 'author': payload['user'], + if (!objectAttributes.containsKey('web_url') && + objectAttributes['url'] != null) + 'web_url': objectAttributes['url'], + }; + if (!issueJson.containsKey('iid') || !issueJson.containsKey('updated_at')) { + return null; + } + return GitHubIssueSummary.fromGitLabJson(trackerProject, issueJson); + } + + String _webhookAction(Map payload) { + final objectAttributes = payload['object_attributes']; + if (objectAttributes is Map) { + final action = objectAttributes['action']?.toString().trim(); + if (action != null && action.isNotEmpty) { + return action; + } + } + return payload['action']?.toString() ?? 'unknown'; + } + + Future _createGosmeeChannelUrl(String localUrl) async { + final result = await runExternalCommand(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.'); + } + _log( + 'gitlab/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText', + ); + + 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: () => _QueuedGitLabBatch( + 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( + 'gitlab/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: 'gitlab/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 = 'gitlab/gosmee project=${project.key} $level=$line'; + if (level == 'stderr') { + _logger.error(message); + } else { + _logger.info(message); + } + }); + } + + void _log(String message) => _logger.info(message); + + void _logError(String message) => _logger.error(message); +} + +class _QueuedGitLabIssue { + _QueuedGitLabIssue({ + required this.project, + required this.issue, + required this.onBatch, + }); + + final ProjectConfig project; + final GitHubIssueSummary issue; + final IssueEventBatchHandler onBatch; +} + +class _QueuedGitLabBatch { + _QueuedGitLabBatch({ + required this.project, + required this.onBatch, + required List issues, + }) : issues = List.from(issues); + + final ProjectConfig project; + final IssueEventBatchHandler onBatch; + final List issues; + + _QueuedGitLabBatch add(GitHubIssueSummary issue) { + if (issues.every((existing) => existing.number != issue.number)) { + issues.add(issue); + } + return this; + } +} diff --git a/test/config/app_config_test.dart b/test/config/app_config_test.dart index f09c9d6..22171b0 100644 --- a/test/config/app_config_test.dart +++ b/test/config/app_config_test.dart @@ -641,6 +641,40 @@ projects: expect(config.projects['sample']!.provider, IssueTrackerProvider.github); }); + /// ```gherkin + /// Scenario: Load glab/gosmee as the issue event source + /// Given a GitLab project config that opts into glab/gosmee + /// When loading the app config from disk + /// Then the project keeps glab/gosmee as its event source + /// And the GitLab provider remains valid for that project + /// ``` + test('Load glab/gosmee as the issue event source', () async { + // Given a GitLab project config that opts into glab/gosmee. + final tempDir = await Directory.systemTemp.createTemp('cws-glab-gosmee-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + eventSource: + type: glab/gosmee + repo: group/subgroup/sample + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the project keeps glab/gosmee as its event source. + expect( + config.projects['sample']!.issueAssistant.eventSource.type, + IssueTrackerEventSourceKind.glabGosmee, + ); + + // And the GitLab provider remains valid for that project. + expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab); + }); + /// ```gherkin /// Scenario: Load an explicit SCM provider separately from the issue tracker /// Given a config whose project uses a Gitea issue tracker and GitHub SCM @@ -761,6 +795,55 @@ projects: ); }); + /// ```gherkin + /// Scenario: Reject a legacy GitHub provider that conflicts with glab/gosmee + /// Given a project config whose legacy tracker provider is github + /// And the project opts into glab/gosmee + /// When loading the app config from disk + /// Then loading fails with a clear conflict error + /// ``` + test( + 'Reject a legacy GitHub provider that conflicts with glab/gosmee', + () async { + // Given a project config whose legacy tracker provider is github. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-glab-gosmee-github-', + ); + final configFile = File( + p.join(tempDir.path, 'agent-orchestrator.yaml'), + ); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + provider: github + eventSource: + type: glab/gosmee + repo: group/subgroup/sample + path: ${tempDir.path} +'''); + + // And the project opts into glab/gosmee. + + // When loading the app config from disk. + final load = AppConfig.load(configFile.path); + + // Then loading fails with a clear conflict error. + await expectLater( + load, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'glab/gosmee implies issue tracker provider gitlab, but the legacy configured provider is 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_event_source/gitlab_gosmee_test.dart b/test/issue_event_source/gitlab_gosmee_test.dart new file mode 100644 index 0000000..39c7eda --- /dev/null +++ b/test/issue_event_source/gitlab_gosmee_test.dart @@ -0,0 +1,193 @@ +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 registerIssueAssistantAppRunGitLabGosmeeTests() { + /// ```gherkin + /// Feature: GitLab gosmee issue events + /// + /// As a maintainer running the long-lived assistant for GitLab + /// I want glab/gosmee events to trigger issue evaluation without relying only on polling + /// So that GitLab issue and note activity can use a channel-based source + /// ``` + group('IssueAssistantApp.run gitlab gosmee', () { + /// ```gherkin + /// Scenario: Reply to a mention received through glab/gosmee + /// Given a temporary project checkout that can be used as the local repo path + /// And a gosmee script that forwards one GitLab note webhook event + /// And a glab script that can manage webhooks and post issue comments + /// And a responder that emits a planning reply + /// And an orchestrator-style config that uses glab/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 GitLab webhook lifecycle commands are invoked for the project + /// ``` + test('Reply to a mention received through glab/gosmee', () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-glab-gosmee-run-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gosmee script that forwards one GitLab note webhook event. + final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); + final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl')); + final issue = { + 'iid': 12, + 'title': 'Need channel-based trigger handling', + 'description': 'Please react to glab/gosmee.', + 'state': 'opened', + 'web_url': + 'https://gitlab.example.test/group/subgroup/sample/-/issues/12', + 'updated_at': '2026-04-08T04:00:00Z', + 'author': {'username': 'reporter'}, + 'labels': const [], + }; + final comments = >[ + { + 'id': 51, + 'body': '@helper can you handle glab/gosmee comments?', + 'created_at': '2026-04-08T04:00:00Z', + 'updated_at': '2026-04-08T04:00:00Z', + 'author': {'username': 'reporter'}, + 'noteable_url': + 'https://gitlab.example.test/group/subgroup/sample/-/issues/12', + }, + ]; + await writeGosmeeForwardScript( + gosmeeScript: gosmeeScript, + publicUrl: 'https://gosmee.example.test/gitlab-sample', + payload: { + 'object_kind': 'note', + 'project': {'path_with_namespace': 'group/subgroup/sample'}, + 'issue': issue, + 'object_attributes': { + 'action': 'create', + 'note': comments.last['body'], + 'noteable_type': 'Issue', + 'url': comments.last['noteable_url'], + }, + 'user': {'username': 'reporter'}, + }, + gosmeeLog: gosmeeLog, + ); + + // And a glab script that can manage webhooks and post issue comments. + final glabScript = File(p.join(sandbox.path, 'glab')); + final glabLog = File(p.join(sandbox.path, 'glab-log.jsonl')); + final postedBody = File(p.join(sandbox.path, 'posted-body.md')); + await writeFakeGlabScript( + glabScript: glabScript, + issueListResponsesByRepo: { + 'group/subgroup/sample': const >[], + }, + commentResponsesByRepo: { + 'group/subgroup/sample': {12: comments}, + }, + postCommentIssueNumbersByRepo: { + 'group/subgroup/sample': {12}, + }, + glabLog: glabLog, + postedBodyFile: postedBody, + viewerLogin: 'glab-user', + webhookIdsByRepo: {'group/subgroup/sample': 81}, + ); + + // 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 glab/gosmee issue note events', + 'summary': 'posted GitLab channel-based planning advice', + }); + + // And an orchestrator-style config that uses glab/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: gitlab + repo: group/subgroup/sample + path: ${repoDir.path} + issueAssistant: + eventSource: glab/gosmee +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + glabCommand: glabScript.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 glab/gosmee issue note events'), + ); + expect(postedComment, contains('@glab-user')); + + // And the GitLab webhook lifecycle commands are invoked for the project. + final glabLogLines = await glabLog.readAsLines(); + expect( + glabLogLines.any( + (line) => + line.contains('api -X POST') && + line.contains('projects/group%2Fsubgroup%2Fsample/hooks') && + line.contains('issues_events=true') && + line.contains('note_events=true'), + ), + isTrue, + ); + expect( + glabLogLines.any( + (line) => line.contains( + 'api -X DELETE projects/group%2Fsubgroup%2Fsample/hooks/81', + ), + ), + isTrue, + ); + final gosmeeLogLines = await gosmeeLog.readAsLines(); + expect( + gosmeeLogLines.any( + (line) => + line.contains('client https://gosmee.example.test/gitlab-sample'), + ), + isTrue, + ); + }); + }); +} + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + registerIssueAssistantAppRunGitLabGosmeeTests(); +} diff --git a/test/support/fake_glab.dart b/test/support/fake_glab.dart index 5ba0e78..c549e02 100644 --- a/test/support/fake_glab.dart +++ b/test/support/fake_glab.dart @@ -13,6 +13,7 @@ Future writeFakeGlabScript({ File? postedBodyFile, String? viewerLogin, bool failViewerLookup = false, + Map? webhookIdsByRepo, }) async { final normalizedPostCommentIssueNumbersByRepo = postCommentIssueNumbersByRepo ?? @@ -117,6 +118,25 @@ Future writeFakeGlabScript({ } } + for (final entry in (webhookIdsByRepo ?? const {}).entries) { + final repo = Uri.encodeComponent(entry.key); + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks" && "\$3" == "POST" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'id': entry.value})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi') + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks/${entry.value}" && "\$3" == "DELETE" ]]; then', + ) + ..writeln(" printf '%s\\n' 'null'") + ..writeln(' exit 0') + ..writeln('fi'); + } + buffer ..writeln(r'''echo "unexpected glab args: $*" >&2''') ..writeln('exit 1');