diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index ba9d5fe..0a54efe 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -2,6 +2,8 @@ name: unittest on: push: + branches: + - main pull_request: jobs: diff --git a/README.md b/README.md index 83262c4..b7cbd79 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ GitHub issue thread assistant with an Agent Orchestrator style YAML config. 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. +optional Discord webhook and desktop notifications. ## Highlights - 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 +- Send optional Discord or desktop notifications for assistant events ## Requirements diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index a283f76..50340be 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -30,13 +30,17 @@ 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 + # 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 + # # Optional desktop notifications for local polling runs. + # - type: desktop + # enabled: false + # events: ["mention_detected", "responder_failed"] projects: code-work-spawner: diff --git a/lib/src/config.dart b/lib/src/config.dart index dad7522..093c9d1 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -624,6 +624,9 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')} # events: ["reply_posted", "responder_failed"] # webhookUrl: \${CWS_DISCORD_WEBHOOK} # username: code-work-spawner + # - type: desktop + # enabled: true + # events: ["mention_detected", "responder_failed"] ${_YamlEmitter().write({'projects': projectsSection})} diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index b4908b7..281ccf1 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -177,7 +177,7 @@ class NotificationConfigDocument { @JsonEnum(fieldRename: FieldRename.snake) enum AssistantCapability { planning, bugVerification } -enum NotificationType { discordWebhook } +enum NotificationType { discordWebhook, desktop } @JsonEnum(fieldRename: FieldRename.snake) enum NotificationEvent { diff --git a/lib/src/notifier.dart b/lib/src/notifier.dart index 36063e9..b52e101 100644 --- a/lib/src/notifier.dart +++ b/lib/src/notifier.dart @@ -4,6 +4,11 @@ import 'dart:io'; import 'config.dart'; typedef NotificationLogger = void Function(String message); +typedef DesktopNotificationCommandRunner = + Future Function({ + required String executable, + required List arguments, + }); class NotificationContext { const NotificationContext({ @@ -40,6 +45,18 @@ abstract interface class Notifier { }); } +enum DesktopNotificationPlatform { linux, macos, windows, unsupported } + +class DesktopNotificationCommand { + const DesktopNotificationCommand({ + required this.executable, + required this.arguments, + }); + + final String executable; + final List arguments; +} + class NotifierRegistry { NotifierRegistry({required this.notifiersByEvent, required this.log}); @@ -58,6 +75,7 @@ class NotifierRegistry { final notifier = switch (config.type) { NotificationType.discordWebhook => DiscordWebhookNotifier(config), + NotificationType.desktop => DesktopNotifier(config), }; for (final event in config.events) { notifiersByEvent.putIfAbsent(event, () => []).add(notifier); @@ -109,7 +127,10 @@ class DiscordWebhookNotifier implements Notifier { request.headers.contentType = ContentType.json; request.write( jsonEncode({ - 'content': _formatDiscordMessage(event: event, context: context), + 'content': formatDiscordNotificationMessage( + event: event, + context: context, + ), if (config.username != null && config.username!.isNotEmpty) 'username': config.username, if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty) @@ -126,32 +147,188 @@ class DiscordWebhookNotifier implements Notifier { ); } } +} - String _formatDiscordMessage({ +class DesktopNotifier implements Notifier { + DesktopNotifier( + this.config, { + DesktopNotificationPlatform? platform, + DesktopNotificationCommandRunner? commandRunner, + }) : _platform = platform ?? detectDesktopNotificationPlatform(), + _commandRunner = commandRunner ?? _runDesktopNotificationCommand; + + final NotificationConfig config; + final DesktopNotificationPlatform _platform; + final DesktopNotificationCommandRunner _commandRunner; + + @override + Future notify({ + required NotificationEvent event, + required NotificationContext context, + }) async { + final command = _buildCommand(event: event, context: context); + final result = await _commandRunner( + executable: command.executable, + arguments: command.arguments, + ); + if (result.exitCode != 0) { + throw ProcessException( + command.executable, + command.arguments, + '${result.stdout}\n${result.stderr}', + result.exitCode, + ); + } + } + + DesktopNotificationCommand _buildCommand({ 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 title = 'code_work_spawner: ${notificationHeadline(event)}'; + final body = formatDesktopNotificationMessage( + event: event, + context: context, + ); + return switch (_platform) { + DesktopNotificationPlatform.linux => DesktopNotificationCommand( + executable: 'notify-send', + arguments: [title, body], + ), + DesktopNotificationPlatform.macos => DesktopNotificationCommand( + executable: 'osascript', + arguments: [ + '-e', + 'display notification ${_appleScriptLiteral(body)} ' + 'with title ${_appleScriptLiteral(title)}', + ], + ), + DesktopNotificationPlatform.windows => DesktopNotificationCommand( + executable: 'powershell', + arguments: [ + '-NoProfile', + '-Command', + _buildWindowsToastCommand(title: title, body: body), + ], + ), + DesktopNotificationPlatform.unsupported => throw UnsupportedError( + 'Desktop notifications are not supported on this platform.', + ), }; - - 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'); } } + +DesktopNotificationPlatform detectDesktopNotificationPlatform() { + if (Platform.isLinux) { + return DesktopNotificationPlatform.linux; + } + if (Platform.isMacOS) { + return DesktopNotificationPlatform.macos; + } + if (Platform.isWindows) { + return DesktopNotificationPlatform.windows; + } + return DesktopNotificationPlatform.unsupported; +} + +Future _runDesktopNotificationCommand({ + required String executable, + required List arguments, +}) { + return Process.run(executable, arguments); +} + +String notificationHeadline(NotificationEvent event) { + return switch (event) { + NotificationEvent.mentionDetected => 'Mention detected', + NotificationEvent.replyPosted => 'Reply posted', + NotificationEvent.noReply => 'No reply', + NotificationEvent.responderFailed => 'Responder failed', + }; +} + +List _notificationDetailLines(NotificationContext context) { + return [ + '${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}', + ]; +} + +String formatDesktopNotificationMessage({ + required NotificationEvent event, + required NotificationContext context, +}) { + return '${notificationHeadline(event)}\n${_notificationDetailLines(context).join('\n')}'; +} + +String formatDiscordNotificationMessage({ + required NotificationEvent event, + required NotificationContext context, +}) { + final details = [ + '**${notificationHeadline(event)}**', + '`${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'); +} + +String _appleScriptLiteral(String value) { + return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"'; +} + +String _buildWindowsToastCommand({ + required String title, + required String body, +}) { + final escapedTitle = _escapeXmlText(title); + final escapedBody = _escapeXmlText(body); + return r''' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null +$template = @" + + + + ''' + + escapedTitle + + r''' + ''' + + escapedBody + + r''' + + + +"@ +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($template) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('code_work_spawner') +$notifier.Show($toast) +'''; +} + +String _escapeXmlText(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/test/app_config_test.dart b/test/app_config_test.dart index 0f64964..ffc1952 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -186,7 +186,7 @@ projects: /// 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 + /// And notification enums are parsed from their config values /// ``` test('Merge notification defaults with project overrides', () async { // Given camelCase config with default notifications and a project-specific notification override. @@ -228,11 +228,53 @@ projects: expect(notification.webhookUrl, 'https://project.example.test/webhook'); expect(notification.username, 'cws'); - // And notification event enums are parsed from snake_case values. + // And notification enums are parsed from their config values. expect(notification.events, { NotificationEvent.responderFailed, NotificationEvent.noReply, }); + expect(notification.type, NotificationType.discordWebhook); + }); + + /// ```gherkin + /// Scenario: Load a desktop notifier from config + /// Given camelCase config with a desktop notifier + /// When loading the app config from disk + /// Then the notifier type is parsed from the config value + /// And the configured events are available to the app + /// ``` + test('Load a desktop notifier from config', () async { + // Given camelCase config with a desktop notifier. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-desktop-notification-config-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + notifications: + - type: desktop + enabled: true + events: ["mention_detected", "reply_posted"] +projects: + sample: + repo: owner/sample + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the notifier type is parsed from the config value. + final notification = + config.projects['sample']!.issueAssistant.notifications.single; + expect(notification.type, NotificationType.desktop); + + // And the configured events are available to the app. + expect(notification.events, { + NotificationEvent.mentionDetected, + NotificationEvent.replyPosted, + }); }); /// ```gherkin diff --git a/test/init_config_cli_test.dart b/test/init_config_cli_test.dart index b4ef09f..32b79f4 100644 --- a/test/init_config_cli_test.dart +++ b/test/init_config_cli_test.dart @@ -39,6 +39,7 @@ void main() { expect(stdoutText, contains('# responders:')); expect(stdoutText, contains('# notifications:')); expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}')); + expect(stdoutText, contains('# - type: desktop')); expect(stdoutText, contains('projects:')); // And the printed config is valid when written to disk and loaded. diff --git a/test/notifier_test.dart b/test/notifier_test.dart new file mode 100644 index 0000000..0991604 --- /dev/null +++ b/test/notifier_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Desktop notifier + /// + /// As a maintainer running code_work_spawner locally + /// I want desktop notifications for assistant events + /// So that I can notice mentions and failures without watching the terminal + /// ``` + group('DesktopNotifier', () { + /// ```gherkin + /// Scenario: Send a Linux desktop notification for a posted reply + /// Given a desktop notifier configured for Linux + /// When it sends a reply_posted notification + /// Then it invokes notify-send with a readable title and body + /// ``` + test('Send a Linux desktop notification for a posted reply', () async { + // Given a desktop notifier configured for Linux. + String? capturedExecutable; + List? capturedArguments; + final notifier = DesktopNotifier( + NotificationConfig( + type: NotificationType.desktop, + enabled: true, + events: {NotificationEvent.replyPosted}, + webhookUrl: null, + username: null, + avatarUrl: null, + ), + platform: DesktopNotificationPlatform.linux, + commandRunner: ({required executable, required arguments}) async { + capturedExecutable = executable; + capturedArguments = List.from(arguments); + return ProcessResult(0, 0, '', ''); + }, + ); + const context = NotificationContext( + projectKey: 'sample', + repo: 'owner/sample', + issueNumber: 61, + issueTitle: 'Need architecture help', + issueUrl: 'https://example.test/issues/61', + trigger: '@helper', + mode: 'planning', + summary: 'posted planning advice', + ); + + // When it sends a reply_posted notification. + await notifier.notify( + event: NotificationEvent.replyPosted, + context: context, + ); + + // Then it invokes notify-send with a readable title and body. + expect(capturedExecutable, 'notify-send'); + expect(capturedArguments, hasLength(2)); + expect(capturedArguments!.first, 'code_work_spawner: Reply posted'); + expect( + capturedArguments!.last, + contains('sample#61 Need architecture help'), + ); + expect( + capturedArguments!.last, + contains('summary: posted planning advice'), + ); + }); + + /// ```gherkin + /// Scenario: Reject unsupported desktop platforms + /// Given a desktop notifier configured for an unsupported platform + /// When it sends a mention_detected notification + /// Then it throws an UnsupportedError + /// ``` + test('Reject unsupported desktop platforms', () async { + // Given a desktop notifier configured for an unsupported platform. + final notifier = DesktopNotifier( + NotificationConfig( + type: NotificationType.desktop, + enabled: true, + events: {NotificationEvent.mentionDetected}, + webhookUrl: null, + username: null, + avatarUrl: null, + ), + platform: DesktopNotificationPlatform.unsupported, + ); + const context = NotificationContext( + projectKey: 'sample', + repo: 'owner/sample', + issueNumber: 61, + issueTitle: 'Need architecture help', + issueUrl: 'https://example.test/issues/61', + trigger: '@helper', + ); + + // When it sends a mention_detected notification. + final future = notifier.notify( + event: NotificationEvent.mentionDetected, + context: context, + ); + + // Then it throws an UnsupportedError. + await expectLater(future, throwsA(isA())); + }); + }); +}