Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe("dashboard routing and API query helpers", () => {
});

it("renders the webhook status exported by the backend", () => {
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 143, deliveries: 912 } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 143, deliveries: 912 } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);

expect(screen.getByRole("heading", { name: "GitHub webhooks" })).toBeInTheDocument();
expect(screen.getByText("shadow")).toBeInTheDocument();
Expand All @@ -160,8 +160,8 @@ describe("dashboard routing and API query helpers", () => {
const onViewHook = vi.fn();
const summary = { mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 101, deliveries: 912 } };
const hooks = [{ id: "42", target: "gisce", target_type: "organization" as const, active: true, events: ["issue_comment"], status: "receiving" as const, last_ping_at: "2026-10-02T10:00:00Z", last_event_at: "2026-10-02T10:05:00Z" }];
const deliveries = [{ delivery_id: "delivery-1", created_at: "2026-10-02T10:05:00Z", hook_id: "42", hook: { id: "42", target: "gisce", target_type: "organization" as const }, event_name: "issue_comment", action: "created", repository: "gisce/github-agent-bridge", status: "observed" }];
const common = { summary, summaryLoading: false, sectionLoading: false, loadingMore: false, hasMore: false, deliveryFilters: { hook_id: "", event_name: "", repository: "", result: "" }, error: null, onSectionChange, onLoadMore: vi.fn(), onDeliveryFiltersChange: vi.fn(), onViewHook, onRefresh: vi.fn() };
const deliveries = [{ delivery_id: "delivery-1", created_at: "2026-10-02T10:05:00Z", hook_id: "42", hook: { id: "42", target: "gisce", target_type: "organization" as const }, event_name: "issue_comment", action: "created", repository: "gisce/github-agent-bridge", status: "observed", enqueue_status: "enqueued", job_id: 81 }];
const common = { summary, summaryLoading: false, sectionLoading: false, loadingMore: false, hasMore: false, deliveryFilters: { hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }, error: null, onSectionChange, onLoadMore: vi.fn(), onDeliveryFiltersChange: vi.fn(), onViewHook, onRefresh: vi.fn() };
const { rerender } = render(<WebhookPage {...common} section="overview" />);

expect(screen.getByRole("tab", { name: "Hooks (101 total)" })).toBeInTheDocument();
Expand All @@ -183,6 +183,8 @@ describe("dashboard routing and API query helpers", () => {
expect(screen.getAllByText("issue_comment · created").length).toBeGreaterThan(0);
expect(screen.getByText("gisce/github-agent-bridge")).toBeInTheDocument();
expect(screen.getByText("#42")).toBeInTheDocument();
expect(screen.getByText("enqueued")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Job #81" })).toHaveAttribute("href", "/jobs/81");
});

it("shows sanitized hook configuration and recent delivery identity", () => {
Expand All @@ -206,7 +208,7 @@ describe("dashboard routing and API query helpers", () => {
takeRecords() { return []; }
}
vi.stubGlobal("IntersectionObserver", ImmediateIntersectionObserver);
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: {}, duplicate_deliveries: 0, cross_source_matches: 0 }} hooks={[{ id: "42", target: "gisce", target_type: "organization", active: true, events: [], status: "quiet" }]} section="hooks" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={onLoadMore} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: {}, duplicate_deliveries: 0, cross_source_matches: 0 }} hooks={[{ id: "42", target: "gisce", target_type: "organization", active: true, events: [], status: "quiet" }]} section="hooks" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={onLoadMore} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);

