diff --git a/README.md b/README.md index 476cb45..83262c4 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,17 @@ GitHub issue thread assistant with an Agent Orchestrator style YAML config. -The app polls configured GitHub repositories with `gh`, reads issue threads and -issue comments, and uses configured CLI responders such as `codex`, -`opencode`, or `copilot-cli` to reply when: +It polls configured GitHub repositories with `gh`, reads issue threads, and +uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide +when to reply. It supports multi-repo config, responder fallback chains, and +optional Discord webhook notifications. -- a user explicitly mentions the assistant -- a new comment changes the thread enough that the responder decides a reply is - useful +## Highlights -It supports multi-repo configuration, responder fallback chains. - -## Features - -- Agent Orchestrator style YAML config with `defaults` and `projects` -- Multiple repositories in one process -- Comment-triggered issue assistance -- Planning / architecture guidance in issue threads -- Bug verification flows in isolated temporary git worktrees -- CLI-based responder fallback chain -- Durable state with `drift` + SQLite -- BDD-style tests using `package:test` with Given / When / Then wording +- Poll GitHub issues across multiple repositories +- Trigger replies from mentions or meaningful thread updates +- Run planning and bug-verification flows in isolated worktrees +- Send optional Discord notifications for assistant events ## Requirements @@ -32,7 +23,9 @@ It supports multi-repo configuration, responder fallback chains. ## Config -Create a local `agent-orchestrator.yaml` and follow [`/examples/README.md`](examples/README.md) as a starting point. +Create a local `agent-orchestrator.yaml` and use +[`examples/README.md`](examples/README.md) plus +[`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference. Generate a starter config: @@ -79,13 +72,6 @@ dart analyze dart test ``` -All Dart tests under `test/` must follow the repo's Gherkin doc-comment format: - -- `group(...)` has a `Feature` doc block above it -- `test(...)` has a `Scenario` doc block above it -- runtime names stay plain, without `Feature:` or `Scenario:` prefixes -- test bodies include `Given` / `When` / `Then` inline comments - ## ref [agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) diff --git a/examples/README.md b/examples/README.md index 829c369..c97e95a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,7 +3,7 @@ The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`. Project entries can override `issueAssistant` fields when a repo needs a -different prompt, mention triggers, or responder chain. +different prompt, mention triggers, responder chain, or notification list. Generated comments include both a visible prefix and footer by default so it is clear the reply came from automation even when `gh` is authenticated as a human @@ -12,6 +12,16 @@ account. Those markers support template variables such as `{{gh_login}}`, `{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty string to disable that section. +Discord notifications are configured under `issueAssistant.notifications`. +Currently supported fields are: + +- `type: discordWebhook` +- `enabled` +- `events`: any of `mention_detected`, `reply_posted`, `no_reply`, `responder_failed` +- `webhookUrl` +- `username` +- `avatarUrl` + Start from [`examples/simple-github.yaml`](simple-github.yaml). This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index e707e7d..a283f76 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -30,6 +30,13 @@ defaults: # - id: opencode # command: opencode # timeout: 10m + notifications: + # Optional Discord notifications for assistant activity. + - type: discordWebhook + enabled: true + events: ["mention_detected", "reply_posted", "responder_failed"] + webhookUrl: ${CWS_DISCORD_WEBHOOK} + username: code-work-spawner projects: code-work-spawner: @@ -37,3 +44,7 @@ projects: path: ~/code_work_spawner defaultBranch: main # sessionPrefix: app + # issueAssistant: + # notifications: + # - type: discordWebhook + # events: ["no_reply"] diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index c2122e9..6d4b438 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -3,4 +3,5 @@ export 'src/config.dart'; export 'src/database.dart'; export 'src/github_client.dart'; export 'src/issue_assistant_app.dart'; +export 'src/notifier.dart'; export 'src/workspace_manager.dart'; diff --git a/lib/src/config.dart b/lib/src/config.dart index 30a3614..dad7522 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -7,7 +7,12 @@ import 'package:yaml/yaml.dart'; import 'comment_templates.dart'; import 'config_schema.dart'; -export 'config_schema.dart' show AssistantCapability; +export 'config_schema.dart' + show + AssistantCapability, + NotificationConfigDocument, + NotificationEvent, + NotificationType; const String defaultIssueAssistantPrompt = ''' You are a GitHub issue thread assistant. @@ -212,6 +217,7 @@ class IssueAssistantConfig { required this.prompt, required this.responders, required this.capabilities, + required this.notifications, required this.commentTag, required this.commentPrefixTemplate, required this.commentFooterTemplate, @@ -224,6 +230,7 @@ class IssueAssistantConfig { final String prompt; final List responders; final Set capabilities; + final List notifications; final String commentTag; final String commentPrefixTemplate; final String commentFooterTemplate; @@ -288,6 +295,43 @@ class CliResponderConfig { } } +class NotificationConfig { + NotificationConfig({ + required this.type, + required this.enabled, + required this.events, + required this.webhookUrl, + required this.username, + required this.avatarUrl, + }); + + final NotificationType type; + final bool enabled; + final Set events; + final String? webhookUrl; + final String? username; + final String? avatarUrl; + + factory NotificationConfig.fromDocument(NotificationConfigDocument document) { + final type = document.type; + if (type == null) { + throw const FormatException('Notification type is required.'); + } + + return NotificationConfig( + type: type, + enabled: document.enabled ?? true, + events: + (document.events ?? + const [NotificationEvent.replyPosted]) + .toSet(), + webhookUrl: _expandOptionalEnvironmentString(document.webhookUrl), + username: _expandOptionalEnvironmentString(document.username), + avatarUrl: _expandOptionalEnvironmentString(document.avatarUrl), + ); + } +} + class _IssueAssistantConfigResolver { const _IssueAssistantConfigResolver(); @@ -296,6 +340,7 @@ class _IssueAssistantConfigResolver { required IssueAssistantConfigDocument? overlay, }) { final responders = overlay?.responders; + final notifications = overlay?.notifications; return IssueAssistantConfig( enabled: overlay?.enabled ?? base?.enabled ?? true, pollInterval: overlay?.pollInterval != null @@ -321,6 +366,11 @@ class _IssueAssistantConfigResolver { AssistantCapability.planning, AssistantCapability.bugVerification, }, + notifications: notifications != null + ? notifications + .map(NotificationConfig.fromDocument) + .toList(growable: false) + : base?.notifications ?? const [], commentTag: overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner', commentPrefixTemplate: @@ -419,6 +469,17 @@ String? _expandOptionalPath(String? path) { return _expandHome(path); } +String? _expandOptionalEnvironmentString(String? value) { + if (value == null || value.isEmpty) { + return value; + } + return value.replaceAllMapped(RegExp(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'), ( + match, + ) { + return Platform.environment[match.group(1)!] ?? ''; + }); +} + void _assertCamelCaseConfigKeys(Map root) { _assertCamelCaseMap(root, scope: ''); @@ -438,6 +499,17 @@ void _assertCamelCaseConfigKeys(Map root) { } } } + final notifications = issueAssistant['notifications']; + if (notifications is List) { + for (final notification in notifications) { + if (notification is Map) { + _assertCamelCaseMap( + notification, + scope: 'defaults.issueAssistant.notifications', + ); + } + } + } } } @@ -466,6 +538,17 @@ void _assertCamelCaseConfigKeys(Map root) { } } } + final notifications = issueAssistant['notifications']; + if (notifications is List) { + for (final notification in notifications) { + if (notification is Map) { + _assertCamelCaseMap( + notification, + scope: 'projects.${entry.key}.issueAssistant.notifications', + ); + } + } + } } } } @@ -535,6 +618,12 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')} # - id: opencode # command: opencode # timeout: 10m + # notifications: + # - type: discordWebhook + # enabled: true + # events: ["reply_posted", "responder_failed"] + # webhookUrl: \${CWS_DISCORD_WEBHOOK} + # username: code-work-spawner ${_YamlEmitter().write({'projects': projectsSection})} diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index f017440..b4908b7 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -90,6 +90,7 @@ class IssueAssistantConfigDocument { this.prompt, this.responders, this.capabilities, + this.notifications, this.commentTag, this.commentPrefixTemplate, this.commentFooterTemplate, @@ -102,6 +103,7 @@ class IssueAssistantConfigDocument { final String? prompt; final List? responders; final List? capabilities; + final List? notifications; final String? commentTag; final String? commentPrefixTemplate; final String? commentFooterTemplate; @@ -142,5 +144,45 @@ class CliResponderConfigDocument { Map toJson() => _$CliResponderConfigDocumentToJson(this); } +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class NotificationConfigDocument { + const NotificationConfigDocument({ + this.type, + this.enabled, + this.events, + this.webhookUrl, + this.username, + this.avatarUrl, + }); + + final NotificationType? type; + final bool? enabled; + final List? events; + final String? webhookUrl; + final String? username; + final String? avatarUrl; + + factory NotificationConfigDocument.fromJson(Map json) => + _$NotificationConfigDocumentFromJson(json); + + Map toJson() => _$NotificationConfigDocumentToJson(this); +} + @JsonEnum(fieldRename: FieldRename.snake) enum AssistantCapability { planning, bugVerification } + +enum NotificationType { discordWebhook } + +@JsonEnum(fieldRename: FieldRename.snake) +enum NotificationEvent { + mentionDetected, + replyPosted, + noReply, + responderFailed, +} diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index bab83c5..f7acadc 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -9,6 +9,7 @@ import 'cli_responder.dart'; import 'config.dart'; import 'database.dart'; import 'github_client.dart'; +import 'notifier.dart'; import 'workspace_manager.dart'; class IssueAssistantApp { @@ -21,6 +22,7 @@ class IssueAssistantApp { required this.githubClient, required this.responderRunner, required this.workspaceManager, + required this.notifierRegistryFactory, }); final AppConfig config; @@ -28,6 +30,8 @@ class IssueAssistantApp { final GitHubClient githubClient; final CliResponderRunner responderRunner; final WorkspaceManager workspaceManager; + final NotifierRegistry Function(ProjectConfig project) + notifierRegistryFactory; final Map _projectPollStates = {}; final Set> _activePolls = >{}; Timer? _schedulerTimer; @@ -47,6 +51,10 @@ class IssueAssistantApp { githubClient: GitHubClient(ghCommand: ghCommand), responderRunner: CliResponderRunner(), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), + notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs( + project.issueAssistant.notifications, + log: _logger.warning, + ), ); } @@ -352,6 +360,7 @@ class IssueAssistantApp { ProjectConfig project, GitHubIssueSummary issue, ) async { + final notifierRegistry = notifierRegistryFactory(project); final thread = await githubClient.fetchThread( repo: project.repoSlug, issue: issue, @@ -370,6 +379,20 @@ class IssueAssistantApp { 'evaluate issue=${project.key}#${issue.number} ' 'comments=${thread.comments.length} trigger=$trigger', ); + final baseNotificationContext = NotificationContext( + projectKey: project.key, + repo: project.repo, + issueNumber: issue.number, + issueTitle: thread.issue.title, + issueUrl: thread.issue.url, + trigger: trigger, + ); + if (trigger == 'mention') { + await notifierRegistry.notify( + event: NotificationEvent.mentionDetected, + context: baseNotificationContext, + ); + } final jobId = await database.createJob( projectKey: project.key, issueNumber: issue.number, @@ -495,6 +518,19 @@ class IssueAssistantApp { 'responder=${responder.id} issue=${project.key}#${issue.number} ' 'failed error=$error', ); + await notifierRegistry.notify( + event: NotificationEvent.responderFailed, + context: NotificationContext( + projectKey: project.key, + repo: project.repo, + issueNumber: issue.number, + issueTitle: thread.issue.title, + issueUrl: thread.issue.url, + trigger: trigger, + responderId: responder.id, + errorSummary: error.toString(), + ), + ); await database.addExecutionRun( jobId: jobId, responderId: responder.id, @@ -534,6 +570,21 @@ class IssueAssistantApp { ); await database.updateJobStatus(jobId, JobStatus.completed); _log('issue=${project.key}#${issue.number} completed decision=no_reply'); + await notifierRegistry.notify( + event: NotificationEvent.noReply, + context: NotificationContext( + projectKey: project.key, + repo: project.repo, + issueNumber: issue.number, + issueTitle: thread.issue.title, + issueUrl: thread.issue.url, + trigger: trigger, + mode: selectedResult.mode, + responderId: selectedResult.responderId, + decision: selectedResult.decision, + summary: selectedResult.summary, + ), + ); return; } @@ -570,6 +621,21 @@ class IssueAssistantApp { ); await database.updateJobStatus(jobId, JobStatus.completed); _log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}'); + await notifierRegistry.notify( + event: NotificationEvent.replyPosted, + context: NotificationContext( + projectKey: project.key, + repo: project.repo, + issueNumber: issue.number, + issueTitle: thread.issue.title, + issueUrl: thread.issue.url, + trigger: trigger, + mode: selectedResult.mode, + responderId: selectedResult.responderId, + decision: selectedResult.decision, + summary: selectedResult.summary, + ), + ); } String _buildPrompt({ diff --git a/lib/src/notifier.dart b/lib/src/notifier.dart new file mode 100644 index 0000000..36063e9 --- /dev/null +++ b/lib/src/notifier.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'config.dart'; + +typedef NotificationLogger = void Function(String message); + +class NotificationContext { + const NotificationContext({ + required this.projectKey, + required this.repo, + required this.issueNumber, + required this.issueTitle, + required this.issueUrl, + required this.trigger, + this.mode, + this.responderId, + this.decision, + this.summary, + this.errorSummary, + }); + + final String projectKey; + final String repo; + final int issueNumber; + final String issueTitle; + final String issueUrl; + final String trigger; + final String? mode; + final String? responderId; + final String? decision; + final String? summary; + final String? errorSummary; +} + +abstract interface class Notifier { + Future notify({ + required NotificationEvent event, + required NotificationContext context, + }); +} + +class NotifierRegistry { + NotifierRegistry({required this.notifiersByEvent, required this.log}); + + final Map> notifiersByEvent; + final NotificationLogger log; + + factory NotifierRegistry.fromConfigs( + List configs, { + required NotificationLogger log, + }) { + final notifiersByEvent = >{}; + for (final config in configs) { + if (!config.enabled) { + continue; + } + + final notifier = switch (config.type) { + NotificationType.discordWebhook => DiscordWebhookNotifier(config), + }; + for (final event in config.events) { + notifiersByEvent.putIfAbsent(event, () => []).add(notifier); + } + } + return NotifierRegistry(notifiersByEvent: notifiersByEvent, log: log); + } + + Future notify({ + required NotificationEvent event, + required NotificationContext context, + }) async { + final notifiers = notifiersByEvent[event]; + if (notifiers == null || notifiers.isEmpty) { + return; + } + + for (final notifier in notifiers) { + try { + await notifier.notify(event: event, context: context); + } catch (error) { + log('notification_failed event=${event.name} error=$error'); + } + } + } +} + +class DiscordWebhookNotifier implements Notifier { + DiscordWebhookNotifier(this.config, {HttpClient? httpClient}) + : _httpClient = httpClient ?? HttpClient(); + + final NotificationConfig config; + final HttpClient _httpClient; + + @override + Future notify({ + required NotificationEvent event, + required NotificationContext context, + }) async { + final webhookUrl = config.webhookUrl; + if (webhookUrl == null || webhookUrl.isEmpty) { + throw const FormatException( + 'discordWebhook notifier requires webhookUrl.', + ); + } + + final uri = Uri.parse(webhookUrl); + final request = await _httpClient.postUrl(uri); + request.headers.contentType = ContentType.json; + request.write( + jsonEncode({ + 'content': _formatDiscordMessage(event: event, context: context), + if (config.username != null && config.username!.isNotEmpty) + 'username': config.username, + if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty) + 'avatar_url': config.avatarUrl, + }), + ); + + final response = await request.close(); + final responseBody = await utf8.decoder.bind(response).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'Discord webhook returned ${response.statusCode}: $responseBody', + uri: uri, + ); + } + } + + String _formatDiscordMessage({ + required NotificationEvent event, + required NotificationContext context, + }) { + final headline = switch (event) { + NotificationEvent.mentionDetected => 'Mention detected', + NotificationEvent.replyPosted => 'Reply posted', + NotificationEvent.noReply => 'No reply', + NotificationEvent.responderFailed => 'Responder failed', + }; + + final details = [ + '**$headline**', + '`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}', + context.issueUrl, + 'repo: `${context.repo}`', + 'trigger: `${context.trigger}`', + if (context.mode != null) 'mode: `${context.mode}`', + if (context.responderId != null) 'responder: `${context.responderId}`', + if (context.decision != null) 'decision: `${context.decision}`', + if (context.summary != null && context.summary!.isNotEmpty) + 'summary: ${context.summary}', + if (context.errorSummary != null && context.errorSummary!.isNotEmpty) + 'error: ${context.errorSummary}', + ]; + return details.join('\n'); + } +} diff --git a/test/app_config_test.dart b/test/app_config_test.dart index 8ea3910..0f64964 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -181,6 +181,60 @@ projects: ]); }); + /// ```gherkin + /// Scenario: Merge notification defaults with project overrides + /// Given camelCase config with default notifications and a project-specific notification override + /// When loading the app config from disk + /// Then the project replaces inherited notifications with its own list + /// And notification event enums are parsed from snake_case values + /// ``` + test('Merge notification defaults with project overrides', () async { + // Given camelCase config with default notifications and a project-specific notification override. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-notification-config-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + notifications: + - type: discordWebhook + enabled: true + events: ["reply_posted"] + webhookUrl: https://default.example.test/webhook +projects: + sample: + repo: owner/sample + path: ${tempDir.path} + issueAssistant: + notifications: + - type: discordWebhook + enabled: true + events: ["responder_failed", "no_reply"] + webhookUrl: https://project.example.test/webhook + username: cws +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the project replaces inherited notifications with its own list. + expect( + config.projects['sample']!.issueAssistant.notifications, + hasLength(1), + ); + final notification = + config.projects['sample']!.issueAssistant.notifications.single; + expect(notification.webhookUrl, 'https://project.example.test/webhook'); + expect(notification.username, 'cws'); + + // And notification event enums are parsed from snake_case values. + expect(notification.events, { + NotificationEvent.responderFailed, + NotificationEvent.noReply, + }); + }); + /// ```gherkin /// Scenario: Load camelCase config with shared worktree root /// Given camelCase config with dataDir, worktreeDir, and project settings diff --git a/test/init_config_cli_test.dart b/test/init_config_cli_test.dart index 5ec3238..b4ef09f 100644 --- a/test/init_config_cli_test.dart +++ b/test/init_config_cli_test.dart @@ -37,6 +37,8 @@ void main() { final stdoutText = result.stdout as String; expect(stdoutText, contains('dataDir:')); expect(stdoutText, contains('# responders:')); + expect(stdoutText, contains('# notifications:')); + expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}')); expect(stdoutText, contains('projects:')); // And the printed config is valid when written to disk and loaded. diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index 02cb267..cdcad1e 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; @@ -777,6 +779,134 @@ projects: ); }); + /// ```gherkin + /// Scenario: Send Discord webhook notifications for mention and reply events + /// Given a temporary project checkout that can be used as the local repo path + /// And GitHub issue data containing an explicit assistant mention + /// And a responder that emits a planning reply + /// And a local webhook server that records notification requests + /// And an orchestrator-style config with a discord webhook notifier + /// When the app processes one polling cycle + /// Then the webhook server receives one mention_detected notification + /// And the webhook server receives one reply_posted notification + /// ``` + test( + 'Send Discord webhook notifications for mention and reply events', + () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-discord-notify-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And GitHub issue data containing an explicit assistant mention. + final ghScript = File(p.join(sandbox.path, 'gh')); + final issueData = [ + { + 'number': 61, + 'title': 'Need architecture help', + 'body': 'Please review the notifier design.', + 'state': 'open', + 'html_url': 'https://example.test/issues/61', + 'updated_at': '2026-04-04T17:10:00Z', + 'user': {'login': 'reporter'}, + 'labels': const [], + }, + ]; + final commentsData = [ + { + 'id': 610, + 'body': '@helper please review the notifier design', + 'html_url': 'https://example.test/issues/61#issuecomment-610', + 'created_at': '2026-04-04T17:10:00Z', + 'updated_at': '2026-04-04T17:10:00Z', + 'user': {'login': 'reporter'}, + }, + ]; + await writeFakeGhScript( + ghScript: ghScript, + issueListResponse: issueData, + commentResponses: {61: commentsData}, + postCommentIssueNumber: 61, + ); + + // 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': 'discord notification reply', + 'summary': 'posted planning advice', + }); + + // And a local webhook server that records notification requests. + final requests = >[]; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final request in server) { + final body = await utf8.decoder.bind(request).join(); + requests.add(jsonDecode(body) as Map); + request.response.statusCode = HttpStatus.noContent; + await request.response.close(); + } + }()); + + // And an orchestrator-style config with a discord webhook notifier. + 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} + notifications: + - type: discordWebhook + enabled: true + events: ["mention_detected", "reply_posted"] + webhookUrl: http://${server.address.host}:${server.port}/discord + username: code-work-spawner +projects: + sample: + 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, + ); + + // When the app processes one polling cycle. + await app.runOnce(); + await app.close(); + await server.close(force: true); + + // Then the webhook server receives one mention_detected notification. + expect(requests, hasLength(2)); + expect(requests.first['username'], 'code-work-spawner'); + expect( + requests.first['content'] as String, + contains('**Mention detected**'), + ); + + // And the webhook server receives one reply_posted notification. + expect( + requests.last['content'] as String, + contains('**Reply posted**'), + ); + expect( + requests.last['content'] as String, + contains('summary: posted planning advice'), + ); + }, + ); + /// ```gherkin /// Scenario: Process multiple issues concurrently up to the configured limit /// Given a temporary project checkout that can be used as the local repo path