forked from bkinnightskytw/code_work_spawner
feat: add desktop notifier
This commit is contained in:
parent
d691ecec15
commit
c1c6f4fba5
|
|
@ -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
|
It polls configured GitHub repositories with `gh`, reads issue threads, and
|
||||||
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
|
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
|
||||||
when to reply. It supports multi-repo config, responder fallback chains, and
|
when to reply. It supports multi-repo config, responder fallback chains, and
|
||||||
optional Discord webhook notifications.
|
optional Discord webhook and desktop notifications.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Poll GitHub issues across multiple repositories
|
- Poll GitHub issues across multiple repositories
|
||||||
- Trigger replies from mentions or meaningful thread updates
|
- Trigger replies from mentions or meaningful thread updates
|
||||||
- Run planning and bug-verification flows in isolated worktrees
|
- 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
|
## Requirements
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ defaults:
|
||||||
events: ["mention_detected", "reply_posted", "responder_failed"]
|
events: ["mention_detected", "reply_posted", "responder_failed"]
|
||||||
webhookUrl: ${CWS_DISCORD_WEBHOOK}
|
webhookUrl: ${CWS_DISCORD_WEBHOOK}
|
||||||
username: code-work-spawner
|
username: code-work-spawner
|
||||||
|
# Optional desktop notifications for local polling runs.
|
||||||
|
- type: desktop
|
||||||
|
enabled: false
|
||||||
|
events: ["mention_detected", "responder_failed"]
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
code-work-spawner:
|
code-work-spawner:
|
||||||
|
|
|
||||||
|
|
@ -624,6 +624,9 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')}
|
||||||
# events: ["reply_posted", "responder_failed"]
|
# events: ["reply_posted", "responder_failed"]
|
||||||
# webhookUrl: \${CWS_DISCORD_WEBHOOK}
|
# webhookUrl: \${CWS_DISCORD_WEBHOOK}
|
||||||
# username: code-work-spawner
|
# username: code-work-spawner
|
||||||
|
# - type: desktop
|
||||||
|
# enabled: true
|
||||||
|
# events: ["mention_detected", "responder_failed"]
|
||||||
|
|
||||||
${_YamlEmitter().write({'projects': projectsSection})}
|
${_YamlEmitter().write({'projects': projectsSection})}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ class NotificationConfigDocument {
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum AssistantCapability { planning, bugVerification }
|
enum AssistantCapability { planning, bugVerification }
|
||||||
|
|
||||||
enum NotificationType { discordWebhook }
|
enum NotificationType { discordWebhook, desktop }
|
||||||
|
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum NotificationEvent {
|
enum NotificationEvent {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import 'dart:io';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
|
|
||||||
typedef NotificationLogger = void Function(String message);
|
typedef NotificationLogger = void Function(String message);
|
||||||
|
typedef DesktopNotificationCommandRunner =
|
||||||
|
Future<ProcessResult> Function({
|
||||||
|
required String executable,
|
||||||
|
required List<String> arguments,
|
||||||
|
});
|
||||||
|
|
||||||
class NotificationContext {
|
class NotificationContext {
|
||||||
const 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 {
|
class NotifierRegistry {
|
||||||
NotifierRegistry({required this.notifiersByEvent, required this.log});
|
NotifierRegistry({required this.notifiersByEvent, required this.log});
|
||||||
|
|
||||||
|
|
@ -58,6 +75,7 @@ class NotifierRegistry {
|
||||||
|
|
||||||
final notifier = switch (config.type) {
|
final notifier = switch (config.type) {
|
||||||
NotificationType.discordWebhook => DiscordWebhookNotifier(config),
|
NotificationType.discordWebhook => DiscordWebhookNotifier(config),
|
||||||
|
NotificationType.desktop => DesktopNotifier(config),
|
||||||
};
|
};
|
||||||
for (final event in config.events) {
|
for (final event in config.events) {
|
||||||
notifiersByEvent.putIfAbsent(event, () => <Notifier>[]).add(notifier);
|
notifiersByEvent.putIfAbsent(event, () => <Notifier>[]).add(notifier);
|
||||||
|
|
@ -109,7 +127,10 @@ class DiscordWebhookNotifier implements Notifier {
|
||||||
request.headers.contentType = ContentType.json;
|
request.headers.contentType = ContentType.json;
|
||||||
request.write(
|
request.write(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'content': _formatDiscordMessage(event: event, context: context),
|
'content': formatDiscordNotificationMessage(
|
||||||
|
event: event,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
if (config.username != null && config.username!.isNotEmpty)
|
if (config.username != null && config.username!.isNotEmpty)
|
||||||
'username': config.username,
|
'username': config.username,
|
||||||
if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty)
|
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<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 NotificationEvent event,
|
||||||
required NotificationContext context,
|
required NotificationContext context,
|
||||||
}) {
|
}) {
|
||||||
final headline = switch (event) {
|
final title = 'code_work_spawner: ${notificationHeadline(event)}';
|
||||||
NotificationEvent.mentionDetected => 'Mention detected',
|
final body = formatDesktopNotificationMessage(
|
||||||
NotificationEvent.replyPosted => 'Reply posted',
|
event: event,
|
||||||
NotificationEvent.noReply => 'No reply',
|
context: context,
|
||||||
NotificationEvent.responderFailed => 'Responder failed',
|
);
|
||||||
|
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 = <String>[
|
|
||||||
'**$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<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>[
|
||||||
|
'**${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 = @"
|
||||||
|
<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
|
/// Given camelCase config with default notifications and a project-specific notification override
|
||||||
/// When loading the app config from disk
|
/// When loading the app config from disk
|
||||||
/// Then the project replaces inherited notifications with its own list
|
/// 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 {
|
test('Merge notification defaults with project overrides', () async {
|
||||||
// Given camelCase config with default notifications and a project-specific notification override.
|
// 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.webhookUrl, 'https://project.example.test/webhook');
|
||||||
expect(notification.username, 'cws');
|
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, {
|
expect(notification.events, {
|
||||||
NotificationEvent.responderFailed,
|
NotificationEvent.responderFailed,
|
||||||
NotificationEvent.noReply,
|
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
|
/// ```gherkin
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ void main() {
|
||||||
expect(stdoutText, contains('# responders:'));
|
expect(stdoutText, contains('# responders:'));
|
||||||
expect(stdoutText, contains('# notifications:'));
|
expect(stdoutText, contains('# notifications:'));
|
||||||
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
||||||
|
expect(stdoutText, contains('# - type: desktop'));
|
||||||
expect(stdoutText, contains('projects:'));
|
expect(stdoutText, contains('projects:'));
|
||||||
|
|
||||||
// And the printed config is valid when written to disk and loaded.
|
// 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