forked from bkinnightskytw/code_work_spawner
feat: add github/webhook-forward issue source (#13)
* feat: add github webhook-forward event source for #12 Add a channel-based GitHub issue source so long-running sessions can react to issue and issue_comment webhooks without polling. Keep --once on polling so backlog reconciliation still works and cover the new config/runtime path with tests.
This commit is contained in:
parent
536c0a9450
commit
eb066aa914
|
|
@ -4,9 +4,7 @@ Repository issue thread assistant with an Agent Orchestrator style YAML config.
|
||||||
|
|
||||||
It consumes issue tracker events for configured GitHub or Gitea repositories,
|
It consumes issue tracker events for configured GitHub or Gitea repositories,
|
||||||
reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
||||||
`copilot-cli` to decide when to reply. Polling via `gh` and `tea` is the
|
`copilot-cli` to decide when to reply. It supports
|
||||||
currently bundled event source, with the runtime structured so future
|
|
||||||
channel-based sources can plug into the same processing pipeline. It supports
|
|
||||||
multi-repo config, responder fallback chains, and optional Discord webhook and
|
multi-repo config, responder fallback chains, and optional Discord webhook and
|
||||||
desktop notifications.
|
desktop notifications.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
||||||
..addFlag(
|
..addFlag(
|
||||||
'once',
|
'once',
|
||||||
abbr: '1',
|
abbr: '1',
|
||||||
help: 'Run a single poll cycle and exit.',
|
help: 'Run a single reconciliation cycle and exit.',
|
||||||
negatable: false,
|
negatable: false,
|
||||||
)
|
)
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,16 @@
|
||||||
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, responder chain, or notification list.
|
different prompt, mention triggers, responder chain, notification list, or
|
||||||
|
event source.
|
||||||
|
|
||||||
|
GitHub projects default to `gh/polling`, and Gitea projects default to
|
||||||
|
`tea/polling`. GitHub projects can switch their long-running runtime to
|
||||||
|
`gh/webhook-forward`, which starts
|
||||||
|
`gh webhook forward` for `issues` and `issue_comment` events while keeping
|
||||||
|
periodic polling reconciliation for startup sync and missed-event recovery.
|
||||||
|
`--once` still uses a single polling reconciliation cycle so existing backlog
|
||||||
|
handling keeps working.
|
||||||
|
|
||||||
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` or `tea` is authenticated
|
clear the reply came from automation even when `gh` or `tea` is authenticated
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ worktreeDir: ~/.worktrees
|
||||||
defaults:
|
defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
# Use gh/webhook-forward for long-running runs while keeping --once on polling.
|
||||||
|
# eventSource: gh/webhook-forward
|
||||||
pollInterval: 30s
|
pollInterval: 30s
|
||||||
maxConcurrentIssues: 3
|
maxConcurrentIssues: 3
|
||||||
mentionTriggers: ["@codex", "@helper"]
|
mentionTriggers: ["@codex", "@helper"]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'config_schema.dart';
|
||||||
export 'config_schema.dart'
|
export 'config_schema.dart'
|
||||||
show
|
show
|
||||||
AssistantCapability,
|
AssistantCapability,
|
||||||
|
IssueAssistantEventSourceKind,
|
||||||
IssueTrackerProvider,
|
IssueTrackerProvider,
|
||||||
NotificationConfigDocument,
|
NotificationConfigDocument,
|
||||||
NotificationEvent,
|
NotificationEvent,
|
||||||
|
|
@ -132,6 +133,7 @@ class AppConfig {
|
||||||
defaults: AppConfigDefaultsDocument(
|
defaults: AppConfigDefaultsDocument(
|
||||||
issueAssistant: IssueAssistantConfigDocument(
|
issueAssistant: IssueAssistantConfigDocument(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
eventSource: IssueAssistantEventSourceKind.ghPolling,
|
||||||
pollInterval: '30s',
|
pollInterval: '30s',
|
||||||
maxConcurrentIssues: 3,
|
maxConcurrentIssues: 3,
|
||||||
mentionTriggers: const ['@codex', '@helper'],
|
mentionTriggers: const ['@codex', '@helper'],
|
||||||
|
|
@ -188,15 +190,49 @@ class ProjectConfig {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final provider = document.provider ?? IssueTrackerProvider.github;
|
||||||
|
var issueAssistant = defaultAssistant.merge(document.issueAssistant);
|
||||||
|
if (document.issueAssistant?.eventSource == null &&
|
||||||
|
_isPollingEventSource(issueAssistant.eventSource)) {
|
||||||
|
issueAssistant = issueAssistant.copyWith(
|
||||||
|
eventSource: provider == IssueTrackerProvider.github
|
||||||
|
? IssueAssistantEventSourceKind.ghPolling
|
||||||
|
: IssueAssistantEventSourceKind.teaPolling,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (issueAssistant.eventSource) {
|
||||||
|
case IssueAssistantEventSourceKind.ghPolling:
|
||||||
|
case IssueAssistantEventSourceKind.ghWebhookForward:
|
||||||
|
if (provider != IssueTrackerProvider.github) {
|
||||||
|
throw FormatException(
|
||||||
|
'projects.$key.issueAssistant.eventSource '
|
||||||
|
'${_eventSourceConfigValue(issueAssistant.eventSource)} '
|
||||||
|
'requires provider: github.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case IssueAssistantEventSourceKind.teaPolling:
|
||||||
|
if (provider != IssueTrackerProvider.gitea) {
|
||||||
|
throw FormatException(
|
||||||
|
'projects.$key.issueAssistant.eventSource tea/polling '
|
||||||
|
'requires provider: gitea.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case IssueAssistantEventSourceKind.teaGosmee:
|
||||||
|
throw FormatException(
|
||||||
|
'projects.$key.issueAssistant.eventSource tea/gosmee '
|
||||||
|
'is not implemented yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ProjectConfig(
|
return ProjectConfig(
|
||||||
key: key,
|
key: key,
|
||||||
provider: document.provider ?? IssueTrackerProvider.github,
|
provider: provider,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||||
path: _expandHome(path),
|
path: _expandHome(path),
|
||||||
defaultBranch: document.defaultBranch ?? 'main',
|
defaultBranch: document.defaultBranch ?? 'main',
|
||||||
sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key),
|
sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key),
|
||||||
issueAssistant: defaultAssistant.merge(document.issueAssistant),
|
issueAssistant: issueAssistant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +253,7 @@ class ProjectConfig {
|
||||||
class IssueAssistantConfig {
|
class IssueAssistantConfig {
|
||||||
IssueAssistantConfig({
|
IssueAssistantConfig({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
|
required this.eventSource,
|
||||||
required this.pollInterval,
|
required this.pollInterval,
|
||||||
required this.maxConcurrentIssues,
|
required this.maxConcurrentIssues,
|
||||||
required this.mentionTriggers,
|
required this.mentionTriggers,
|
||||||
|
|
@ -230,6 +267,7 @@ class IssueAssistantConfig {
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final IssueAssistantEventSourceKind eventSource;
|
||||||
final Duration pollInterval;
|
final Duration pollInterval;
|
||||||
final int maxConcurrentIssues;
|
final int maxConcurrentIssues;
|
||||||
final List<String> mentionTriggers;
|
final List<String> mentionTriggers;
|
||||||
|
|
@ -255,6 +293,23 @@ class IssueAssistantConfig {
|
||||||
overlay: overlay,
|
overlay: overlay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IssueAssistantConfig copyWith({IssueAssistantEventSourceKind? eventSource}) {
|
||||||
|
return IssueAssistantConfig(
|
||||||
|
enabled: enabled,
|
||||||
|
eventSource: eventSource ?? this.eventSource,
|
||||||
|
pollInterval: pollInterval,
|
||||||
|
maxConcurrentIssues: maxConcurrentIssues,
|
||||||
|
mentionTriggers: mentionTriggers,
|
||||||
|
prompt: prompt,
|
||||||
|
responders: responders,
|
||||||
|
capabilities: capabilities,
|
||||||
|
notifications: notifications,
|
||||||
|
commentTag: commentTag,
|
||||||
|
commentPrefixTemplate: commentPrefixTemplate,
|
||||||
|
commentFooterTemplate: commentFooterTemplate,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CliResponderConfig {
|
class CliResponderConfig {
|
||||||
|
|
@ -349,6 +404,10 @@ class _IssueAssistantConfigResolver {
|
||||||
final notifications = overlay?.notifications;
|
final notifications = overlay?.notifications;
|
||||||
return IssueAssistantConfig(
|
return IssueAssistantConfig(
|
||||||
enabled: overlay?.enabled ?? base?.enabled ?? true,
|
enabled: overlay?.enabled ?? base?.enabled ?? true,
|
||||||
|
eventSource:
|
||||||
|
overlay?.eventSource ??
|
||||||
|
base?.eventSource ??
|
||||||
|
_defaultEventSourceForProvider(base),
|
||||||
pollInterval: overlay?.pollInterval != null
|
pollInterval: overlay?.pollInterval != null
|
||||||
? _parseDuration(overlay!.pollInterval!)
|
? _parseDuration(overlay!.pollInterval!)
|
||||||
: base?.pollInterval ?? const Duration(seconds: 30),
|
: base?.pollInterval ?? const Duration(seconds: 30),
|
||||||
|
|
@ -389,6 +448,30 @@ class _IssueAssistantConfigResolver {
|
||||||
defaultCommentFooterTemplate,
|
defaultCommentFooterTemplate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IssueAssistantEventSourceKind _defaultEventSourceForProvider(
|
||||||
|
IssueAssistantConfig? base,
|
||||||
|
) {
|
||||||
|
return base?.eventSource ?? IssueAssistantEventSourceKind.ghPolling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
|
||||||
|
return switch (value) {
|
||||||
|
IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
|
||||||
|
IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
|
||||||
|
IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
|
||||||
|
IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPollingEventSource(IssueAssistantEventSourceKind value) {
|
||||||
|
return switch (value) {
|
||||||
|
IssueAssistantEventSourceKind.ghPolling => true,
|
||||||
|
IssueAssistantEventSourceKind.teaPolling => true,
|
||||||
|
IssueAssistantEventSourceKind.ghWebhookForward => false,
|
||||||
|
IssueAssistantEventSourceKind.teaGosmee => false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String renderTemplate(String template, Map<String, String> values) {
|
String renderTemplate(String template, Map<String, String> values) {
|
||||||
|
|
@ -610,6 +693,7 @@ ${_YamlEmitter().write({'defaults': defaultsSection})}
|
||||||
|
|
||||||
# capabilities: ["planning", "bug_verification"]
|
# capabilities: ["planning", "bug_verification"]
|
||||||
# commentTag: code-work-spawner
|
# commentTag: code-work-spawner
|
||||||
|
# eventSource: gh/polling
|
||||||
# commentPrefixTemplate: |
|
# commentPrefixTemplate: |
|
||||||
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
|
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
|
||||||
# commentFooterTemplate: |
|
# commentFooterTemplate: |
|
||||||
|
|
@ -638,6 +722,7 @@ ${_YamlEmitter().write({'projects': projectsSection})}
|
||||||
|
|
||||||
# issueAssistant:
|
# issueAssistant:
|
||||||
# enabled: ${assistant?.enabled ?? true}
|
# enabled: ${assistant?.enabled ?? true}
|
||||||
|
# eventSource: gh/webhook-forward
|
||||||
# mentionTriggers: ["@helper"]
|
# mentionTriggers: ["@helper"]
|
||||||
# sessionPrefix: sample
|
# sessionPrefix: sample
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ class ProjectConfigDocument {
|
||||||
class IssueAssistantConfigDocument {
|
class IssueAssistantConfigDocument {
|
||||||
const IssueAssistantConfigDocument({
|
const IssueAssistantConfigDocument({
|
||||||
this.enabled,
|
this.enabled,
|
||||||
|
this.eventSource,
|
||||||
this.pollInterval,
|
this.pollInterval,
|
||||||
this.maxConcurrentIssues,
|
this.maxConcurrentIssues,
|
||||||
this.mentionTriggers,
|
this.mentionTriggers,
|
||||||
|
|
@ -99,6 +100,7 @@ class IssueAssistantConfigDocument {
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool? enabled;
|
final bool? enabled;
|
||||||
|
final IssueAssistantEventSourceKind? eventSource;
|
||||||
final String? pollInterval;
|
final String? pollInterval;
|
||||||
final int? maxConcurrentIssues;
|
final int? maxConcurrentIssues;
|
||||||
final List<String>? mentionTriggers;
|
final List<String>? mentionTriggers;
|
||||||
|
|
@ -179,6 +181,17 @@ class NotificationConfigDocument {
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum AssistantCapability { planning, bugVerification }
|
enum AssistantCapability { planning, bugVerification }
|
||||||
|
|
||||||
|
enum IssueAssistantEventSourceKind {
|
||||||
|
@JsonValue('gh/polling')
|
||||||
|
ghPolling,
|
||||||
|
@JsonValue('tea/polling')
|
||||||
|
teaPolling,
|
||||||
|
@JsonValue('gh/webhook-forward')
|
||||||
|
ghWebhookForward,
|
||||||
|
@JsonValue('tea/gosmee')
|
||||||
|
teaGosmee,
|
||||||
|
}
|
||||||
|
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum IssueTrackerProvider { github, gitea }
|
enum IssueTrackerProvider { github, gitea }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'cli_responder.dart';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import 'issue_event_source.dart';
|
import 'issue_event_source.dart';
|
||||||
|
import 'issue_event_source_github_webhook_forward.dart';
|
||||||
import 'issue_tracker_client.dart';
|
import 'issue_tracker_client.dart';
|
||||||
import 'notifier.dart';
|
import 'notifier.dart';
|
||||||
import 'workspace_manager.dart';
|
import 'workspace_manager.dart';
|
||||||
|
|
@ -31,6 +32,7 @@ class IssueAssistantApp {
|
||||||
final IssueEventSource issueEventSource;
|
final IssueEventSource issueEventSource;
|
||||||
final CliResponderRunner responderRunner;
|
final CliResponderRunner responderRunner;
|
||||||
final WorkspaceManager workspaceManager;
|
final WorkspaceManager workspaceManager;
|
||||||
|
final Map<String, Future<void>> _issueProcessingFutures = {};
|
||||||
final NotifierRegistry Function(ProjectConfig project)
|
final NotifierRegistry Function(ProjectConfig project)
|
||||||
notifierRegistryFactory;
|
notifierRegistryFactory;
|
||||||
|
|
||||||
|
|
@ -45,14 +47,25 @@ class IssueAssistantApp {
|
||||||
ghCommand: ghCommand,
|
ghCommand: ghCommand,
|
||||||
teaCommand: teaCommand,
|
teaCommand: teaCommand,
|
||||||
);
|
);
|
||||||
|
final pollingIssueEventSource = PollingIssueEventSource(
|
||||||
|
database: database,
|
||||||
|
issueTrackerClient: issueTrackerClient,
|
||||||
|
logger: _logger,
|
||||||
|
);
|
||||||
return IssueAssistantApp._(
|
return IssueAssistantApp._(
|
||||||
config: config,
|
config: config,
|
||||||
database: database,
|
database: database,
|
||||||
issueTrackerClient: issueTrackerClient,
|
issueTrackerClient: issueTrackerClient,
|
||||||
issueEventSource: PollingIssueEventSource(
|
issueEventSource: RoutedIssueEventSource(
|
||||||
database: database,
|
sources: {
|
||||||
issueTrackerClient: issueTrackerClient,
|
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
|
||||||
logger: _logger,
|
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
|
||||||
|
IssueAssistantEventSourceKind.ghWebhookForward:
|
||||||
|
GitHubWebhookForwardIssueEventSource(
|
||||||
|
issueTrackerClient: issueTrackerClient,
|
||||||
|
logger: _logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
responderRunner: CliResponderRunner(),
|
responderRunner: CliResponderRunner(),
|
||||||
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||||
|
|
@ -133,7 +146,7 @@ class IssueAssistantApp {
|
||||||
'project=${project.key} process_issue issue=${issues[currentIndex].number} '
|
'project=${project.key} process_issue issue=${issues[currentIndex].number} '
|
||||||
'queue_index=${currentIndex + 1}/${issues.length} active_limit=$activeCount',
|
'queue_index=${currentIndex + 1}/${issues.length} active_limit=$activeCount',
|
||||||
);
|
);
|
||||||
await _processIssue(project, issues[currentIndex]);
|
await _processIssueSerialized(project, issues[currentIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,6 +441,31 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _processIssueSerialized(
|
||||||
|
ProjectConfig project,
|
||||||
|
GitHubIssueSummary issue,
|
||||||
|
) async {
|
||||||
|
final issueKey = '${project.key}#${issue.number}';
|
||||||
|
final previous = _issueProcessingFutures[issueKey];
|
||||||
|
|
||||||
|
late final Future<void> current;
|
||||||
|
current = () async {
|
||||||
|
if (previous != null) {
|
||||||
|
await previous.catchError((_) {});
|
||||||
|
}
|
||||||
|
await _processIssue(project, issue);
|
||||||
|
}();
|
||||||
|
|
||||||
|
_issueProcessingFutures[issueKey] = current;
|
||||||
|
try {
|
||||||
|
await current;
|
||||||
|
} finally {
|
||||||
|
if (identical(_issueProcessingFutures[issueKey], current)) {
|
||||||
|
_issueProcessingFutures.remove(issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _buildPrompt({
|
String _buildPrompt({
|
||||||
required ProjectConfig project,
|
required ProjectConfig project,
|
||||||
required IssueThread thread,
|
required IssueThread thread,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,83 @@ abstract class IssueEventSource {
|
||||||
Future<void> close();
|
Future<void> close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RoutedIssueEventSource implements IssueEventSource {
|
||||||
|
RoutedIssueEventSource({required this.sources});
|
||||||
|
|
||||||
|
final Map<IssueAssistantEventSourceKind, IssueEventSource> sources;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> run({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
final projectsBySource =
|
||||||
|
<IssueAssistantEventSourceKind, List<ProjectConfig>>{
|
||||||
|
for (final eventSource in sources.keys)
|
||||||
|
eventSource: <ProjectConfig>[],
|
||||||
|
};
|
||||||
|
for (final project in projects) {
|
||||||
|
for (final projectSource in _registeredSourcesFor(project)) {
|
||||||
|
final sourceProjects = projectsBySource[projectSource];
|
||||||
|
if (sourceProjects == null) {
|
||||||
|
throw StateError(
|
||||||
|
'No issue event source registered for ${projectSource.name}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sourceProjects.add(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(
|
||||||
|
projectsBySource.entries
|
||||||
|
.where((entry) => entry.value.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(entry) => sources[entry.key]!.run(
|
||||||
|
projects: entry.value,
|
||||||
|
onBatch: onBatch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runOnce({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) {
|
||||||
|
return sources[IssueAssistantEventSourceKind.ghPolling]!.runOnce(
|
||||||
|
projects: projects,
|
||||||
|
onBatch: onBatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
for (final source in sources.values) {
|
||||||
|
await source.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<IssueAssistantEventSourceKind> _registeredSourcesFor(
|
||||||
|
ProjectConfig project,
|
||||||
|
) sync* {
|
||||||
|
switch (project.issueAssistant.eventSource) {
|
||||||
|
case IssueAssistantEventSourceKind.ghPolling:
|
||||||
|
yield IssueAssistantEventSourceKind.ghPolling;
|
||||||
|
case IssueAssistantEventSourceKind.teaPolling:
|
||||||
|
yield IssueAssistantEventSourceKind.teaPolling;
|
||||||
|
case IssueAssistantEventSourceKind.ghWebhookForward:
|
||||||
|
// Keep reconciliation polling active for startup sync and missed
|
||||||
|
// webhook recovery while the forwarded channel handles low-latency
|
||||||
|
// issue/comment events.
|
||||||
|
yield IssueAssistantEventSourceKind.ghPolling;
|
||||||
|
yield IssueAssistantEventSourceKind.ghWebhookForward;
|
||||||
|
case IssueAssistantEventSourceKind.teaGosmee:
|
||||||
|
yield IssueAssistantEventSourceKind.teaGosmee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PollingIssueEventSource implements IssueEventSource {
|
class PollingIssueEventSource implements IssueEventSource {
|
||||||
PollingIssueEventSource({
|
PollingIssueEventSource({
|
||||||
required this.database,
|
required this.database,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import 'config.dart';
|
||||||
|
import 'issue_event_source.dart';
|
||||||
|
import 'issue_tracker_client.dart';
|
||||||
|
|
||||||
|
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
|
GitHubWebhookForwardIssueEventSource({
|
||||||
|
required this.issueTrackerClient,
|
||||||
|
required Logger logger,
|
||||||
|
}) : _logger = logger;
|
||||||
|
|
||||||
|
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
final IssueTrackerClient issueTrackerClient;
|
||||||
|
final Logger _logger;
|
||||||
|
final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
|
||||||
|
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||||
|
final Map<String, Process> _forwardProcesses = {};
|
||||||
|
|
||||||
|
HttpServer? _server;
|
||||||
|
Timer? _flushTimer;
|
||||||
|
Completer<void>? _runCompleter;
|
||||||
|
bool _isClosing = false;
|
||||||
|
bool _isClosed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> run({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('IssueEventSource is already closed.');
|
||||||
|
}
|
||||||
|
if (_runCompleter != null) {
|
||||||
|
return _runCompleter!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final githubProjects = projects
|
||||||
|
.where((project) => project.provider == IssueTrackerProvider.github)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (githubProjects.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_runCompleter = completer;
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
_server = server;
|
||||||
|
final repoToProject = {
|
||||||
|
for (final project in githubProjects) project.repo: project,
|
||||||
|
};
|
||||||
|
server.listen(
|
||||||
|
(request) => unawaited(
|
||||||
|
_handleWebhookRequest(
|
||||||
|
request: request,
|
||||||
|
repoToProject: repoToProject,
|
||||||
|
onBatch: onBatch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
|
_logError('github/webhook-forward server error=$error');
|
||||||
|
if (!_isClosing && !completer.isCompleted) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final url =
|
||||||
|
'http://${server.address.address}:${server.port}/github/webhooks';
|
||||||
|
for (final project in githubProjects) {
|
||||||
|
final process = await Process.start(issueTrackerClient.ghCommand, [
|
||||||
|
'webhook',
|
||||||
|
'forward',
|
||||||
|
'--events=issues,issue_comment',
|
||||||
|
'--repo=${project.repo}',
|
||||||
|
'--url=$url',
|
||||||
|
]);
|
||||||
|
_forwardProcesses[project.key] = process;
|
||||||
|
_pipeProcessLogs(
|
||||||
|
project: project,
|
||||||
|
stream: process.stdout,
|
||||||
|
level: 'stdout',
|
||||||
|
);
|
||||||
|
_pipeProcessLogs(
|
||||||
|
project: project,
|
||||||
|
stream: process.stderr,
|
||||||
|
level: 'stderr',
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((exitCode) {
|
||||||
|
_forwardProcesses.remove(project.key);
|
||||||
|
_log(
|
||||||
|
'github/webhook-forward process project=${project.key} '
|
||||||
|
'exit_code=$exitCode',
|
||||||
|
);
|
||||||
|
if (!_isClosing && !completer.isCompleted) {
|
||||||
|
completer.completeError(
|
||||||
|
StateError(
|
||||||
|
'gh webhook forward exited for project=${project.key} '
|
||||||
|
'with code $exitCode.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completer.future;
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runOnce({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'github/webhook-forward does not support runOnce; use polling instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_isClosed || _isClosing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isClosing = true;
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
final completer = _runCompleter;
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
_runCompleter = null;
|
||||||
|
|
||||||
|
final server = _server;
|
||||||
|
_server = null;
|
||||||
|
await server?.close(force: true);
|
||||||
|
|
||||||
|
final processes = _forwardProcesses.values.toList(growable: false);
|
||||||
|
_forwardProcesses.clear();
|
||||||
|
for (final process in processes) {
|
||||||
|
process.kill();
|
||||||
|
}
|
||||||
|
for (final process in processes) {
|
||||||
|
await process.exitCode.catchError((_) => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _flushQueuedIssues();
|
||||||
|
await Future.wait(_activeDispatches.toList(growable: false));
|
||||||
|
|
||||||
|
_isClosed = true;
|
||||||
|
_isClosing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleWebhookRequest({
|
||||||
|
required HttpRequest request,
|
||||||
|
required Map<String, ProjectConfig> repoToProject,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
if (request.method != 'POST') {
|
||||||
|
request.response
|
||||||
|
..statusCode = HttpStatus.methodNotAllowed
|
||||||
|
..write('Only POST is supported.');
|
||||||
|
await request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final payloadText = await utf8.decoder.bind(request).join();
|
||||||
|
final payload = jsonDecode(payloadText);
|
||||||
|
if (payload is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('Webhook payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
final repository = payload['repository'];
|
||||||
|
final fullName = repository is Map<String, dynamic>
|
||||||
|
? repository['full_name']?.toString()
|
||||||
|
: null;
|
||||||
|
final project = fullName == null ? null : repoToProject[fullName];
|
||||||
|
final issueNode = payload['issue'];
|
||||||
|
if (project != null &&
|
||||||
|
issueNode is Map<String, dynamic> &&
|
||||||
|
(issueNode['pull_request'] == null)) {
|
||||||
|
final issue = GitHubIssueSummary.fromRepositoryJson(
|
||||||
|
project.repoSlug,
|
||||||
|
issueNode,
|
||||||
|
);
|
||||||
|
if (issue.state == 'open') {
|
||||||
|
_queuedIssues['${project.key}#${issue.number}'] = _QueuedWebhookIssue(
|
||||||
|
project: project,
|
||||||
|
issue: issue,
|
||||||
|
onBatch: onBatch,
|
||||||
|
);
|
||||||
|
_scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.response.statusCode = HttpStatus.accepted;
|
||||||
|
await request.response.close();
|
||||||
|
} catch (error) {
|
||||||
|
_logError('github/webhook-forward payload error=$error');
|
||||||
|
request.response
|
||||||
|
..statusCode = HttpStatus.badRequest
|
||||||
|
..write('Invalid webhook payload.');
|
||||||
|
await request.response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleFlush() {
|
||||||
|
_flushTimer ??= Timer(_webhookCoalescingWindow, () {
|
||||||
|
_flushTimer = null;
|
||||||
|
unawaited(_flushQueuedIssues());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushQueuedIssues() async {
|
||||||
|
if (_queuedIssues.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queuedIssues = _queuedIssues.values.toList(growable: false);
|
||||||
|
_queuedIssues.clear();
|
||||||
|
final groupedIssues = <String, _QueuedWebhookBatch>{};
|
||||||
|
for (final queuedIssue in queuedIssues) {
|
||||||
|
groupedIssues.update(
|
||||||
|
queuedIssue.project.key,
|
||||||
|
(existing) => existing.add(queuedIssue.issue),
|
||||||
|
ifAbsent: () => _QueuedWebhookBatch(
|
||||||
|
project: queuedIssue.project,
|
||||||
|
onBatch: queuedIssue.onBatch,
|
||||||
|
issues: [queuedIssue.issue],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final Future<void> dispatchFuture;
|
||||||
|
dispatchFuture = () async {
|
||||||
|
for (final batch in groupedIssues.values) {
|
||||||
|
batch.issues.sort(
|
||||||
|
(left, right) => left.updatedAt.compareTo(right.updatedAt),
|
||||||
|
);
|
||||||
|
_log(
|
||||||
|
'github/webhook-forward project=${batch.project.key} '
|
||||||
|
'fetched_issues=${batch.issues.length}',
|
||||||
|
);
|
||||||
|
await batch.onBatch(
|
||||||
|
IssueEventBatch(
|
||||||
|
project: batch.project,
|
||||||
|
issues: batch.issues,
|
||||||
|
latestSeenUpdatedAt: batch.issues.last.updatedAt,
|
||||||
|
sourceKind: 'github/webhook-forward',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
_activeDispatches.add(dispatchFuture);
|
||||||
|
try {
|
||||||
|
await dispatchFuture;
|
||||||
|
} finally {
|
||||||
|
_activeDispatches.remove(dispatchFuture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pipeProcessLogs({
|
||||||
|
required ProjectConfig project,
|
||||||
|
required Stream<List<int>> stream,
|
||||||
|
required String level,
|
||||||
|
}) {
|
||||||
|
stream.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
||||||
|
line,
|
||||||
|
) {
|
||||||
|
_log('github/webhook-forward project=${project.key} $level=$line');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String message) {
|
||||||
|
_logger.info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logError(String message) {
|
||||||
|
_logger.severe(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedWebhookIssue {
|
||||||
|
_QueuedWebhookIssue({
|
||||||
|
required this.project,
|
||||||
|
required this.issue,
|
||||||
|
required this.onBatch,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectConfig project;
|
||||||
|
final GitHubIssueSummary issue;
|
||||||
|
final IssueEventBatchHandler onBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedWebhookBatch {
|
||||||
|
_QueuedWebhookBatch({
|
||||||
|
required this.project,
|
||||||
|
required this.onBatch,
|
||||||
|
required this.issues,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectConfig project;
|
||||||
|
final IssueEventBatchHandler onBatch;
|
||||||
|
final List<GitHubIssueSummary> issues;
|
||||||
|
|
||||||
|
_QueuedWebhookBatch add(GitHubIssueSummary issue) {
|
||||||
|
issues.removeWhere((existing) => existing.number == issue.number);
|
||||||
|
issues.add(issue);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -350,6 +350,84 @@ projects:
|
||||||
|
|
||||||
// And the repository slug still parses normally.
|
// And the repository slug still parses normally.
|
||||||
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
|
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.eventSource,
|
||||||
|
IssueAssistantEventSourceKind.teaPolling,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Load gh/webhook-forward as the issue event source
|
||||||
|
/// Given a GitHub project config that opts into gh/webhook-forward
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the project keeps gh/webhook-forward as its event source
|
||||||
|
/// And the GitHub provider remains valid for that project
|
||||||
|
/// ```
|
||||||
|
test('Load gh/webhook-forward as the issue event source', () async {
|
||||||
|
// Given a GitHub project config that opts into gh/webhook-forward.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-webhook-forward-',
|
||||||
|
);
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the project keeps gh/webhook-forward as its event source.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.eventSource,
|
||||||
|
IssueAssistantEventSourceKind.ghWebhookForward,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the GitHub provider remains valid for that project.
|
||||||
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reject gh/webhook-forward for non-GitHub providers
|
||||||
|
/// Given a Gitea project config that opts into gh/webhook-forward
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then loading fails with a clear provider compatibility error
|
||||||
|
/// ```
|
||||||
|
test('Reject gh/webhook-forward for non-GitHub providers', () async {
|
||||||
|
// Given a Gitea project config that opts into gh/webhook-forward.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-webhook-forward-gitea-',
|
||||||
|
);
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: gitea
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final load = AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then loading fails with a clear provider compatibility error.
|
||||||
|
await expectLater(
|
||||||
|
load,
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
contains('gh/webhook-forward requires provider: github'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
|
|
|
||||||
|
|
@ -1595,6 +1595,322 @@ projects:
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: GitHub webhook forward issue events
|
||||||
|
///
|
||||||
|
/// As a maintainer running the long-lived assistant
|
||||||
|
/// I want GitHub webhook-forward events to trigger issue evaluation without polling
|
||||||
|
/// So that comment mentions can be processed through a channel-based source
|
||||||
|
/// ```
|
||||||
|
group('IssueAssistantApp.run webhook-forward', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reply to a mention received through gh/webhook-forward
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that forwards one issue_comment webhook event
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that uses gh/webhook-forward
|
||||||
|
/// When the long-running app starts and receives the forwarded webhook
|
||||||
|
/// Then it reads issue comments and posts exactly one reply for that issue
|
||||||
|
/// And the gh webhook forward command is invoked for the GitHub project
|
||||||
|
/// ```
|
||||||
|
test('Reply to a mention received through gh/webhook-forward', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-webhook-run-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that forwards one issue_comment webhook event.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
|
final issue = {
|
||||||
|
'number': 12,
|
||||||
|
'title': 'Need channel-based trigger handling',
|
||||||
|
'body': 'Please react to webhook-forward.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/12',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const <Map<String, Object?>>[],
|
||||||
|
};
|
||||||
|
final comments = [
|
||||||
|
{
|
||||||
|
'id': 51,
|
||||||
|
'body': '@helper can you handle forwarded comments?',
|
||||||
|
'html_url': 'https://example.test/issues/12#issuecomment-51',
|
||||||
|
'created_at': '2026-04-08T04:00:00Z',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeWebhookForwardGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
repo: 'owner/sample',
|
||||||
|
issue: issue,
|
||||||
|
comments: comments,
|
||||||
|
ghLog: ghLog,
|
||||||
|
postedBodyFile: postedBody,
|
||||||
|
viewerLogin: 'octocat',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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': 'Plan:\n- consume forwarded issue_comment events',
|
||||||
|
'summary': 'posted channel-based planning advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses gh/webhook-forward.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (await postedBody.exists()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
|
||||||
|
// Then it reads issue comments and posts exactly one reply for that issue.
|
||||||
|
expect(await postedBody.exists(), isTrue);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- consume forwarded issue_comment events'),
|
||||||
|
);
|
||||||
|
expect(postedComment, contains('@octocat'));
|
||||||
|
|
||||||
|
// And the gh webhook forward command is invoked for the GitHub project.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines.any(
|
||||||
|
(line) => line.contains(
|
||||||
|
'webhook forward --events=issues,issue_comment --repo=owner/sample',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reconcile a missed GitHub webhook event through polling
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gh script that starts webhook forwarding without delivering an event
|
||||||
|
/// And the same gh script returns a new issue only on a later reconciliation poll
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval
|
||||||
|
/// When the long-running app starts and enough time passes for reconciliation polling
|
||||||
|
/// Then it posts exactly one reply for the issue discovered by polling
|
||||||
|
/// And the gh webhook forward command is still invoked for the GitHub project
|
||||||
|
/// And the app performs more than one GitHub search request while running
|
||||||
|
/// ```
|
||||||
|
test('Reconcile a missed GitHub webhook event through polling', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-webhook-reconcile-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gh script that starts webhook forwarding without delivering an event.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
|
final searchCountFile = File(p.join(sandbox.path, 'search-count'));
|
||||||
|
final issue = {
|
||||||
|
'number': 18,
|
||||||
|
'title': 'Need reconciliation after a missed webhook',
|
||||||
|
'body': 'Please recover this mention from polling.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/18',
|
||||||
|
'updated_at': '2026-04-08T05:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const <Map<String, Object?>>[],
|
||||||
|
};
|
||||||
|
final searchItem = {
|
||||||
|
...issue,
|
||||||
|
'repository_url': 'https://api.github.com/repos/owner/sample',
|
||||||
|
};
|
||||||
|
final comments = [
|
||||||
|
{
|
||||||
|
'id': 181,
|
||||||
|
'body': '@helper please recover this even if the webhook was missed',
|
||||||
|
'html_url': 'https://example.test/issues/18#issuecomment-181',
|
||||||
|
'created_at': '2026-04-08T05:00:00Z',
|
||||||
|
'updated_at': '2026-04-08T05:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "\$*" >> "${ghLog.path}"
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({'login': 'octocat'})}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
|
||||||
|
count=0
|
||||||
|
if [[ -f "${searchCountFile.path}" ]]; then
|
||||||
|
count="\$(cat "${searchCountFile.path}")"
|
||||||
|
fi
|
||||||
|
count=\$((count + 1))
|
||||||
|
printf '%s' "\$count" > "${searchCountFile.path}"
|
||||||
|
if [[ "\$count" -eq 1 ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
{"total_count":0,"incomplete_results":false,"items":[]}
|
||||||
|
JSON
|
||||||
|
else
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({
|
||||||
|
'total_count': 1,
|
||||||
|
'incomplete_results': false,
|
||||||
|
'items': [searchItem],
|
||||||
|
})}
|
||||||
|
JSON
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?per_page=100" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(comments)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then
|
||||||
|
for arg in "\$@"; do
|
||||||
|
if [[ "\$arg" == body=* ]]; then
|
||||||
|
printf '%s' "\${arg#body=}" > "${postedBody.path}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode({'id': 703, 'html_url': 'https://example.test/comment/703', 'body': 'posted'})}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then
|
||||||
|
while true; do
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected gh args: \$*" >&2
|
||||||
|
exit 1
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
|
||||||
|
// 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': 'Plan:\n- reconcile missed webhook events through polling',
|
||||||
|
'summary': 'posted reconciliation advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 1s
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: github
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: gh/webhook-forward
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (await postedBody.exists()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
|
||||||
|
// Then it posts exactly one reply for the issue discovered by polling.
|
||||||
|
expect(await postedBody.exists(), isTrue);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- reconcile missed webhook events through polling'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the gh webhook forward command is still invoked for the GitHub project.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines.any(
|
||||||
|
(line) => line.contains(
|
||||||
|
'webhook forward --events=issues,issue_comment --repo=owner/sample',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the app performs more than one GitHub search request while running.
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) =>
|
||||||
|
line ==
|
||||||
|
'api -X GET search/issues -f q=repo:owner/sample is:issue state:open -f sort=updated -f order=asc -f per_page=100 -f page=1',
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
greaterThanOrEqualTo(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Feature: Continuous issue polling
|
/// Feature: Continuous issue polling
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,136 @@ JSON
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> writeWebhookForwardGhScript({
|
||||||
|
required File ghScript,
|
||||||
|
required String repo,
|
||||||
|
required Map<String, Object?> issue,
|
||||||
|
required List<Map<String, Object?>> comments,
|
||||||
|
List<Map<String, Object?>> issueListResponse = const [],
|
||||||
|
File? ghLog,
|
||||||
|
File? postedBodyFile,
|
||||||
|
String? viewerLogin,
|
||||||
|
}) async {
|
||||||
|
final issueNumber = issue['number'] as int;
|
||||||
|
final payload = {
|
||||||
|
'action': 'created',
|
||||||
|
'issue': issue,
|
||||||
|
'comment': comments.last,
|
||||||
|
'repository': {'full_name': repo},
|
||||||
|
};
|
||||||
|
final searchItems = issueListResponse
|
||||||
|
.map(
|
||||||
|
(searchIssue) => <String, Object?>{
|
||||||
|
...searchIssue,
|
||||||
|
'repository_url': 'https://api.github.com/repos/$repo',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
final searchResponse = jsonEncode({
|
||||||
|
'total_count': searchItems.length,
|
||||||
|
'incomplete_results': false,
|
||||||
|
'items': searchItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('#!/usr/bin/env bash')
|
||||||
|
..writeln('set -euo pipefail');
|
||||||
|
|
||||||
|
if (ghLog != null) {
|
||||||
|
buffer.writeln(
|
||||||
|
r'''printf '%s\n' "$*" >> '''
|
||||||
|
'"${ghLog.path}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||||
|
buffer
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(searchResponse)
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode(comments))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == '
|
||||||
|
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(r''' for arg in "$@"; do''')
|
||||||
|
..writeln(r''' :''');
|
||||||
|
if (postedBodyFile != null) {
|
||||||
|
buffer
|
||||||
|
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||||
|
..writeln(
|
||||||
|
r''' printf '%s' "${arg#body=}" > '''
|
||||||
|
'"${postedBodyFile.path}"',
|
||||||
|
)
|
||||||
|
..writeln(r''' fi''');
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
..writeln(r''' done''')
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(
|
||||||
|
jsonEncode({
|
||||||
|
'id': 702,
|
||||||
|
'html_url': 'https://example.test/comment/702',
|
||||||
|
'body': 'posted',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(r'''if [[ "$1" == "webhook" && "$2" == "forward" ]]; then''')
|
||||||
|
..writeln(r''' url=""''')
|
||||||
|
..writeln(r''' for arg in "$@"; do''')
|
||||||
|
..writeln(r''' if [[ "$arg" == --url=* ]]; then''')
|
||||||
|
..writeln(r''' url="${arg#--url=}"''')
|
||||||
|
..writeln(r''' fi''')
|
||||||
|
..writeln(r''' done''')
|
||||||
|
..writeln(r''' if [[ -z "$url" ]]; then''')
|
||||||
|
..writeln(r''' echo "missing --url" >&2''')
|
||||||
|
..writeln(' exit 1')
|
||||||
|
..writeln(r''' fi''')
|
||||||
|
..writeln(r''' curl -sS -X POST \''')
|
||||||
|
..writeln(r''' -H "Content-Type: application/json" \''')
|
||||||
|
..writeln(r''' -H "X-GitHub-Event: issue_comment" \''')
|
||||||
|
..writeln(r''' --data-binary @- "$url" <<'JSON' >/dev/null''')
|
||||||
|
..writeln(jsonEncode(payload))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(r''' while true; do''')
|
||||||
|
..writeln(r''' sleep 60''')
|
||||||
|
..writeln(r''' done''')
|
||||||
|
..writeln('fi');
|
||||||
|
|
||||||
|
buffer
|
||||||
|
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||||
|
..writeln('exit 1');
|
||||||
|
|
||||||
|
await ghScript.writeAsString(buffer.toString());
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> initGitRepo(Directory directory) async {
|
Future<void> initGitRepo(Directory directory) async {
|
||||||
await GitDir.init(directory.path, allowContent: true);
|
await GitDir.init(directory.path, allowContent: true);
|
||||||
await runGit([
|
await runGit([
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue