From 22f2ad9c9d39e5d42a96e07afd3d1b77e3cebfa7 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 11 Apr 2026 18:08:45 +0800 Subject: [PATCH 1/2] fix: unregister gosmee webhooks on forced shutdown (#47) --- .../issue_event_source/gitea_gosmee.dart | 39 ++++- .../issue_event_source/github_gosmee.dart | 39 ++++- .../issue_event_source/gitlab_gosmee.dart | 39 ++++- .../github_gosmee_test.dart | 135 ++++++++++++++++ .../gitlab_gosmee_test.dart | 150 ++++++++++++++++++ test/issue_event_source/gosmee_test.dart | 132 +++++++++++++++ test/support/fake_gosmee.dart | 3 +- test/support/script_utils.dart | 20 ++- 8 files changed, 537 insertions(+), 20 deletions(-) diff --git a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart index 9fd78d2..dc506d4 100644 --- a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart @@ -20,6 +20,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); + static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -206,12 +207,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { 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); - } + await _terminateProcesses(processes); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -451,6 +447,37 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { _logger.error(message); } + Future _terminateProcesses( + List processes, + ) async { + for (final process in processes) { + process.kill(); + } + for (final process in processes) { + await _awaitProcessExit(process); + } + } + + Future _awaitProcessExit(StartedExternalCommand process) async { + try { + await process.exitCode.timeout(_processShutdownTimeout); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } catch (_) { + return; + } + + try { + await process.exitCode.timeout(_processShutdownTimeout); + } on TimeoutException { + _logError( + 'gitea/gosmee process did not exit after sigkill ' + 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', + ); + } catch (_) {} + } + String _summarizeText(String value) { final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); if (singleLine.length <= 240) { diff --git a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart index eec4e19..a1d2fbb 100644 --- a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart @@ -20,6 +20,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); + static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -206,12 +207,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { 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); - } + await _terminateProcesses(processes); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -447,6 +443,37 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { void _logError(String message) => _logger.error(message); + Future _terminateProcesses( + List processes, + ) async { + for (final process in processes) { + process.kill(); + } + for (final process in processes) { + await _awaitProcessExit(process); + } + } + + Future _awaitProcessExit(StartedExternalCommand process) async { + try { + await process.exitCode.timeout(_processShutdownTimeout); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } catch (_) { + return; + } + + try { + await process.exitCode.timeout(_processShutdownTimeout); + } on TimeoutException { + _logError( + 'github/gosmee process did not exit after sigkill ' + 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', + ); + } catch (_) {} + } + String _summarizeText(String value) { final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); if (singleLine.length <= 240) { diff --git a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart index 662b8c1..219afd4 100644 --- a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart @@ -18,6 +18,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); + static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -204,12 +205,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { 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); - } + await _terminateProcesses(processes); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -485,6 +481,37 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { void _logError(String message) => _logger.error(message); + Future _terminateProcesses( + List processes, + ) async { + for (final process in processes) { + process.kill(); + } + for (final process in processes) { + await _awaitProcessExit(process); + } + } + + Future _awaitProcessExit(StartedExternalCommand process) async { + try { + await process.exitCode.timeout(_processShutdownTimeout); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } catch (_) { + return; + } + + try { + await process.exitCode.timeout(_processShutdownTimeout); + } on TimeoutException { + _logError( + 'gitlab/gosmee process did not exit after sigkill ' + 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', + ); + } catch (_) {} + } + String _summarizeText(String value) { final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); if (singleLine.length <= 240) { diff --git a/test/issue_event_source/github_gosmee_test.dart b/test/issue_event_source/github_gosmee_test.dart index 0d2a846..6b5ae08 100644 --- a/test/issue_event_source/github_gosmee_test.dart +++ b/test/issue_event_source/github_gosmee_test.dart @@ -217,6 +217,141 @@ projects: isTrue, ); }); + + /// ```gherkin + /// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown + /// Given a temporary project checkout that can be used as the local repo path + /// And a gosmee command that forwards one GitHub webhook event but ignores SIGTERM + /// 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 then closes + /// Then the app still deletes the GitHub webhook for the project + /// ``` + test( + 'Delete webhook even when gosmee ignores SIGTERM during shutdown', + () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-gh-gosmee-shutdown-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gosmee command that forwards one GitHub webhook event but ignores SIGTERM. + final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); + final issue = { + 'number': 21, + 'title': 'Shutdown should still unregister webhook', + 'body': 'Please react before shutdown.', + 'state': 'open', + 'html_url': 'https://example.test/issues/21', + 'updated_at': '2026-04-08T07:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': const >[], + }; + final comments = [ + { + 'id': 151, + 'body': '@helper verify webhook cleanup on shutdown', + 'html_url': 'https://example.test/issues/21#issuecomment-151', + 'created_at': '2026-04-08T07:00:00Z', + 'updated_at': '2026-04-08T07:00:00Z', + 'user': {'login': 'reporter'}, + }, + ]; + registerFakeGosmeeCommand( + command: gosmeeScript.path, + publicUrl: 'https://gosmee.example.test/github-shutdown', + payload: { + 'action': 'created', + 'issue': issue, + 'comment': comments.last, + 'repository': {'full_name': 'owner/sample'}, + }, + webhookHeader: 'X-GitHub-Event', + exitOnSigterm: false, + ); + + // 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: 21, + 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- ensure webhook cleanup when shutdown races forwarding', + 'summary': 'posted shutdown cleanup 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 + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + issueTracker: + provider: github + eventSource: + pollInterval: 5m + type: gh/gosmee + repo: owner/sample + path: ${repoDir.path} +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), + ); + + // When the long-running app starts and then closes. + final runFuture = app.run(); + for (var attempt = 0; attempt < 200; attempt += 1) { + if (await postedBody.exists()) { + break; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + await app.close(); + await runFuture; + + // Then the app still deletes the GitHub webhook for the project. + final ghLogLines = await ghLog.readAsLines(); + expect( + ghLogLines.any( + (line) => + line.contains('api -X DELETE repos/owner/sample/hooks/81'), + ), + isTrue, + ); + }, + ); }); } diff --git a/test/issue_event_source/gitlab_gosmee_test.dart b/test/issue_event_source/gitlab_gosmee_test.dart index 10a2f7e..d654c1e 100644 --- a/test/issue_event_source/gitlab_gosmee_test.dart +++ b/test/issue_event_source/gitlab_gosmee_test.dart @@ -236,6 +236,156 @@ projects: isTrue, ); }); + + /// ```gherkin + /// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown + /// Given a temporary project checkout that can be used as the local repo path + /// And a gosmee command that forwards one GitLab webhook event but ignores SIGTERM + /// 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 then closes + /// Then the app still deletes the GitLab webhook for the project + /// ``` + test( + 'Delete webhook even when gosmee ignores SIGTERM during shutdown', + () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-glab-gosmee-shutdown-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gosmee command that forwards one GitLab webhook event but ignores SIGTERM. + final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); + final issue = { + 'iid': 21, + 'title': 'Shutdown should still unregister webhook', + 'description': 'Please react before shutdown.', + 'state': 'opened', + 'web_url': + 'https://gitlab.example.test/group/subgroup/sample/-/issues/21', + 'updated_at': '2026-04-08T07:00:00Z', + 'author': {'username': 'reporter'}, + 'labels': const [], + }; + final comments = >[ + { + 'id': 151, + 'body': '@helper verify webhook cleanup on shutdown', + 'created_at': '2026-04-08T07:00:00Z', + 'updated_at': '2026-04-08T07:00:00Z', + 'author': {'username': 'reporter'}, + 'noteable_url': + 'https://gitlab.example.test/group/subgroup/sample/-/issues/21', + }, + ]; + registerFakeGosmeeCommand( + command: gosmeeScript.path, + publicUrl: 'https://gosmee.example.test/gitlab-shutdown', + 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'}, + }, + webhookHeader: 'X-Gitlab-Event', + exitOnSigterm: false, + ); + + // 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': {21: comments}, + }, + postCommentIssueNumbersByRepo: { + 'group/subgroup/sample': {21}, + }, + 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- ensure webhook cleanup when shutdown races forwarding', + 'summary': 'posted shutdown cleanup 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 + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + issueTracker: + provider: gitlab + eventSource: + pollInterval: 5m + type: glab/gosmee + repo: group/subgroup/sample + path: ${repoDir.path} +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + glabCommand: glabScript.path, + gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), + ); + + // When the long-running app starts and then closes. + final runFuture = app.run(); + for (var attempt = 0; attempt < 200; attempt += 1) { + if (await postedBody.exists()) { + break; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + await app.close(); + await runFuture; + + // Then the app still deletes the GitLab webhook for the project. + final glabLogLines = await glabLog.readAsLines(); + expect( + glabLogLines.any( + (line) => line.contains( + 'api -X DELETE projects/group%2Fsubgroup%2Fsample/hooks/81', + ), + ), + isTrue, + ); + }, + ); }); } diff --git a/test/issue_event_source/gosmee_test.dart b/test/issue_event_source/gosmee_test.dart index 95d5979..48b45e8 100644 --- a/test/issue_event_source/gosmee_test.dart +++ b/test/issue_event_source/gosmee_test.dart @@ -365,6 +365,138 @@ CWS_GITEA_TOKEN=test-token isTrue, ); }); + + /// ```gherkin + /// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown + /// Given a temporary project checkout that can be used as the local repo path + /// And a gosmee command that forwards one Gitea webhook event but ignores SIGTERM + /// And a Gitea API fixture that can manage webhooks and post issue comments + /// And a responder that emits a planning reply + /// And an orchestrator-style config that uses tea/gosmee + /// When the long-running app starts and then closes + /// Then the app still deletes the Gitea webhook for the project + /// ``` + test('Delete webhook even when gosmee ignores SIGTERM during shutdown', () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-gosmee-shutdown-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gosmee command that forwards one Gitea webhook event but ignores SIGTERM. + final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); + final issue = { + 'index': 21, + 'title': 'Shutdown should still unregister webhook', + 'body': 'Please react before shutdown.', + 'state': 'open', + 'url': 'https://gitea.example.test/owner/sample/issues/21', + 'updated_at': '2026-04-08T07:00:00Z', + 'poster': {'login': 'reporter'}, + 'labels': const >[], + }; + final comments = [ + { + 'id': 151, + 'body': '@helper verify webhook cleanup on shutdown', + 'url': + 'https://gitea.example.test/owner/sample/issues/21#issuecomment-151', + 'created_at': '2026-04-08T07:00:00Z', + 'updated_at': '2026-04-08T07:00:00Z', + 'poster': {'login': 'reporter'}, + }, + ]; + registerFakeGosmeeCommand( + command: gosmeeScript.path, + publicUrl: 'https://gosmee.example.test/shutdown', + payload: { + 'action': 'created', + 'issue': issue, + 'comment': comments.last, + 'repository': {'full_name': 'owner/sample'}, + }, + exitOnSigterm: false, + ); + + // And a Gitea API fixture that can manage webhooks and post issue comments. + final giteaServer = await FakeGiteaApiServer.start( + issueListResponsesByRepo: { + 'owner/sample': const >[], + }, + commentResponsesByRepo: { + 'owner/sample': {21: comments}, + }, + postCommentResponsesByRepo: { + 'owner/sample': { + 21: { + 'id': 803, + 'body': 'posted', + 'html_url': 'https://gitea.example.test/comment/803', + }, + }, + }, + viewerLogin: 'tea-octocat', + ); + addTearDown(giteaServer.close); + + // 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- ensure webhook cleanup when shutdown races forwarding', + 'summary': 'posted shutdown cleanup advice', + }); + + // And an orchestrator-style config that uses tea/gosmee. + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + issueTracker: + provider: gitea + eventSource: + pollInterval: 5m + type: tea/gosmee + repo: owner/sample + path: ${repoDir.path} +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + gosmeeCommand: gosmeeScript.path, + responderRunner: fakeResponderRunner(), + commandExecutor: fakeExternalCommandExecutor(), + ); + + // When the long-running app starts and then closes. + final runFuture = app.run(); + for (var attempt = 0; attempt < 200; attempt += 1) { + if (giteaServer.postedBodies.isNotEmpty) { + break; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + await app.close(); + await runFuture; + + // Then the app still deletes the Gitea webhook for the project. + expect(giteaServer.deletedWebhookIds, contains(81)); + }); }); } diff --git a/test/support/fake_gosmee.dart b/test/support/fake_gosmee.dart index 172a316..e0ba83d 100644 --- a/test/support/fake_gosmee.dart +++ b/test/support/fake_gosmee.dart @@ -24,6 +24,7 @@ void registerFakeGosmeeCommand({ Map? payload, File? gosmeeLog, String webhookHeader = 'X-Gitea-Event', + bool exitOnSigterm = true, }) { fakeCommandHandlers[command] = (arguments, {timeout}) async { if (gosmeeLog != null) { @@ -44,7 +45,7 @@ void registerFakeGosmeeCommand({ if (gosmeeLog != null) { await _appendLine(gosmeeLog, '${arguments.join(' ')}\n'); } - final process = FakeStartedExternalCommand(); + final process = FakeStartedExternalCommand(exitOnSigterm: exitOnSigterm); if (arguments.length >= 3 && arguments[0] == 'client' && arguments[1] == publicUrl) { diff --git a/test/support/script_utils.dart b/test/support/script_utils.dart index c2e7223..593c329 100644 --- a/test/support/script_utils.dart +++ b/test/support/script_utils.dart @@ -61,6 +61,15 @@ ExternalCommandExecutor fakeExternalCommandExecutor() { } class FakeStartedExternalCommand implements StartedExternalCommand { + FakeStartedExternalCommand({ + this.exitOnSigterm = true, + this.exitOnSigkill = true, + this.sigkillExitCode = 137, + }); + + final bool exitOnSigterm; + final bool exitOnSigkill; + final int sigkillExitCode; final StreamController> _stdoutController = StreamController(); final StreamController> _stderrController = StreamController(); final Completer _exitCodeCompleter = Completer(); @@ -96,7 +105,16 @@ class FakeStartedExternalCommand implements StartedExternalCommand { @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { - complete(); + if (signal == ProcessSignal.sigkill) { + if (exitOnSigkill) { + complete(sigkillExitCode); + } + return true; + } + if (signal == ProcessSignal.sigterm && exitOnSigterm) { + complete(); + return true; + } return true; } } From a1d0ffe8f30f314adc82a2b9c83e663a1544b6d0 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 11 Apr 2026 19:50:27 +0800 Subject: [PATCH 2/2] refactor: deduplicate gosmee event source and test scaffolding --- .../issue_event_source/gitea_gosmee.dart | 119 +++----------- .../issue_event_source/github_gosmee.dart | 122 +++----------- .../issue_event_source/gitlab_gosmee.dart | 122 +++----------- .../issue_event_source/gosmee_shared.dart | 140 ++++++++++++++++ .../github_gosmee_test.dart | 93 +++-------- .../gitlab_gosmee_test.dart | 93 +++-------- test/issue_event_source/gosmee_test.dart | 151 ++++++------------ test/issue_event_source/test_helpers.dart | 68 ++++++++ .../webhook_forward_test.dart | 118 +++++--------- 9 files changed, 410 insertions(+), 616 deletions(-) create mode 100644 lib/src/issue_tracker/issue_event_source/gosmee_shared.dart create mode 100644 test/issue_event_source/test_helpers.dart diff --git a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart index dc506d4..20d0dd5 100644 --- a/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart @@ -10,6 +10,7 @@ import '../client.dart'; import '../models.dart'; import '../../core/process_launcher.dart'; import 'base.dart'; +import 'gosmee_shared.dart'; class GiteaGosmeeIssueEventSource implements IssueEventSource { GiteaGosmeeIssueEventSource({ @@ -20,7 +21,6 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); - static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -207,7 +207,11 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { final processes = _gosmeeProcesses.values.toList(growable: false); _gosmeeProcesses.clear(); - await _terminateProcesses(processes); + await GosmeeShared.terminateProcesses( + processes: processes, + sourceLabel: 'gitea/gosmee', + logError: _logError, + ); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -311,57 +315,14 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await commandExecutor.run(gosmeeCommand, [ - '--output', - 'json', - 'client', - '--new-url', - localUrl, - ]); - _logDebug( - 'gitea/gosmee channel_command_result local_url=$localUrl ' - 'exit_code=${result.exitCode} stdout=${_summarizeText(result.stdout.toString())} ' - 'stderr=${_summarizeText(result.stderr.toString())}', + return GosmeeShared.createChannelUrl( + commandExecutor: commandExecutor, + gosmeeCommand: gosmeeCommand, + localUrl: localUrl, + sourceLabel: 'gitea/gosmee', + log: _log, + logDebug: _logDebug, ); - 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( - 'gitea/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText', - ); - try { - final decodedJson = jsonDecode(stdoutText); - if (decodedJson is Map) { - final publicUrl = decodedJson['url']?.toString().trim(); - if (publicUrl == null || publicUrl.isEmpty) { - throw const FormatException( - 'gosmee JSON output did not contain a valid public URL.', - ); - } - return publicUrl; - } - if (decodedJson is String) { - final publicUrl = decodedJson.trim(); - if (publicUrl.isEmpty) { - throw const FormatException('gosmee did not print a public URL.'); - } - return publicUrl; - } - } on FormatException { - if (Uri.tryParse(stdoutText)?.hasScheme ?? false) { - return stdoutText; - } - } - throw const FormatException('gosmee did not print a valid public URL.'); } void _scheduleFlush() { @@ -425,14 +386,13 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { required Stream> stream, required String level, }) { - utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) { - final message = 'gitea/gosmee project=${project.key} $level=$line'; - if (level == 'stderr') { - _logError(message); - return; - } - _log(message); - }); + GosmeeShared.pipeProcessLogs( + logger: _logger, + sourceLabel: 'gitea/gosmee', + projectKey: project.key, + stream: stream, + level: level, + ); } void _log(String message) { @@ -446,45 +406,6 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { void _logError(String message) { _logger.error(message); } - - Future _terminateProcesses( - List processes, - ) async { - for (final process in processes) { - process.kill(); - } - for (final process in processes) { - await _awaitProcessExit(process); - } - } - - Future _awaitProcessExit(StartedExternalCommand process) async { - try { - await process.exitCode.timeout(_processShutdownTimeout); - return; - } on TimeoutException { - process.kill(ProcessSignal.sigkill); - } catch (_) { - return; - } - - try { - await process.exitCode.timeout(_processShutdownTimeout); - } on TimeoutException { - _logError( - 'gitea/gosmee process did not exit after sigkill ' - 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', - ); - } catch (_) {} - } - - String _summarizeText(String value) { - final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); - if (singleLine.length <= 240) { - return singleLine.isEmpty ? '(empty)' : singleLine; - } - return '${singleLine.substring(0, 240)}...'; - } } class _QueuedGiteaIssue { diff --git a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart index a1d2fbb..1477893 100644 --- a/lib/src/issue_tracker/issue_event_source/github_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart @@ -10,6 +10,7 @@ import '../client.dart'; import '../models.dart'; import '../../core/process_launcher.dart'; import 'base.dart'; +import 'gosmee_shared.dart'; class GitHubGosmeeIssueEventSource implements IssueEventSource { GitHubGosmeeIssueEventSource({ @@ -20,7 +21,6 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); - static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -207,7 +207,11 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { final processes = _gosmeeProcesses.values.toList(growable: false); _gosmeeProcesses.clear(); - await _terminateProcesses(processes); + await GosmeeShared.terminateProcesses( + processes: processes, + sourceLabel: 'github/gosmee', + logError: _logError, + ); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -311,60 +315,14 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await commandExecutor.run(gosmeeCommand, [ - '--output', - 'json', - 'client', - '--new-url', - localUrl, - ]); - _logDebug( - 'github/gosmee channel_command_result local_url=$localUrl ' - 'exit_code=${result.exitCode} stdout=${_summarizeText(result.stdout.toString())} ' - 'stderr=${_summarizeText(result.stderr.toString())}', + return GosmeeShared.createChannelUrl( + commandExecutor: commandExecutor, + gosmeeCommand: gosmeeCommand, + localUrl: localUrl, + sourceLabel: 'github/gosmee', + log: _log, + logDebug: _logDebug, ); - 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( - 'github/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() { @@ -427,14 +385,13 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { required Stream> stream, required String level, }) { - utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) { - final message = 'github/gosmee project=${project.key} $level=$line'; - if (level == 'stderr') { - _logger.error(message); - } else { - _logger.info(message); - } - }); + GosmeeShared.pipeProcessLogs( + logger: _logger, + sourceLabel: 'github/gosmee', + projectKey: project.key, + stream: stream, + level: level, + ); } void _log(String message) => _logger.info(message); @@ -442,45 +399,6 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { void _logDebug(String message) => _logger.debug(message); void _logError(String message) => _logger.error(message); - - Future _terminateProcesses( - List processes, - ) async { - for (final process in processes) { - process.kill(); - } - for (final process in processes) { - await _awaitProcessExit(process); - } - } - - Future _awaitProcessExit(StartedExternalCommand process) async { - try { - await process.exitCode.timeout(_processShutdownTimeout); - return; - } on TimeoutException { - process.kill(ProcessSignal.sigkill); - } catch (_) { - return; - } - - try { - await process.exitCode.timeout(_processShutdownTimeout); - } on TimeoutException { - _logError( - 'github/gosmee process did not exit after sigkill ' - 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', - ); - } catch (_) {} - } - - String _summarizeText(String value) { - final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); - if (singleLine.length <= 240) { - return singleLine.isEmpty ? '(empty)' : singleLine; - } - return '${singleLine.substring(0, 240)}...'; - } } class _QueuedGitHubIssue { diff --git a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart index 219afd4..c3f1bcf 100644 --- a/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitlab_gosmee.dart @@ -8,6 +8,7 @@ import '../../core/process_launcher.dart'; import '../client.dart'; import '../models.dart'; import 'base.dart'; +import 'gosmee_shared.dart'; class GitLabGosmeeIssueEventSource implements IssueEventSource { GitLabGosmeeIssueEventSource({ @@ -18,7 +19,6 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { }) : _logger = logger; static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); - static const Duration _processShutdownTimeout = Duration(seconds: 2); final IssueTrackerClient issueTrackerClient; final String gosmeeCommand; @@ -205,7 +205,11 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { final processes = _gosmeeProcesses.values.toList(growable: false); _gosmeeProcesses.clear(); - await _terminateProcesses(processes); + await GosmeeShared.terminateProcesses( + processes: processes, + sourceLabel: 'gitlab/gosmee', + logError: _logError, + ); final webhookIds = Map.from(_webhookIdsByProject); _webhookIdsByProject.clear(); @@ -349,60 +353,14 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { } Future _createGosmeeChannelUrl(String localUrl) async { - final result = await commandExecutor.run(gosmeeCommand, [ - '--output', - 'json', - 'client', - '--new-url', - localUrl, - ]); - _logDebug( - 'gitlab/gosmee channel_command_result local_url=$localUrl ' - 'exit_code=${result.exitCode} stdout=${_summarizeText(result.stdout.toString())} ' - 'stderr=${_summarizeText(result.stderr.toString())}', + return GosmeeShared.createChannelUrl( + commandExecutor: commandExecutor, + gosmeeCommand: gosmeeCommand, + localUrl: localUrl, + sourceLabel: 'gitlab/gosmee', + log: _log, + logDebug: _logDebug, ); - 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() { @@ -465,14 +423,13 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { 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); - } - }); + GosmeeShared.pipeProcessLogs( + logger: _logger, + sourceLabel: 'gitlab/gosmee', + projectKey: project.key, + stream: stream, + level: level, + ); } void _log(String message) => _logger.info(message); @@ -480,45 +437,6 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource { void _logDebug(String message) => _logger.debug(message); void _logError(String message) => _logger.error(message); - - Future _terminateProcesses( - List processes, - ) async { - for (final process in processes) { - process.kill(); - } - for (final process in processes) { - await _awaitProcessExit(process); - } - } - - Future _awaitProcessExit(StartedExternalCommand process) async { - try { - await process.exitCode.timeout(_processShutdownTimeout); - return; - } on TimeoutException { - process.kill(ProcessSignal.sigkill); - } catch (_) { - return; - } - - try { - await process.exitCode.timeout(_processShutdownTimeout); - } on TimeoutException { - _logError( - 'gitlab/gosmee process did not exit after sigkill ' - 'timeout_ms=${_processShutdownTimeout.inMilliseconds}', - ); - } catch (_) {} - } - - String _summarizeText(String value) { - final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); - if (singleLine.length <= 240) { - return singleLine.isEmpty ? '(empty)' : singleLine; - } - return '${singleLine.substring(0, 240)}...'; - } } class _QueuedGitLabIssue { diff --git a/lib/src/issue_tracker/issue_event_source/gosmee_shared.dart b/lib/src/issue_tracker/issue_event_source/gosmee_shared.dart new file mode 100644 index 0000000..7c40589 --- /dev/null +++ b/lib/src/issue_tracker/issue_event_source/gosmee_shared.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../config/app_logger.dart'; +import '../../core/process_launcher.dart'; + +class GosmeeShared { + static const Duration processShutdownTimeout = Duration(seconds: 2); + + static Future createChannelUrl({ + required ExternalCommandExecutor commandExecutor, + required String gosmeeCommand, + required String localUrl, + required String sourceLabel, + required void Function(String message) log, + required void Function(String message) logDebug, + }) async { + final result = await commandExecutor.run(gosmeeCommand, [ + '--output', + 'json', + 'client', + '--new-url', + localUrl, + ]); + logDebug( + '$sourceLabel channel_command_result local_url=$localUrl ' + 'exit_code=${result.exitCode} stdout=${_summarizeText(result.stdout.toString())} ' + 'stderr=${_summarizeText(result.stderr.toString())}', + ); + 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( + '$sourceLabel 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.'); + } + + static Future terminateProcesses({ + required List processes, + required String sourceLabel, + required void Function(String message) logError, + }) async { + for (final process in processes) { + process.kill(); + } + for (final process in processes) { + await _awaitProcessExit( + process: process, + sourceLabel: sourceLabel, + logError: logError, + ); + } + } + + static void pipeProcessLogs({ + required AppLogger logger, + required String sourceLabel, + required String projectKey, + required Stream> stream, + required String level, + }) { + utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) { + final message = '$sourceLabel project=$projectKey $level=$line'; + if (level == 'stderr') { + logger.error(message); + } else { + logger.info(message); + } + }); + } + + static Future _awaitProcessExit({ + required StartedExternalCommand process, + required String sourceLabel, + required void Function(String message) logError, + }) async { + try { + await process.exitCode.timeout(processShutdownTimeout); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } catch (_) { + return; + } + + try { + await process.exitCode.timeout(processShutdownTimeout); + } on TimeoutException { + logError( + '$sourceLabel process did not exit after sigkill ' + 'timeout_ms=${processShutdownTimeout.inMilliseconds}', + ); + } catch (_) {} + } + + static String _summarizeText(String value) { + final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (singleLine.length <= 240) { + return singleLine.isEmpty ? '(empty)' : singleLine; + } + return '${singleLine.substring(0, 240)}...'; + } +} diff --git a/test/issue_event_source/github_gosmee_test.dart b/test/issue_event_source/github_gosmee_test.dart index 6b5ae08..ea0b999 100644 --- a/test/issue_event_source/github_gosmee_test.dart +++ b/test/issue_event_source/github_gosmee_test.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../test_support.dart'; +import 'test_helpers.dart'; void registerIssueAssistantAppRunGitHubGosmeeTests() { /// ```gherkin @@ -29,11 +30,9 @@ void registerIssueAssistantAppRunGitHubGosmeeTests() { /// ``` 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); + final sandboxRepo = await createSandboxRepo(prefix: 'cws-gh-gosmee-run-'); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee script that forwards one GitHub issue_comment webhook event. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -95,26 +94,14 @@ void registerIssueAssistantAppRunGitHubGosmeeTests() { }); // 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 - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: github - eventSource: - pollInterval: 5m - type: gh/gosmee - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'github', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: gh/gosmee'], + ); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -132,15 +119,7 @@ projects: addTearDown(() => AppLogger.removeListener(captureLog)); // When the long-running app starts and receives the forwarded webhook. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then it reads issue comments and posts exactly one reply for that issue. expect(await postedBody.exists(), isTrue); @@ -232,11 +211,11 @@ projects: 'Delete webhook even when gosmee ignores SIGTERM during shutdown', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-gh-gosmee-shutdown-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-gh-gosmee-shutdown-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee command that forwards one GitHub webhook event but ignores SIGTERM. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -299,28 +278,14 @@ projects: }); // And an orchestrator-style config that uses gh/gosmee. - final configFile = File( - p.join(sandbox.path, 'agent-orchestrator.yaml'), + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'github', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: gh/gosmee'], ); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: github - eventSource: - pollInterval: 5m - type: gh/gosmee - repo: owner/sample - path: ${repoDir.path} -'''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -331,15 +296,7 @@ projects: ); // When the long-running app starts and then closes. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then the app still deletes the GitHub webhook for the project. final ghLogLines = await ghLog.readAsLines(); diff --git a/test/issue_event_source/gitlab_gosmee_test.dart b/test/issue_event_source/gitlab_gosmee_test.dart index d654c1e..b87a435 100644 --- a/test/issue_event_source/gitlab_gosmee_test.dart +++ b/test/issue_event_source/gitlab_gosmee_test.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../test_support.dart'; +import 'test_helpers.dart'; void registerIssueAssistantAppRunGitLabGosmeeTests() { /// ```gherkin @@ -29,11 +30,11 @@ void registerIssueAssistantAppRunGitLabGosmeeTests() { /// ``` 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 sandboxRepo = await createSandboxRepo( + prefix: 'cws-glab-gosmee-run-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee script that forwards one GitLab note webhook event. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -109,26 +110,14 @@ void registerIssueAssistantAppRunGitLabGosmeeTests() { }); // 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 - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: gitlab - eventSource: - pollInterval: 5m - type: glab/gosmee - repo: group/subgroup/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'gitlab', + repo: 'group/subgroup/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: glab/gosmee'], + ); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -146,15 +135,7 @@ projects: addTearDown(() => AppLogger.removeListener(captureLog)); // When the long-running app starts and receives the forwarded webhook. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then it reads issue comments and posts exactly one reply for that issue. expect(await postedBody.exists(), isTrue); @@ -251,11 +232,11 @@ projects: 'Delete webhook even when gosmee ignores SIGTERM during shutdown', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-glab-gosmee-shutdown-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-glab-gosmee-shutdown-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee command that forwards one GitLab webhook event but ignores SIGTERM. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -332,28 +313,14 @@ projects: }); // And an orchestrator-style config that uses glab/gosmee. - final configFile = File( - p.join(sandbox.path, 'agent-orchestrator.yaml'), + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'gitlab', + repo: 'group/subgroup/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: glab/gosmee'], ); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: gitlab - eventSource: - pollInterval: 5m - type: glab/gosmee - repo: group/subgroup/sample - path: ${repoDir.path} -'''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -364,15 +331,7 @@ projects: ); // When the long-running app starts and then closes. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then the app still deletes the GitLab webhook for the project. final glabLogLines = await glabLog.readAsLines(); diff --git a/test/issue_event_source/gosmee_test.dart b/test/issue_event_source/gosmee_test.dart index 48b45e8..75b31ac 100644 --- a/test/issue_event_source/gosmee_test.dart +++ b/test/issue_event_source/gosmee_test.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../test_support.dart'; +import 'test_helpers.dart'; void registerIssueAssistantAppRunGosmeeTests() { /// ```gherkin @@ -30,9 +31,9 @@ void registerIssueAssistantAppRunGosmeeTests() { /// ``` test('Reply to a mention received through tea/gosmee', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp('cws-gosmee-run-'); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandboxRepo = await createSandboxRepo(prefix: 'cws-gosmee-run-'); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee script that forwards one Gitea issue_comment webhook event. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -101,26 +102,14 @@ void registerIssueAssistantAppRunGosmeeTests() { }); // And an orchestrator-style config that uses tea/gosmee. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: gitea - eventSource: - pollInterval: 5m - type: tea/gosmee - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'gitea', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: tea/gosmee'], + ); await File(p.join(sandbox.path, '.env')).writeAsString(''' CWS_GITEA_HOST=${giteaServer.baseUrl} CWS_GITEA_TOKEN=test-token @@ -141,15 +130,10 @@ CWS_GITEA_TOKEN=test-token addTearDown(() => AppLogger.removeListener(captureLog)); // When the long-running app starts and receives the forwarded webhook. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (giteaServer.postedBodies.isNotEmpty) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil( + app: app, + isReady: () async => giteaServer.postedBodies.isNotEmpty, + ); // Then it reads issue comments and posts exactly one reply for that issue. expect(giteaServer.postedBodies, hasLength(1)); @@ -225,11 +209,11 @@ CWS_GITEA_TOKEN=test-token /// ``` test('Reconcile a missed Gitea gosmee event through polling', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-gosmee-reconcile-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-gosmee-reconcile-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee script that starts forwarding without delivering an event. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -296,27 +280,18 @@ CWS_GITEA_TOKEN=test-token }); // And an orchestrator-style config that uses tea/gosmee with continuous reconciliation. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: gitea - eventSource: - type: tea/gosmee - reconciliation: continuous - reconcileInterval: 1s - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'gitea', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const [ + 'type: tea/gosmee', + 'reconciliation: continuous', + 'reconcileInterval: 1s', + ], + ); await File(p.join(sandbox.path, '.env')).writeAsString(''' CWS_GITEA_HOST=${giteaServer.baseUrl} CWS_GITEA_TOKEN=test-token @@ -330,15 +305,10 @@ CWS_GITEA_TOKEN=test-token ); // When the long-running app starts and enough time passes for reconciliation polling. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (giteaServer.postedBodies.isNotEmpty) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil( + app: app, + isReady: () async => giteaServer.postedBodies.isNotEmpty, + ); // Then it posts exactly one reply for the issue discovered by polling. expect(giteaServer.postedBodies, hasLength(1)); @@ -378,11 +348,11 @@ CWS_GITEA_TOKEN=test-token /// ``` test('Delete webhook even when gosmee ignores SIGTERM during shutdown', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-gosmee-shutdown-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-gosmee-shutdown-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gosmee command that forwards one Gitea webhook event but ignores SIGTERM. final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); @@ -451,26 +421,14 @@ CWS_GITEA_TOKEN=test-token }); // And an orchestrator-style config that uses tea/gosmee. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: gitea - eventSource: - pollInterval: 5m - type: tea/gosmee - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'gitea', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const ['pollInterval: 5m', 'type: tea/gosmee'], + ); await File(p.join(sandbox.path, '.env')).writeAsString(''' CWS_GITEA_HOST=${giteaServer.baseUrl} CWS_GITEA_TOKEN=test-token @@ -484,15 +442,10 @@ CWS_GITEA_TOKEN=test-token ); // When the long-running app starts and then closes. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (giteaServer.postedBodies.isNotEmpty) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil( + app: app, + isReady: () async => giteaServer.postedBodies.isNotEmpty, + ); // Then the app still deletes the Gitea webhook for the project. expect(giteaServer.deletedWebhookIds, contains(81)); diff --git a/test/issue_event_source/test_helpers.dart b/test/issue_event_source/test_helpers.dart new file mode 100644 index 0000000..52cdc60 --- /dev/null +++ b/test/issue_event_source/test_helpers.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:path/path.dart' as p; + +import '../test_support.dart'; + +Future<({Directory sandbox, Directory repoDir})> createSandboxRepo({ + required String prefix, +}) async { + final sandbox = await Directory.systemTemp.createTemp(prefix); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + return (sandbox: sandbox, repoDir: repoDir); +} + +Future writeSingleProjectIssueAssistantConfig({ + required Directory sandbox, + required String responderPath, + required String provider, + required String repo, + required String repoPath, + required List eventSourceLines, +}) async { + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + final eventSourceYaml = eventSourceLines + .map((line) => ' $line') + .join('\n'); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + mentionTriggers: ["@helper"] + responders: + - id: primary + command: $responderPath + timeout: 1m +projects: + sample: + issueTracker: + provider: $provider + eventSource: +$eventSourceYaml + repo: $repo + path: $repoPath +'''); + return configFile; +} + +Future runAppUntil({ + required IssueAssistantApp app, + required Future Function() isReady, + int maxAttempts = 200, + Duration pollInterval = const Duration(milliseconds: 100), +}) async { + final runFuture = app.run(); + try { + for (var attempt = 0; attempt < maxAttempts; attempt += 1) { + if (await isReady()) { + break; + } + await Future.delayed(pollInterval); + } + } finally { + await app.close(); + } + await runFuture; +} diff --git a/test/issue_event_source/webhook_forward_test.dart b/test/issue_event_source/webhook_forward_test.dart index f1726ff..e72335c 100644 --- a/test/issue_event_source/webhook_forward_test.dart +++ b/test/issue_event_source/webhook_forward_test.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../test_support.dart'; +import 'test_helpers.dart'; void registerIssueAssistantAppRunWebhookForwardTests() { /// ```gherkin @@ -29,9 +30,9 @@ void registerIssueAssistantAppRunWebhookForwardTests() { /// ``` test('Reply to a mention received through gh/webhook-forward', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp('cws-webhook-run-'); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandboxRepo = await createSandboxRepo(prefix: 'cws-webhook-run-'); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gh script that forwards one issue_comment webhook event. final ghScript = File(p.join(sandbox.path, 'gh')); @@ -77,25 +78,14 @@ void registerIssueAssistantAppRunWebhookForwardTests() { }); // And an orchestrator-style config that uses gh/webhook-forward. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: github - eventSource: - type: gh/webhook-forward - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'github', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const ['type: gh/webhook-forward'], + ); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -105,15 +95,7 @@ projects: ); // When the long-running app starts and receives the forwarded webhook. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then it reads issue comments and posts exactly one reply for that issue. expect(await postedBody.exists(), isTrue); @@ -146,11 +128,11 @@ projects: /// ``` test('Log webhook forward stderr at error level', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-webhook-stderr-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-webhook-stderr-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gh script that writes a webhook creation failure to stderr while forwarding. final ghScript = File(p.join(sandbox.path, 'gh')); @@ -215,17 +197,12 @@ projects: AppLogger.addListener(captureLog); // When the long-running app starts and webhook-forward emits that stderr line. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (records.any( + await runAppUntil( + app: app, + isReady: () async => records.any( (record) => record.message.contains('error creating webhook'), - )) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + ), + ); AppLogger.removeListener(captureLog); // Then the app logs the webhook-forward stderr message at error level. @@ -255,11 +232,11 @@ projects: /// ``` test('Reconcile a missed GitHub webhook event through polling', () async { // Given a temporary project checkout that can be used as the local repo path. - final sandbox = await Directory.systemTemp.createTemp( - 'cws-webhook-reconcile-', + final sandboxRepo = await createSandboxRepo( + prefix: 'cws-webhook-reconcile-', ); - final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); - await initGitRepo(repoDir); + final sandbox = sandboxRepo.sandbox; + final repoDir = sandboxRepo.repoDir; // And a gh script that starts webhook forwarding without delivering an event. final ghScript = File(p.join(sandbox.path, 'gh')); @@ -380,27 +357,18 @@ projects: }); // And an orchestrator-style config that uses gh/webhook-forward with continuous reconciliation. - final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - await configFile.writeAsString(''' -defaults: - issueAssistant: - enabled: true - mentionTriggers: ["@helper"] - responders: - - id: primary - command: ${responderScript.path} - timeout: 1m -projects: - sample: - issueTracker: - provider: github - eventSource: - type: gh/webhook-forward - reconciliation: continuous - reconcileInterval: 1s - repo: owner/sample - path: ${repoDir.path} -'''); + final configFile = await writeSingleProjectIssueAssistantConfig( + sandbox: sandbox, + responderPath: responderScript.path, + provider: 'github', + repo: 'owner/sample', + repoPath: repoDir.path, + eventSourceLines: const [ + 'type: gh/webhook-forward', + 'reconciliation: continuous', + 'reconcileInterval: 1s', + ], + ); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), @@ -410,15 +378,7 @@ projects: ); // When the long-running app starts and enough time passes for reconciliation polling. - final runFuture = app.run(); - for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - await app.close(); - await runFuture; + await runAppUntil(app: app, isReady: postedBody.exists); // Then it posts exactly one reply for the issue discovered by polling. expect(await postedBody.exists(), isTrue);