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
25 changes: 25 additions & 0 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SystemdUnits,
UserMenu,
WebPushControl,
WebhookPage,
buildJobQuery,
buildKnowledgeQuery,
changelogMarkdown,
Expand All @@ -28,6 +29,7 @@ import {
isMcpPath,
isRetryableStatus,
isSystemPath,
isWebhooksPath,
metricsSummaryPath,
runtimeBucketLabel,
selectedJobIdFromPath,
Expand Down Expand Up @@ -71,6 +73,12 @@ describe("dashboard routing and API query helpers", () => {
expect(isSystemPath("/system/processes")).toBe(false);
});

it("recognizes the webhook monitoring route", () => {
expect(isWebhooksPath("/webhooks")).toBe(true);
expect(isWebhooksPath("/webhooks/")).toBe(true);
expect(isWebhooksPath("/webhooks/github")).toBe(false);
});

it("recognizes only canonical job detail routes", () => {
expect(selectedJobIdFromPath("/jobs/45")).toBe(45);
expect(selectedJobIdFromPath("/jobs/45/")).toBe(45);
Expand All @@ -97,6 +105,23 @@ describe("dashboard routing and API query helpers", () => {
expect(screen.getByRole("link", { name: /MCP/i })).toHaveClass("bg-primary");
});

it("shows the webhook section only when shadow ingestion is configured", () => {
const { rerender } = render(<SectionNav isDashboardRoute={true} isKnowledgeRoute={false} showWebhooks={false} />);
expect(screen.queryByRole("link", { name: /Webhooks/i })).not.toBeInTheDocument();

rerender(<SectionNav isDashboardRoute={false} isKnowledgeRoute={false} isWebhooksRoute={true} showWebhooks={true} />);
expect(screen.getByRole("link", { name: /Webhooks/i })).toHaveClass("bg-primary");
});

it("renders the webhook status exported by the backend", () => {
render(<WebhookPage status={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3 }} loading={false} error={null} onRefresh={vi.fn()} />);

expect(screen.getByRole("heading", { name: "GitHub webhooks" })).toBeInTheDocument();
expect(screen.getByText("shadow")).toBeInTheDocument();
expect(screen.getByText("observed")).toBeInTheDocument();
expect(screen.getAllByText("7").length).toBeGreaterThan(0);
});

it("uses client-side navigation for dashboard section links", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
Expand Down
62 changes: 59 additions & 3 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type DashboardStatus = {
dashboard_url?: string;
dashboard_url_source?: "configured" | "forwarded" | "request";
admin_actions: string[];
webhook_configured?: boolean;
autoupdate: AutoupdateState;
metrics?: {
knowledge?: {
Expand All @@ -68,6 +69,14 @@ type DashboardStatus = {
};
};

type WebhookStatus = {
mode: string;
configured: boolean;
receipts: Record<string, number>;
duplicate_deliveries: number;
cross_source_matches: number;
};

type AutoupdateState = {
updated_at?: string;
installed_version?: string;
Expand Down Expand Up @@ -785,6 +794,10 @@ function isSystemPath(pathname = window.location.pathname) {
return /^\/system\/?$/.test(pathname);
}

function isWebhooksPath(pathname = window.location.pathname) {
return /^\/webhooks\/?$/.test(pathname);
}

function repoFromScope(scope: string) {
return scope.startsWith("repo:") ? scope.slice("repo:".length) : scope;
}
Expand Down Expand Up @@ -820,7 +833,8 @@ function App() {
const isKnowledgeRoute = isKnowledgePath(pathname);
const isMcpRoute = isMcpPath(pathname);
const isSystemRoute = isSystemPath(pathname);
const isDashboardRoute = !isJobDetailRoute && !isKnowledgeRoute && !isMcpRoute && !isSystemRoute;
const isWebhooksRoute = isWebhooksPath(pathname);
const isDashboardRoute = !isJobDetailRoute && !isKnowledgeRoute && !isMcpRoute && !isSystemRoute && !isWebhooksRoute;
const selectedJobId = jobRouteId;
const metrics = useQuery({ queryKey: ["metrics", dashboardTimeZone], queryFn: () => api<{ metrics: MetricsSummary }>(metricsSummaryPath()), enabled: isDashboardRoute || isSystemRoute });
const dashboardStatus = useQuery({ queryKey: ["dashboard-status"], queryFn: () => api<DashboardStatus>("/api/status") });
Expand All @@ -837,6 +851,11 @@ function App() {
const processes = useQuery({ queryKey: ["processes"], queryFn: () => api<ProcessesResponse>("/api/processes"), enabled: isSystemRoute });
const systemd = useQuery({ queryKey: ["systemd"], queryFn: () => api<SystemdResponse>("/api/systemd"), enabled: isSystemRoute });
const alerts = useQuery({ queryKey: ["alerts"], queryFn: () => api<{ alerts: AlertRecord[] }>("/api/alerts"), enabled: isSystemRoute });
const webhookStatus = useQuery({
queryKey: ["webhook-status"],
queryFn: () => api<WebhookStatus>("/api/webhooks/github/status"),
enabled: isWebhooksRoute && Boolean(me.data?.user?.is_admin && dashboardStatus.data?.webhook_configured),
});
const knowledge = useQuery({
queryKey: ["knowledge", knowledgeRepo, knowledgeStatus],
queryFn: () => api<KnowledgeResponse>(buildKnowledgeQuery(knowledgeRepo, knowledgeStatus)),
Expand Down Expand Up @@ -1092,7 +1111,7 @@ function App() {
</header>

<main className="mx-auto grid w-full max-w-[1440px] gap-4 px-3 py-4 sm:px-4 md:px-6 md:py-5">
<SectionNav isDashboardRoute={isDashboardRoute} isSystemRoute={isSystemRoute} isKnowledgeRoute={isKnowledgeRoute} isMcpRoute={isMcpRoute} knowledgeBadgeCount={dashboardStatus.data?.metrics?.knowledge?.proposed ?? 0} onNavigate={navigateDashboard} />
<SectionNav isDashboardRoute={isDashboardRoute} isSystemRoute={isSystemRoute} isKnowledgeRoute={isKnowledgeRoute} isMcpRoute={isMcpRoute} isWebhooksRoute={isWebhooksRoute} showWebhooks={Boolean(me.data?.user?.is_admin && dashboardStatus.data?.webhook_configured)} knowledgeBadgeCount={dashboardStatus.data?.metrics?.knowledge?.proposed ?? 0} onNavigate={navigateDashboard} />
<WebPushToast notification={inAppPush} onDismiss={() => setInAppPush(null)} onNavigate={navigateDashboard} />
{jobRouteId !== null ? (
<JobDetailPage
Expand Down Expand Up @@ -1143,6 +1162,8 @@ function App() {
onRevoke={revokeMcpToken}
onRefresh={() => mcpTokens.refetch()}
/>
) : isWebhooksRoute ? (
<WebhookPage status={webhookStatus.data} loading={webhookStatus.isLoading} error={webhookStatus.error} onRefresh={() => webhookStatus.refetch()} />
) : isSystemRoute ? (
<SystemPage
processes={processes.data}
Expand Down Expand Up @@ -1418,13 +1439,17 @@ function SectionNav({
isSystemRoute = false,
isKnowledgeRoute,
isMcpRoute = false,
isWebhooksRoute = false,
showWebhooks = false,
knowledgeBadgeCount = 0,
onNavigate,
}: {
isDashboardRoute: boolean;
isSystemRoute?: boolean;
isKnowledgeRoute: boolean;
isMcpRoute?: boolean;
isWebhooksRoute?: boolean;
showWebhooks?: boolean;
knowledgeBadgeCount?: number;
onNavigate?: (path: string) => void;
}) {
Expand All @@ -1438,6 +1463,12 @@ function SectionNav({
<Gauge className="h-4 w-4" aria-hidden />
<span>System</span>
</SectionLink>
{showWebhooks ? (
<SectionLink href="/webhooks" active={isWebhooksRoute} onNavigate={onNavigate}>
<Activity className="h-4 w-4" aria-hidden />
<span>Webhooks</span>
</SectionLink>
) : null}
<SectionLink href="/knowledge" active={isKnowledgeRoute} onNavigate={onNavigate}>
<Brain className="h-4 w-4" aria-hidden />
<span>Knowledge</span>
Expand All @@ -1461,6 +1492,29 @@ function SectionNav({
);
}

function WebhookPage({ status, loading, error, onRefresh }: { status: WebhookStatus | undefined; loading: boolean; error: Error | null; onRefresh: () => void }) {
const observed = Object.values(status?.receipts ?? {}).reduce((total, count) => total + count, 0);
return (
<section className="grid gap-4">
<PageTitle icon={<Activity className="h-5 w-5 text-muted" aria-hidden />} title="GitHub webhooks" subtitle="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="grid grid-cols-2 gap-3 xl:grid-cols-4" aria-label="Webhook status">
<Metric title="Receipts" value={observed} icon={<Activity className="h-5 w-5" />} />
<Metric title="Duplicates" value={status?.duplicate_deliveries ?? 0} icon={<RefreshCw className="h-5 w-5" />} />
<Metric title="Cross-source matches" value={status?.cross_source_matches ?? 0} icon={<Link className="h-5 w-5" />} />
<Metric title="Mode" value={loading ? "…" : status?.mode ?? "unknown"} icon={<Eye className="h-5 w-5" />} />
</div>
<Panel title="Receipt states">
{loading ? <EmptyState text="Loading webhook status..." /> : Object.keys(status?.receipts ?? {}).length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(status?.receipts ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([state, count]) => <MiniStat key={state} label={state} value={count} />)}
</div>
) : <EmptyState text="No webhook deliveries observed yet." />}
</Panel>
</section>
);
}

function SystemPage({
processes,
processesLoading,
Expand Down Expand Up @@ -2609,7 +2663,7 @@ function Panel({ title, action, children, className, flushHeader = false }: { ti
);
}

function Metric({ title, value, icon }: { title: string; value: number; icon: React.ReactNode }) {
function Metric({ title, value, icon }: { title: string; value: number | string; icon: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-panel p-3 shadow-sm md:p-4">
<div className="flex items-center justify-between text-muted">
Expand Down Expand Up @@ -4059,6 +4113,7 @@ export {
isKnowledgePath,
isMcpPath,
isSystemPath,
isWebhooksPath,
isRetryableStatus,
groupSessionEvents,
groupTranscriptEntries,
Expand All @@ -4068,6 +4123,7 @@ export {
selectedJobIdFromPath,
shouldRefreshJobForSessionEvent,
urlBase64ToUint8Array,
WebhookPage,
};

const root = document.getElementById("root");
Expand Down
68 changes: 62 additions & 6 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ accepts a possible duplicate rather than risk dropping a legitimate action.

| Concern | IMAP | GitHub App webhook |
| --- | --- | --- |
| Trust | Auth headers and GitHub sender checks | Mandatory HMAC-SHA256 signature over raw bytes, plus installation/repository policy |
| Trust | Auth headers and GitHub sender checks | Mandatory HMAC-SHA256 signature over raw bytes; optional owner allowlist through owner-specific secrets |
| Idempotency | `Message-ID` receipt | `X-GitHub-Delivery` receipt |
| Cross-source identity | IDs parsed from GitHub URLs | IDs in the structured payload |
| Latency | Polling and mail delivery | Immediate delivery with retries |
Expand All @@ -33,13 +33,69 @@ accepts a possible duplicate rather than risk dropping a legitimate action.

1. **Phase 0 (implemented):** route IMAP through the common transactional
ingestor while preserving the existing queue and dispatch behavior.
2. **Shadow webhook:** verify signatures and persist receipts/events, but do
not create jobs. Compare coverage and canonical keys with IMAP.
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`.
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.

Webhook ingestion must not be enabled until raw-body signature verification,
payload-size limits, secret rotation, recovery of persisted-but-unprocessed
receipts, and metrics for source divergence are in place.
Phase 1 is exposed as `POST /api/webhooks/github` by the dashboard service.
For a single trusted owner, configure `GITHUB_AGENT_BRIDGE_WEBHOOK_SECRET`;
during rotation, `GITHUB_AGENT_BRIDGE_WEBHOOK_PREVIOUS_SECRET` accepts the old
secret as well. This legacy form accepts any repository signed with that shared
secret.

For multiple organizations or owners, use independent secrets and an explicit
owner allowlist:

```shell
export GITHUB_AGENT_BRIDGE_WEBHOOK_SECRETS_BY_OWNER='{"gisce":["current-gisce-secret","previous-gisce-secret"],"example":["example-secret"]}'
```

Each value is an ordered list of accepted current/rotation secrets. When this
setting is present, a payload whose `repository.full_name` owner is not in the
map is rejected before persistence. Do not reuse a secret across owners: that
would couple rotation and increase the blast radius of a leak.

`GITHUB_AGENT_BRIDGE_WEBHOOK_MAX_BYTES` defaults to 1 MiB. GitHub must send
`Content-Type: application/json`, `X-GitHub-Delivery`, `X-GitHub-Event`, and a
valid `X-Hub-Signature-256` computed over the unmodified request bytes.

### GitHub configuration

Create either a repository webhook under **Settings → Webhooks** or an
organization webhook under **Organization settings → Webhooks**:

1. Set **Payload URL** to `https://<host>/api/webhooks/github`.
2. Set **Content type** to `application/json` and **Secret** to the matching
configured owner secret.
3. Keep SSL verification enabled and the webhook active.
4. Select individual events: **Issue comments**, **Pull request reviews**,
**Pull request review comments**, **Commit comments**, and **Workflow runs**.
Do not select “Send me everything” for Phase 1.

A repository webhook covers only that repository. An organization webhook
covers repositories in that organization and is the recommended deployment.
Multiple organizations may use the same endpoint when each owner has its own
entry in `GITHUB_AGENT_BRIDGE_WEBHOOK_SECRETS_BY_OWNER`.

The endpoint stores only routing metadata, a SHA-256 payload hash, and the
canonical event key in `webhook_shadow_receipts`; it deliberately stores no raw
payload and never creates a queue job. Authenticated operators can inspect
counts, duplicate deliveries, and cross-source event-key matches at
`GET /api/webhooks/github/status`. Here “operators” means users authorized as
dashboard administrators through `GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS`
or `GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS`; other authenticated dashboard
users receive HTTP 403 and unauthenticated requests receive HTTP 401.

The maintained event inventory and support levels are in
[`webhook-events.md`](webhook-events.md).

Comment and review `edited` deliveries are observed under a distinct key and
do not retrigger work. Phase 2 must make an explicit policy decision before
any non-`created` action can enqueue a job.

Webhook enqueueing must not be enabled until recovery of persisted-but-
unprocessed receipts and divergence metrics have been validated in production.
27 changes: 27 additions & 0 deletions docs/webhook-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# GitHub webhook event inventory

This inventory tracks GitHub's documented webhook event names and the bridge's
Phase 1 support. It is an integration backlog as well as an operator reference.
GitHub may add events, so compare it with the official [webhook events and
payloads](https://docs.github.com/en/webhooks/webhook-events-and-payloads) when
planning a new integration. Last reviewed: 2026-10-02.

Support levels:

- **Canonical shadow**: persisted with an immutable canonical event key and
eligible for IMAP/webhook coverage comparison. It never creates a job in
Phase 1.
- **Observed only**: a signed delivery can be persisted as `unsupported`, but
no canonical key is derived.
- **Not selected**: operators should not subscribe to it in Phase 1.

| Support | GitHub event names |
| --- | --- |
| Canonical shadow | `commit_comment`, `issue_comment`, `pull_request_review`, `pull_request_review_comment`, `workflow_run` |
| Observed only / not selected | `branch_protection_configuration`, `branch_protection_rule`, `check_run`, `check_suite`, `code_scanning_alert`, `create`, `custom_property`, `custom_property_values`, `delete`, `dependabot_alert`, `deploy_key`, `deployment`, `deployment_protection_rule`, `deployment_review`, `deployment_status`, `discussion`, `discussion_comment`, `fork`, `github_app_authorization`, `gollum`, `installation`, `installation_repositories`, `installation_target`, `issue_dependencies`, `issue_relates_to`, `issues`, `label`, `marketplace_purchase`, `member`, `membership`, `merge_group`, `meta`, `milestone`, `org_block`, `organization`, `package`, `page_build`, `personal_access_token_request`, `ping`, `project`, `project_card`, `project_column`, `projects_v2`, `projects_v2_item`, `projects_v2_status_update`, `public`, `pull_request`, `pull_request_review_thread`, `push`, `registry_package`, `release`, `repository`, `repository_advisory`, `repository_dispatch`, `repository_import`, `repository_ruleset`, `repository_vulnerability_alert`, `secret_scanning_alert`, `secret_scanning_alert_location`, `secret_scanning_scan`, `security_advisory`, `security_and_analysis`, `sponsorship`, `star`, `status`, `sub_issues`, `team`, `team_add`, `watch`, `workflow_dispatch`, `workflow_job` |

The inventory names event families, not every `action` value. Actions are
tracked separately because their delivery semantics differ. For comments and
reviews, `created`/`submitted` has the actionable canonical identity; `edited`
and other actions remain distinct observational identities and must not
retrigger work without an explicit Phase 2 policy decision.
Loading
Loading