await waitFor(() => expect(onLoadMore).toHaveBeenCalledTimes(1));
vi.unstubAllGlobals();
Expand Down
18 changes: 14 additions & 4 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ type WebhookSummary = {
receipts: Record<string, number>;
duplicate_deliveries: number;
cross_source_matches: number;
enqueue?: Record<string, number>;
totals?: { hooks: number; deliveries: number };
coverage?: {
both: number;
Expand All @@ -86,6 +87,9 @@ type WebhookSummary = {
imap_eligible: number;
ratio?: number | null;
mean_match_delay_ms?: number | null;
window_start?: string;
window_end?: string;
grace_seconds?: number;
};
};

Expand Down Expand Up @@ -162,6 +166,7 @@ type WebhookDeliveryFilters = {
event_name: string;
repository: string;
result: string;
enqueue_status: string;
};

type WebhookDelivery = {
Expand All @@ -172,6 +177,8 @@ type WebhookDelivery = {
action?: string | null;
repository?: string | null;
status: string;
enqueue_status?: string | null;
job_id?: number | null;
event_key?: string | null;
duplicate_count?: number;
hook?: {
Expand Down Expand Up @@ -971,7 +978,7 @@ function App() {
const [knowledgeStatus, setKnowledgeStatus] = React.useState("proposed");
const [webhookSection, setWebhookSection] = React.useState<WebhookSection>("overview");
const [webhookDeliveryFilters, setWebhookDeliveryFilters] = React.useState<WebhookDeliveryFilters>({
hook_id: "", event_name: "", repository: "", result: "",
hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "",
});
const [webhookWindow, setWebhookWindow] = React.useState(() => webhookMonitoringWindow());
const [autoupdateAction, setAutoupdateAction] = React.useState<"refresh" | "apply" | "complete" | null>(null);
Expand Down Expand Up @@ -1790,7 +1797,7 @@ function WebhookPage({
];
return (
<section className="grid gap-4">
<PageTitle icon={<Activity className="h-5 w-5 text-muted" aria-hidden />} title="GitHub webhooks" subtitle={summary?.mode === "canary" ? "Canary ingestion health. Only enabled repositories may create jobs." : "Shadow ingestion health. Deliveries are observed but do not create jobs."} action={<RefreshButton onClick={onRefresh} />} />
<PageTitle icon={<Activity className="h-5 w-5 text-muted" aria-hidden />} title="GitHub webhooks" subtitle={summary?.mode === "canary" ? "Canary ingestion health. Only the webhook canary allowlist may create jobs." : "Shadow ingestion health. Deliveries are observed but do not create jobs."} action={<RefreshButton onClick={onRefresh} />} />
{error ? <Banner tone="error" text={error.message} /> : null}
<div className="flex max-w-full flex-wrap rounded-md border border-border bg-white p-1" role="tablist" aria-label="Webhook dashboard section">
{sections.map((item) => <button key={item.id} type="button" role="tab" aria-label={item.count === undefined ? item.label : `${item.label} (${item.count} total)`} aria-selected={section === item.id} className={cn("inline-flex h-8 items-center gap-2 rounded px-3 text-sm font-semibold", section === item.id ? "bg-primary text-white" : "text-muted hover:bg-slate-50 hover:text-foreground")} onClick={() => onSectionChange(item.id)}>{item.label}{item.count !== undefined ? <span className="rounded border border-current/30 px-1 font-mono text-[10px]">{item.count}</span> : null}</button>)}
Expand All @@ -1815,7 +1822,9 @@ function WebhookPage({
<MiniStat label="Webhook only" value={summary?.coverage?.webhook_only ?? 0} />
<MiniStat label="Coverage" value={summary?.coverage?.ratio == null ? "not measurable" : `${Math.round(summary.coverage.ratio * 100)}%`} />
</div>
{summary?.coverage?.window_start && summary.coverage.window_end ? <p className="mt-3 font-mono text-xs text-muted">Comparable canonical events from {summary.coverage.window_start} to {summary.coverage.window_end}; newest {summary.coverage.grace_seconds ?? 0}s excluded.</p> : null}
</Panel>
{Object.keys(summary?.enqueue ?? {}).length ? <Panel title="Canary ingestion decisions"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{Object.entries(summary?.enqueue ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([state, count]) => <MiniStat key={state} label={state} value={count} />)}</div></Panel> : null}
<Panel title="Exceptions requiring review">
{exceptions?.length ? <div className="overflow-x-auto"><table className="w-full min-w-[720px] text-left text-sm"><thead className="border-b border-border text-xs text-muted"><tr><th className="px-3 py-2">Type</th><th className="px-3 py-2">Repository</th><th className="px-3 py-2">Reference</th><th className="px-3 py-2">Observed</th></tr></thead><tbody>{exceptions.map((item) => <tr key={`${item.kind}:${item.reference}`} className="border-b border-border/70 last:border-0"><td className="px-3 py-2 font-semibold">{item.kind}</td><td className="px-3 py-2 font-mono text-xs">{item.repository ?? "unknown"}</td><td className="px-3 py-2 font-mono text-xs">{item.event_key ?? item.reference}</td><td className="px-3 py-2 font-mono text-xs text-muted">{item.created_at}</td></tr>)}</tbody></table></div> : <EmptyState text="No cross-source exceptions in retained data." />}
</Panel>
Expand All @@ -1837,12 +1846,13 @@ function WebhookDeliveryFiltersForm({ filters, onChange }: { filters: WebhookDel
{ key: "event_name", label: "Event", placeholder: "issue_comment" },
{ key: "repository", label: "Repository", placeholder: "gisce/repository" },
{ key: "result", label: "Result", placeholder: "observed" },
{ key: "enqueue_status", label: "Ingestion", placeholder: "enqueued" },
];
return <div className="grid gap-2 border-b border-border p-3 sm:grid-cols-2 xl:grid-cols-4">{fields.map((field) => <label key={field.key} className="grid gap-1 text-xs font-semibold text-muted">{field.label}<input className="h-9 rounded-md border border-border bg-white px-2 font-mono text-xs text-foreground" value={filters[field.key]} placeholder={field.placeholder} onChange={(event) => onChange({ ...filters, [field.key]: event.target.value })} /></label>)}</div>;
return <div className="grid gap-2 border-b border-border p-3 sm:grid-cols-2 xl:grid-cols-5">{fields.map((field) => <label key={field.key} className="grid gap-1 text-xs font-semibold text-muted">{field.label}<input className="h-9 rounded-md border border-border bg-white px-2 font-mono text-xs text-foreground" value={filters[field.key]} placeholder={field.placeholder} onChange={(event) => onChange({ ...filters, [field.key]: event.target.value })} /></label>)}</div>;
}

function WebhookDeliveriesTable({ deliveries, onViewHook }: { deliveries: WebhookDelivery[]; onViewHook: (hookId: string) => void }) {
return <table className="w-full min-w-[980px] text-left text-sm"><thead><tr className="sticky top-0 z-10 border-b border-border bg-panel text-left text-xs text-muted"><th className="px-3 py-2">Received</th><th className="px-3 py-2">Hook</th><th className="px-3 py-2">Event</th><th className="px-3 py-2">Repository</th><th className="px-3 py-2">Result</th><th className="px-3 py-2">Delivery</th></tr></thead><tbody>{deliveries.map((delivery) => <tr key={delivery.delivery_id} className="border-b border-border/70 last:border-0"><td className="px-3 py-3 font-mono text-xs text-muted">{delivery.created_at}</td><td className="px-3 py-3">{delivery.hook_id ? <button type="button" className="text-left text-primary hover:underline" onClick={() => onViewHook(delivery.hook_id!)}><span className="block font-semibold">{delivery.hook?.target ?? `Hook #${delivery.hook_id}`}</span><span className="font-mono text-xs">#{delivery.hook_id}</span></button> : <span className="text-xs text-muted">Unknown (legacy)</span>}</td><td className="px-3 py-3 font-semibold">{delivery.event_name}{delivery.action ? ` · ${delivery.action}` : ""}</td><td className="px-3 py-3">{delivery.repository ?? "—"}</td><td className="px-3 py-3"><span className="rounded border border-border bg-slate-50 px-2 py-1 text-xs font-semibold">{delivery.status}</span>{delivery.duplicate_count ? <span className="ml-2 font-mono text-xs text-muted">{delivery.duplicate_count} retries</span> : null}</td><td className="max-w-[14rem] truncate px-3 py-3 font-mono text-xs text-muted" title={delivery.delivery_id}>{delivery.delivery_id}</td></tr>)}</tbody></table>;
return <table className="w-full min-w-[1120px] text-left text-sm"><thead><tr className="sticky top-0 z-10 border-b border-border bg-panel text-left text-xs text-muted"><th className="px-3 py-2">Received</th><th className="px-3 py-2">Hook</th><th className="px-3 py-2">Event</th><th className="px-3 py-2">Repository</th><th className="px-3 py-2">Result</th><th className="px-3 py-2">Ingestion</th><th className="px-3 py-2">Delivery</th></tr></thead><tbody>{deliveries.map((delivery) => <tr key={delivery.delivery_id} className="border-b border-border/70 last:border-0"><td className="px-3 py-3 font-mono text-xs text-muted">{delivery.created_at}</td><td className="px-3 py-3">{delivery.hook_id ? <button type="button" className="text-left text-primary hover:underline" onClick={() => onViewHook(delivery.hook_id!)}><span className="block font-semibold">{delivery.hook?.target ?? `Hook #${delivery.hook_id}`}</span><span className="font-mono text-xs">#{delivery.hook_id}</span></button> : <span className="text-xs text-muted">Unknown (legacy)</span>}</td><td className="px-3 py-3 font-semibold">{delivery.event_name}{delivery.action ? ` · ${delivery.action}` : ""}</td><td className="px-3 py-3">{delivery.repository ?? "—"}</td><td className="px-3 py-3"><span className="rounded border border-border bg-slate-50 px-2 py-1 text-xs font-semibold">{delivery.status}</span>{delivery.duplicate_count ? <span className="ml-2 font-mono text-xs text-muted">{delivery.duplicate_count} retries</span> : null}</td><td className="px-3 py-3">{delivery.enqueue_status ? <><span className="rounded border border-border bg-slate-50 px-2 py-1 text-xs font-semibold">{delivery.enqueue_status}</span>{delivery.job_id ? <a className="ml-2 font-mono text-xs font-semibold text-primary hover:underline" href={`/jobs/${delivery.job_id}`}>Job #{delivery.job_id}</a> : null}</> : <span className="text-xs text-muted">shadow</span>}</td><td className="max-w-[14rem] truncate px-3 py-3 font-mono text-xs text-muted" title={delivery.delivery_id}>{delivery.delivery_id}</td></tr>)}</tbody></table>;
}

function LazyScrollFrame({ noun, hasMore, loading, onLoadMore, children, className }: { noun: string; hasMore: boolean; loading: boolean; onLoadMore?: () => void; children: React.ReactNode; className?: string }) {
Expand Down
29 changes: 21 additions & 8 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ accepts a possible duplicate rather than risk dropping a legitimate action.
2. **Shadow webhook (implemented):** verify signatures and persist shadow
receipts, but do not create jobs or claim canonical events. Compare coverage
and canonical keys with IMAP.
3. **Canary dual ingest:** allow webhook enqueue only for `enabledRepos`.
3. **Canary dual ingest:** allow webhook enqueue only for `webhookCanaryRepos`.
The unique event key guarantees that the first source wins.
4. **Webhook primary:** keep IMAP as a delayed fallback until a complete
operational cycle has no unexplained IMAP-only actionable events.
Expand Down Expand Up @@ -133,19 +133,32 @@ any non-`created` action can enqueue a job.
Set `GITHUB_AGENT_BRIDGE_WEBHOOK_MODE=canary` and point
`GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY` at the reader/executor policy file. Canary
mode converts only supported actionable deliveries into the common queue and
requires their repository to be explicitly listed in `enabledRepos`; an empty
allowlist enqueues nothing. IMAP continues unchanged. Both transports use the
same canonical event key, so the first committed receipt wins and the second is
recorded as a duplicate of the same job.
requires their repository to be explicitly listed in `webhookCanaryRepos`; an
empty allowlist enqueues nothing. This allowlist is deliberately separate from
`enabledRepos`, which remains the hard scope for every transport, so narrowing
the webhook canary does not deny IMAP work for other repositories. IMAP
continues unchanged. Both transports use the same canonical event key, so the
first committed receipt wins and the second is recorded as a duplicate of the
same job.

For enqueueing, the common queue transaction commits before the monitoring
receipt. A crash in that narrow gap cannot lose work: GitHub retries the
receipt. The receipt records the enqueue decision and linked job so canary
behavior is auditable in the delivery explorer. A crash in that narrow gap
cannot lose work: GitHub retries the
delivery, the durable `ingest_receipts(source='webhook', source_key=<delivery>)`
row makes the queue operation idempotent, and the retry repairs the monitoring
receipt. `edited` comments/reviews, unsupported families, and repositories
outside `enabledRepos` remain observational only. For `workflow_run.completed`,
receipt. Events sent by a configured `botLogins` identity, `edited`
comments/reviews, unsupported families, and repositories outside
`webhookCanaryRepos` remain observational only. For `workflow_run.completed`,
only runs with `conclusion: failure` enqueue work; successful and other
conclusions remain observational.

Coverage compares only canonical event families shared by both transports,
starting at the first retained webhook receipt and ending before a configurable
grace period. It excludes source-specific `email:*` fallbacks and historical
IMAP receipts from before webhook observation began. Set
`GITHUB_AGENT_BRIDGE_WEBHOOK_COVERAGE_GRACE_SECONDS` to change the default
ten-minute grace period.

Webhook enqueueing must not be enabled until recovery of persisted-but-
unprocessed receipts and divergence metrics have been validated in production.
Loading
Loading