Merge pull request #5 from existedinnettw/feat/desktop-notifier
feat: add desktop notifier
This commit is contained in:
commit
86ca0fc4a6
|
|
@ -2,6 +2,8 @@ name: unittest
|
|||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import 'dart:io';
|
|||
import 'config.dart';
|
||||
|
||||
typedef NotificationLogger = void Function(String message);
|
||||
typedef DesktopNotificationCommandRunner =
|
||||
Future<ProcessResult> Function({
|
||||
required String executable,
|
||||
required List<String> 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<String> 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, () => <Notifier>[]).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,20 +147,135 @@ 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<void> 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) {
|
||||
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.',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
DesktopNotificationPlatform detectDesktopNotificationPlatform() {
|
||||
if (Platform.isLinux) {
|
||||
return DesktopNotificationPlatform.linux;
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
return DesktopNotificationPlatform.macos;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return DesktopNotificationPlatform.windows;
|
||||
}
|
||||
return DesktopNotificationPlatform.unsupported;
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runDesktopNotificationCommand({
|
||||
required String executable,
|
||||
required List<String> 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<String> _notificationDetailLines(NotificationContext context) {
|
||||
return <String>[
|
||||
'${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 = <String>[
|
||||
'**$headline**',
|
||||
'**${notificationHeadline(event)}**',
|
||||
'`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}',
|
||||
context.issueUrl,
|
||||
'repo: `${context.repo}`',
|
||||
|
|
@ -154,4 +290,45 @@ class DiscordWebhookNotifier implements Notifier {
|
|||
];
|
||||
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 = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
<text>''' +
|
||||
escapedTitle +
|
||||
r'''</text>
|
||||
<text>''' +
|
||||
escapedBody +
|
||||
r'''</text>
|
||||
</binding>
|
||||
</visual>
|
||||
</toast>
|
||||
"@
|
||||
$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('>', '>');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String>? 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<String>.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<UnsupportedError>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue