forked from bkinnightskytw/code_work_spawner
feat: add Discord issue assistant notifications
This commit is contained in:
parent
200992d67b
commit
c9fd672a04
|
|
@ -11,6 +11,8 @@ issue comments, and uses configured CLI responders such as `codex`,
|
|||
useful
|
||||
|
||||
It supports multi-repo configuration, responder fallback chains.
|
||||
It can also emit non-fatal Discord webhook notifications for issue assistant
|
||||
events such as mentions, replies, no-reply decisions, and responder failures.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ It supports multi-repo configuration, responder fallback chains.
|
|||
- Planning / architecture guidance in issue threads
|
||||
- Bug verification flows in isolated temporary git worktrees
|
||||
- CLI-based responder fallback chain
|
||||
- Optional Discord webhook notifications
|
||||
- Durable state with `drift` + SQLite
|
||||
- BDD-style tests using `package:test` with Given / When / Then wording
|
||||
|
||||
|
|
@ -34,6 +37,10 @@ It supports multi-repo configuration, responder fallback chains.
|
|||
|
||||
Create a local `agent-orchestrator.yaml` and follow [`/examples/README.md`](examples/README.md) as a starting point.
|
||||
|
||||
Notification config lives under `defaults.issueAssistant.notifications` or
|
||||
`projects.<key>.issueAssistant.notifications`. Supported events are
|
||||
`mention_detected`, `reply_posted`, `no_reply`, and `responder_failed`.
|
||||
|
||||
Generate a starter config:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<CliResponderConfig> responders;
|
||||
final Set<AssistantCapability> capabilities;
|
||||
final List<NotificationConfig> 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<NotificationEvent> 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>[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 <NotificationConfig>[],
|
||||
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<String, dynamic> root) {
|
||||
_assertCamelCaseMap(root, scope: '');
|
||||
|
||||
|
|
@ -438,6 +499,17 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
|||
}
|
||||
}
|
||||
}
|
||||
final notifications = issueAssistant['notifications'];
|
||||
if (notifications is List) {
|
||||
for (final notification in notifications) {
|
||||
if (notification is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
notification,
|
||||
scope: 'defaults.issueAssistant.notifications',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -466,6 +538,17 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
|||
}
|
||||
}
|
||||
}
|
||||
final notifications = issueAssistant['notifications'];
|
||||
if (notifications is List) {
|
||||
for (final notification in notifications) {
|
||||
if (notification is Map<String, dynamic>) {
|
||||
_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})}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CliResponderConfigDocument>? responders;
|
||||
final List<AssistantCapability>? capabilities;
|
||||
final List<NotificationConfigDocument>? notifications;
|
||||
final String? commentTag;
|
||||
final String? commentPrefixTemplate;
|
||||
final String? commentFooterTemplate;
|
||||
|
|
@ -142,5 +144,45 @@ class CliResponderConfigDocument {
|
|||
Map<String, dynamic> 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<NotificationEvent>? events;
|
||||
final String? webhookUrl;
|
||||
final String? username;
|
||||
final String? avatarUrl;
|
||||
|
||||
factory NotificationConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$NotificationConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum AssistantCapability { planning, bugVerification }
|
||||
|
||||
enum NotificationType { discordWebhook }
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum NotificationEvent {
|
||||
mentionDetected,
|
||||
replyPosted,
|
||||
noReply,
|
||||
responderFailed,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, _ProjectPollState> _projectPollStates = {};
|
||||
final Set<Future<void>> _activePolls = <Future<void>>{};
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<void> notify({
|
||||
required NotificationEvent event,
|
||||
required NotificationContext context,
|
||||
});
|
||||
}
|
||||
|
||||
class NotifierRegistry {
|
||||
NotifierRegistry({required this.notifiersByEvent, required this.log});
|
||||
|
||||
final Map<NotificationEvent, List<Notifier>> notifiersByEvent;
|
||||
final NotificationLogger log;
|
||||
|
||||
factory NotifierRegistry.fromConfigs(
|
||||
List<NotificationConfig> configs, {
|
||||
required NotificationLogger log,
|
||||
}) {
|
||||
final notifiersByEvent = <NotificationEvent, List<Notifier>>{};
|
||||
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, () => <Notifier>[]).add(notifier);
|
||||
}
|
||||
}
|
||||
return NotifierRegistry(notifiersByEvent: notifiersByEvent, log: log);
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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 = <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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>);
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue