forked from bkinnightskytw/code_work_spawner
Merge pull request #4 from existedinnettw/feat/discord-notifications
This commit is contained in:
commit
d691ecec15
38
README.md
38
README.md
|
|
@ -2,26 +2,17 @@
|
||||||
|
|
||||||
GitHub issue thread assistant with an Agent Orchestrator style YAML config.
|
GitHub issue thread assistant with an Agent Orchestrator style YAML config.
|
||||||
|
|
||||||
The app polls configured GitHub repositories with `gh`, reads issue threads and
|
It polls configured GitHub repositories with `gh`, reads issue threads, and
|
||||||
issue comments, and uses configured CLI responders such as `codex`,
|
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
|
||||||
`opencode`, or `copilot-cli` to reply when:
|
when to reply. It supports multi-repo config, responder fallback chains, and
|
||||||
|
optional Discord webhook notifications.
|
||||||
|
|
||||||
- a user explicitly mentions the assistant
|
## Highlights
|
||||||
- a new comment changes the thread enough that the responder decides a reply is
|
|
||||||
useful
|
|
||||||
|
|
||||||
It supports multi-repo configuration, responder fallback chains.
|
- Poll GitHub issues across multiple repositories
|
||||||
|
- Trigger replies from mentions or meaningful thread updates
|
||||||
## Features
|
- Run planning and bug-verification flows in isolated worktrees
|
||||||
|
- Send optional Discord notifications for assistant events
|
||||||
- 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
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -32,7 +23,9 @@ It supports multi-repo configuration, responder fallback chains.
|
||||||
|
|
||||||
## Config
|
## 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:
|
Generate a starter config:
|
||||||
|
|
||||||
|
|
@ -79,13 +72,6 @@ dart analyze
|
||||||
dart test
|
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
|
## ref
|
||||||
|
|
||||||
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)
|
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`.
|
The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`.
|
||||||
|
|
||||||
Project entries can override `issueAssistant` fields when a repo needs a
|
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
|
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
|
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
|
`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty
|
||||||
string to disable that section.
|
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
|
Start from [`examples/simple-github.yaml`](simple-github.yaml). This
|
||||||
repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
||||||
bug-verification worktrees.
|
bug-verification worktrees.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ defaults:
|
||||||
# - id: opencode
|
# - id: opencode
|
||||||
# command: opencode
|
# command: opencode
|
||||||
# timeout: 10m
|
# 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:
|
projects:
|
||||||
code-work-spawner:
|
code-work-spawner:
|
||||||
|
|
@ -37,3 +44,7 @@ projects:
|
||||||
path: ~/code_work_spawner
|
path: ~/code_work_spawner
|
||||||
defaultBranch: main
|
defaultBranch: main
|
||||||
# sessionPrefix: app
|
# sessionPrefix: app
|
||||||
|
# issueAssistant:
|
||||||
|
# notifications:
|
||||||
|
# - type: discordWebhook
|
||||||
|
# events: ["no_reply"]
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ export 'src/config.dart';
|
||||||
export 'src/database.dart';
|
export 'src/database.dart';
|
||||||
export 'src/github_client.dart';
|
export 'src/github_client.dart';
|
||||||
export 'src/issue_assistant_app.dart';
|
export 'src/issue_assistant_app.dart';
|
||||||
|
export 'src/notifier.dart';
|
||||||
export 'src/workspace_manager.dart';
|
export 'src/workspace_manager.dart';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
import 'comment_templates.dart';
|
import 'comment_templates.dart';
|
||||||
import 'config_schema.dart';
|
import 'config_schema.dart';
|
||||||
export 'config_schema.dart' show AssistantCapability;
|
export 'config_schema.dart'
|
||||||
|
show
|
||||||
|
AssistantCapability,
|
||||||
|
NotificationConfigDocument,
|
||||||
|
NotificationEvent,
|
||||||
|
NotificationType;
|
||||||
|
|
||||||
const String defaultIssueAssistantPrompt = '''
|
const String defaultIssueAssistantPrompt = '''
|
||||||
You are a GitHub issue thread assistant.
|
You are a GitHub issue thread assistant.
|
||||||
|
|
@ -212,6 +217,7 @@ class IssueAssistantConfig {
|
||||||
required this.prompt,
|
required this.prompt,
|
||||||
required this.responders,
|
required this.responders,
|
||||||
required this.capabilities,
|
required this.capabilities,
|
||||||
|
required this.notifications,
|
||||||
required this.commentTag,
|
required this.commentTag,
|
||||||
required this.commentPrefixTemplate,
|
required this.commentPrefixTemplate,
|
||||||
required this.commentFooterTemplate,
|
required this.commentFooterTemplate,
|
||||||
|
|
@ -224,6 +230,7 @@ class IssueAssistantConfig {
|
||||||
final String prompt;
|
final String prompt;
|
||||||
final List<CliResponderConfig> responders;
|
final List<CliResponderConfig> responders;
|
||||||
final Set<AssistantCapability> capabilities;
|
final Set<AssistantCapability> capabilities;
|
||||||
|
final List<NotificationConfig> notifications;
|
||||||
final String commentTag;
|
final String commentTag;
|
||||||
final String commentPrefixTemplate;
|
final String commentPrefixTemplate;
|
||||||
final String commentFooterTemplate;
|
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 {
|
class _IssueAssistantConfigResolver {
|
||||||
const _IssueAssistantConfigResolver();
|
const _IssueAssistantConfigResolver();
|
||||||
|
|
||||||
|
|
@ -296,6 +340,7 @@ class _IssueAssistantConfigResolver {
|
||||||
required IssueAssistantConfigDocument? overlay,
|
required IssueAssistantConfigDocument? overlay,
|
||||||
}) {
|
}) {
|
||||||
final responders = overlay?.responders;
|
final responders = overlay?.responders;
|
||||||
|
final notifications = overlay?.notifications;
|
||||||
return IssueAssistantConfig(
|
return IssueAssistantConfig(
|
||||||
enabled: overlay?.enabled ?? base?.enabled ?? true,
|
enabled: overlay?.enabled ?? base?.enabled ?? true,
|
||||||
pollInterval: overlay?.pollInterval != null
|
pollInterval: overlay?.pollInterval != null
|
||||||
|
|
@ -321,6 +366,11 @@ class _IssueAssistantConfigResolver {
|
||||||
AssistantCapability.planning,
|
AssistantCapability.planning,
|
||||||
AssistantCapability.bugVerification,
|
AssistantCapability.bugVerification,
|
||||||
},
|
},
|
||||||
|
notifications: notifications != null
|
||||||
|
? notifications
|
||||||
|
.map(NotificationConfig.fromDocument)
|
||||||
|
.toList(growable: false)
|
||||||
|
: base?.notifications ?? const <NotificationConfig>[],
|
||||||
commentTag:
|
commentTag:
|
||||||
overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner',
|
overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner',
|
||||||
commentPrefixTemplate:
|
commentPrefixTemplate:
|
||||||
|
|
@ -419,6 +469,17 @@ String? _expandOptionalPath(String? path) {
|
||||||
return _expandHome(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) {
|
void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
||||||
_assertCamelCaseMap(root, scope: '');
|
_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
|
# - id: opencode
|
||||||
# command: opencode
|
# command: opencode
|
||||||
# timeout: 10m
|
# timeout: 10m
|
||||||
|
# notifications:
|
||||||
|
# - type: discordWebhook
|
||||||
|
# enabled: true
|
||||||
|
# events: ["reply_posted", "responder_failed"]
|
||||||
|
# webhookUrl: \${CWS_DISCORD_WEBHOOK}
|
||||||
|
# username: code-work-spawner
|
||||||
|
|
||||||
${_YamlEmitter().write({'projects': projectsSection})}
|
${_YamlEmitter().write({'projects': projectsSection})}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ class IssueAssistantConfigDocument {
|
||||||
this.prompt,
|
this.prompt,
|
||||||
this.responders,
|
this.responders,
|
||||||
this.capabilities,
|
this.capabilities,
|
||||||
|
this.notifications,
|
||||||
this.commentTag,
|
this.commentTag,
|
||||||
this.commentPrefixTemplate,
|
this.commentPrefixTemplate,
|
||||||
this.commentFooterTemplate,
|
this.commentFooterTemplate,
|
||||||
|
|
@ -102,6 +103,7 @@ class IssueAssistantConfigDocument {
|
||||||
final String? prompt;
|
final String? prompt;
|
||||||
final List<CliResponderConfigDocument>? responders;
|
final List<CliResponderConfigDocument>? responders;
|
||||||
final List<AssistantCapability>? capabilities;
|
final List<AssistantCapability>? capabilities;
|
||||||
|
final List<NotificationConfigDocument>? notifications;
|
||||||
final String? commentTag;
|
final String? commentTag;
|
||||||
final String? commentPrefixTemplate;
|
final String? commentPrefixTemplate;
|
||||||
final String? commentFooterTemplate;
|
final String? commentFooterTemplate;
|
||||||
|
|
@ -142,5 +144,45 @@ class CliResponderConfigDocument {
|
||||||
Map<String, dynamic> toJson() => _$CliResponderConfigDocumentToJson(this);
|
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)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum AssistantCapability { planning, bugVerification }
|
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 'config.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import 'github_client.dart';
|
import 'github_client.dart';
|
||||||
|
import 'notifier.dart';
|
||||||
import 'workspace_manager.dart';
|
import 'workspace_manager.dart';
|
||||||
|
|
||||||
class IssueAssistantApp {
|
class IssueAssistantApp {
|
||||||
|
|
@ -21,6 +22,7 @@ class IssueAssistantApp {
|
||||||
required this.githubClient,
|
required this.githubClient,
|
||||||
required this.responderRunner,
|
required this.responderRunner,
|
||||||
required this.workspaceManager,
|
required this.workspaceManager,
|
||||||
|
required this.notifierRegistryFactory,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppConfig config;
|
final AppConfig config;
|
||||||
|
|
@ -28,6 +30,8 @@ class IssueAssistantApp {
|
||||||
final GitHubClient githubClient;
|
final GitHubClient githubClient;
|
||||||
final CliResponderRunner responderRunner;
|
final CliResponderRunner responderRunner;
|
||||||
final WorkspaceManager workspaceManager;
|
final WorkspaceManager workspaceManager;
|
||||||
|
final NotifierRegistry Function(ProjectConfig project)
|
||||||
|
notifierRegistryFactory;
|
||||||
final Map<String, _ProjectPollState> _projectPollStates = {};
|
final Map<String, _ProjectPollState> _projectPollStates = {};
|
||||||
final Set<Future<void>> _activePolls = <Future<void>>{};
|
final Set<Future<void>> _activePolls = <Future<void>>{};
|
||||||
Timer? _schedulerTimer;
|
Timer? _schedulerTimer;
|
||||||
|
|
@ -47,6 +51,10 @@ class IssueAssistantApp {
|
||||||
githubClient: GitHubClient(ghCommand: ghCommand),
|
githubClient: GitHubClient(ghCommand: ghCommand),
|
||||||
responderRunner: CliResponderRunner(),
|
responderRunner: CliResponderRunner(),
|
||||||
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||||
|
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
|
||||||
|
project.issueAssistant.notifications,
|
||||||
|
log: _logger.warning,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,6 +360,7 @@ class IssueAssistantApp {
|
||||||
ProjectConfig project,
|
ProjectConfig project,
|
||||||
GitHubIssueSummary issue,
|
GitHubIssueSummary issue,
|
||||||
) async {
|
) async {
|
||||||
|
final notifierRegistry = notifierRegistryFactory(project);
|
||||||
final thread = await githubClient.fetchThread(
|
final thread = await githubClient.fetchThread(
|
||||||
repo: project.repoSlug,
|
repo: project.repoSlug,
|
||||||
issue: issue,
|
issue: issue,
|
||||||
|
|
@ -370,6 +379,20 @@ class IssueAssistantApp {
|
||||||
'evaluate issue=${project.key}#${issue.number} '
|
'evaluate issue=${project.key}#${issue.number} '
|
||||||
'comments=${thread.comments.length} trigger=$trigger',
|
'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(
|
final jobId = await database.createJob(
|
||||||
projectKey: project.key,
|
projectKey: project.key,
|
||||||
issueNumber: issue.number,
|
issueNumber: issue.number,
|
||||||
|
|
@ -495,6 +518,19 @@ class IssueAssistantApp {
|
||||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
'failed error=$error',
|
'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(
|
await database.addExecutionRun(
|
||||||
jobId: jobId,
|
jobId: jobId,
|
||||||
responderId: responder.id,
|
responderId: responder.id,
|
||||||
|
|
@ -534,6 +570,21 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
await database.updateJobStatus(jobId, JobStatus.completed);
|
await database.updateJobStatus(jobId, JobStatus.completed);
|
||||||
_log('issue=${project.key}#${issue.number} completed decision=no_reply');
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,6 +621,21 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
await database.updateJobStatus(jobId, JobStatus.completed);
|
await database.updateJobStatus(jobId, JobStatus.completed);
|
||||||
_log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}');
|
_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({
|
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
|
/// ```gherkin
|
||||||
/// Scenario: Load camelCase config with shared worktree root
|
/// Scenario: Load camelCase config with shared worktree root
|
||||||
/// Given camelCase config with dataDir, worktreeDir, and project settings
|
/// Given camelCase config with dataDir, worktreeDir, and project settings
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ void main() {
|
||||||
final stdoutText = result.stdout as String;
|
final stdoutText = result.stdout as String;
|
||||||
expect(stdoutText, contains('dataDir:'));
|
expect(stdoutText, contains('dataDir:'));
|
||||||
expect(stdoutText, contains('# responders:'));
|
expect(stdoutText, contains('# responders:'));
|
||||||
|
expect(stdoutText, contains('# notifications:'));
|
||||||
|
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
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
|
/// ```gherkin
|
||||||
/// Scenario: Process multiple issues concurrently up to the configured limit
|
/// Scenario: Process multiple issues concurrently up to the configured limit
|
||||||
/// Given a temporary project checkout that can be used as the local repo path
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue