FN-8885: add GitHub CI signal ingestion

Ingest signed GitHub CI webhook outcomes into Command Center incidents.

- Support check_suite, workflow_run, and status events with HMAC verification.
- Resolve the latest matching open incident for successful recovery events without creating tasks.
- Document GitHub signal configuration and add connector, route, and monitor-store coverage.

Files changed:
 .changeset/fn-8885-github-signals-connector.md     |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   1 +
 docs/signals-connectors.md                         |  31 ++++-
 .../core/src/task-store/async/async-monitor.ts     |  30 ++---
 packages/dashboard/README.md                       |   5 +-
 .../src/__tests__/github-signal-source.test.ts     |  49 ++++++++
 .../src/__tests__/monitor-store.pg.test.ts         |  35 ++++++
 .../src/__tests__/register-signal-routes.test.ts   | 132 +++++++++++++++++++-
 packages/dashboard/src/monitor-store.ts            |  28 ++---
 .../dashboard/src/routes/register-signal-routes.ts |  25 ++++
 packages/dashboard/src/signal-source.ts            |  17 ++-
 packages/dashboard/src/signal-sources/github.ts    | 135 +++++++++++++++++++++
 13 files changed, 461 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8885

Fusion-Task-Lineage: d2e9f6ed-95fe-4387-81bd-2ee67b2e1283

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-09 06:51:22 -07:00
parent 98ad663011
commit 569d2eee91
13 changed files with 461 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add signed GitHub CI signal ingestion with taskless green recovery.
category: feature
dev: FUSION_SIGNAL_GITHUB_SECRET enables POST /api/signals/github; recovery-only greens use atomic single-incident conditional resolution.

View File

@@ -1376,7 +1376,7 @@ GitLab settings are collapsed by default to keep Settings less noisy. Use **Sett
When `gitlabEnabled` is off, Fusion keeps saved GitLab URLs and tokens intact but disables outbound GitLab API work: the Import Tasks GitLab provider tab is hidden and restored GitLab import state opens on GitHub instead, API/CLI/pi import paths reject before network calls, and lifecycle comments/close/reconcile/refresh paths skip with diagnostics. Existing imported-task GitLab metadata remains viewable. GitHub imports and GitHub settings are unchanged. GitLab Signals inbound webhooks are configured separately by `FUSION_SIGNAL_GITLAB_SECRET`; they are not governed by the outbound GitLab API enable toggle.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/github`, `/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
- **System** is the canonical system-telemetry destination and Command Center report home. Its guided **Report** menu files Bug, Feedback, Idea, or Help reports without opening a raw prefilled GitHub issue; **Copy diagnostics** remains a separate local control. System reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Tokens CSV includes `nTasks` and `nChatMessages` columns so mixed task/chat totals can be reconciled without relabeling chat turns as tasks. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.

View File

@@ -39,6 +39,7 @@ Command Center signal connectors are configured with process environment variabl
| `FUSION_SIGNAL_DATADOG_SECRET` | Datadog | `POST /api/signals/datadog` | Verifies the custom `X-Datadog-Signature` HMAC header; optional `X-Datadog-Timestamp` bounds replay. |
| `FUSION_SIGNAL_PAGERDUTY_SECRET` | PagerDuty | `POST /api/signals/pagerduty` | Verifies `X-PagerDuty-Signature` (`v1=<hex>`). |
| `FUSION_SIGNAL_GITLAB_SECRET` | GitLab | `POST /api/signals/gitlab` | Verifies GitLab's `X-Gitlab-Token` secret-token header for GitLab.com or self-managed project/group issue and merge-request webhooks. |
| `FUSION_SIGNAL_GITHUB_SECRET` | GitHub | `POST /api/signals/github` | Verifies `X-Hub-Signature-256` for `check_suite`, `workflow_run`, and `status` events; successful CI is taskless recovery-only. |
| `FUSION_MONITOR_INGEST_SECRET` | Monitor incidents API | `POST /api/monitor/incidents` | Separate bearer-token path for direct monitor ingestion; it is not used by `/api/signals/:provider`. |
See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, and open/resolved mapping details.

View File

@@ -1,12 +1,12 @@
# Signals Connectors
Fusion can receive signed external signals from GitLab, Sentry, Datadog, PagerDuty, or a generic webhook at:
Fusion can receive signed external signals from GitHub, GitLab, Sentry, Datadog, PagerDuty, or a generic webhook at:
```text
POST /api/signals/:provider
```
Supported providers are `webhook`, `gitlab`, `sentry`, `datadog`, and `pagerduty`. Every connector requires a verification secret configured in the Fusion dashboard process environment. Generic webhook, Sentry, Datadog, and PagerDuty use HMAC signatures; GitLab uses GitLab's `X-Gitlab-Token` secret-token header. Verified signals still create triage tasks, and they also write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns.
Supported providers are `webhook`, `github`, `gitlab`, `sentry`, `datadog`, and `pagerduty`. Every connector requires a verification secret configured in the Fusion dashboard process environment. Generic webhook, GitHub, Sentry, Datadog, and PagerDuty use HMAC signatures; GitLab uses GitLab's `X-Gitlab-Token` secret-token header. Actionable open signals create triage tasks and write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns. GitHub recovery-only green CI signals are the exception: they create neither a task nor a new incident.
## Runtime behavior
@@ -19,7 +19,7 @@ Supported providers are `webhook`, `gitlab`, `sentry`, `datadog`, and `pagerduty
## Security model
- Secrets are environment variables; do not commit them to source control.
- HMAC verification uses the raw request body and constant-time comparison where the provider supplies an HMAC signature. GitLab secret-token verification compares `X-Gitlab-Token` to `FUSION_SIGNAL_GITLAB_SECRET` with constant-time comparison.
- HMAC verification uses the raw request body and constant-time comparison where the provider supplies an HMAC signature. GitHub uses `X-Hub-Signature-256`; GitLab secret-token verification compares `X-Gitlab-Token` to `FUSION_SIGNAL_GITLAB_SECRET` with constant-time comparison.
- Requests are capped at about 1 MB.
- Replay protection rejects stale timestamps where the provider supplies one and rejects repeated delivery ids within the replay window.
- Normalized `title`, `body`, `groupingKey`, `link`, and `meta` fields are capped by `signal-source.ts` before storage.
@@ -126,6 +126,31 @@ Normalization:
- `severity`: explicit `data.severity` when it is one of Fusion's normalized severities; otherwise high urgency maps to `critical` and other events map to `warning`.
- Resolution: `event.event_type === "incident.resolved"` or `data.status === "resolved"` resolves the grouped incident; other incident events open/absorb it.
## GitHub
Set `FUSION_SIGNAL_GITHUB_SECRET` and configure `https://<your-fusion-host>/api/signals/github` for GitHub `check_suite`, `workflow_run`, and `status` deliveries. Fusion verifies the raw body using `X-Hub-Signature-256`.
Normalization uses:
- `source`: `github`.
- `groupingKey`: `github:<repo>:<kind>:<check-name>:<head-sha>`.
- `externalId`: `delivery:<X-GitHub-Delivery>` when available; otherwise a grouping-key/outcome/timestamp fallback so a failure and recovery are distinct.
- `title`: repository, check name, and outcome; `link`: the safe GitHub run/target URL or repository commit URL fallback.
- `severity` and `resolution`: the terminal-outcome mapping below.
Terminal outcomes map as follows:
| Outcome | Severity | Resolution |
| --- | --- | --- |
| `failure`, `timed_out`, `startup_failure`, `stale`, `error` | error | open |
| `action_required`, `cancelled` | warning | open |
| `success`, `neutral`, `skipped` | info | resolved, recovery-only |
| unknown future outcome | warning | open (fail-visible) |
Pending, queued, in-progress, and ping deliveries are accepted without tasks or incidents. Recovery-only green outcomes create no triage task or incident: they atomically resolve only the newest already-open incident with a status-gated conditional update. Cold, redelivered, and concurrent greens write nothing; only one concurrent caller can resolve an open row, and no delivery can rewrite `resolvedAt` after that.
Upstream evidence: [GitHub webhook events](https://docs.github.com/en/webhooks/webhook-events-and-payloads) and [signature validation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries).
## GitLab
Set:

View File

@@ -26,7 +26,7 @@
* consume.
*/
import { randomUUID } from "node:crypto";
import { and, desc, eq, gte, isNull, notLike, sql } from "drizzle-orm";
import { and, desc, eq, gte, inArray, isNull, notLike, sql } from "drizzle-orm";
import * as schema from "../../postgres/schema/index.js";
import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js";
@@ -336,14 +336,11 @@ export async function ingestIncidentSignalAsync(
return { incident, created: true };
}
/**
* Resolve an open incident for a grouping key (sets `status = resolved` +
* `resolvedAt`). Returns the resolved incident, or null if none was open.
*
* @param db The Drizzle instance (or transaction handle) from the AsyncDataLayer.
* @param groupingKey The signal grouping key.
* @param at Optional resolution timestamp (ISO-8601); defaults to now.
*/
/*
FNXC:Monitor 2026-08-09-13:23:
Recovery-only GitHub CI signals have no task-marker dedup. Resolve exactly the newest open incident in one status-gated UPDATE: MVCC locks make a concurrent loser re-check `status = 'open'` and change nothing, so resolvedAt is written once. The LIMIT 1 selection deliberately preserves older open rows because schema permits multiple rows per key; claimIncidentForFixTaskAsync is the sibling conditional-update precedent.
*/
/** Resolves the newest open incident for a grouping key in one conditional UPDATE (at most one row). Older open rows remain untouched; only the caller whose statement changed a row receives it, and every loser/no-open caller receives null. */
export async function resolveIncidentAsync(
db: AsyncDataLayer["db"] | DbTransaction,
groupingKey: string,
@@ -351,14 +348,19 @@ export async function resolveIncidentAsync(
projectId = "",
): Promise<Incident | null> {
const ownerProjectId = monitorProjectPartition(projectId);
const open = await getOpenIncidentByGroupingKeyAsync(db, groupingKey, ownerProjectId);
if (!open) return null;
const now = at ?? new Date().toISOString();
await db
const newestOpen = db
.select({ incidentId: schema.project.incidents.incidentId })
.from(schema.project.incidents)
.where(and(eq(schema.project.incidents.projectId, ownerProjectId), eq(schema.project.incidents.groupingKey, groupingKey), eq(schema.project.incidents.status, "open")))
.orderBy(desc(schema.project.incidents.openedAt), desc(schema.project.incidents.id))
.limit(1);
const rows = await db
.update(schema.project.incidents)
.set({ status: "resolved", resolvedAt: now, updatedAt: now })
.where(and(eq(schema.project.incidents.projectId, ownerProjectId), eq(schema.project.incidents.incidentId, open.incidentId)));
return getIncidentAsync(db, open.incidentId, ownerProjectId);
.where(and(eq(schema.project.incidents.projectId, ownerProjectId), eq(schema.project.incidents.status, "open"), inArray(schema.project.incidents.incidentId, newestOpen)))
.returning();
return rows[0] ? incidentFromRow(rows[0]) : null;
}
/**

View File

@@ -769,7 +769,7 @@ For real-time PR/issue badge updates, configure a GitHub App instead of relying
**Fallback Behavior:**
When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state).
### External Signal Ingestion (Sentry / Datadog / PagerDuty / generic webhook)
### External Signal Ingestion (GitHub / Sentry / Datadog / PagerDuty / generic webhook)
Inbound signals from error trackers and alerting tools are ingested into triage
tasks via `POST /api/signals/:provider`. Every endpoint requires a valid HMAC
@@ -788,6 +788,9 @@ source-controlled:
verifies `X-Datadog-Signature`; `groupingKey` = monitor `aggreg_key`/`alert_id`.
- `FUSION_SIGNAL_PAGERDUTY_SECRET` — PagerDuty (`POST /api/signals/pagerduty`),
verifies `X-PagerDuty-Signature` (`v1=<hex>`); `groupingKey` = `incident.id`.
- `FUSION_SIGNAL_GITHUB_SECRET` — GitHub (`POST /api/signals/github`), verifies
`X-Hub-Signature-256` for `check_suite`, `workflow_run`, and `status`. Terminal
success/neutral/skipped deliveries atomically resolve an existing incident without a triage task.
**Security:** mandatory HMAC (401 on missing/invalid secret or signature),
replay window (±5 min) + delivery-id nonce dedup, persistent external-id dedup,

View File

@@ -0,0 +1,49 @@
// @vitest-environment node
import { createHmac } from "node:crypto";
import { expect, it } from "vitest";
import { GITHUB_OUTCOME_MAP, githubSource } from "../signal-sources/github.js";
const secret = "github-secret";
function context(payload: object, event: string, delivery = "delivery-1") {
const rawBody = Buffer.from(JSON.stringify(payload));
return { rawBody, secret, headers: { "x-github-event": event, "x-github-delivery": delivery, "x-hub-signature-256": `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}` } };
}
function payload(kind: "check_suite" | "workflow_run" | "status", outcome: string) {
const repository = { full_name: "org/repo", html_url: "https://github.com/org/repo" };
if (kind === "status") return { repository, state: outcome, context: "build", sha: "abc123", target_url: "https://github.com/org/repo/actions/1", created_at: "2026-08-09T12:00:00Z" };
const event = { status: "completed", conclusion: outcome, head_sha: "abc123", head_branch: "main", updated_at: "2026-08-09T12:00:00Z", app: { slug: "checks" } };
return kind === "check_suite" ? { repository, check_suite: event } : { repository, workflow: { name: "build" }, workflow_run: event };
}
it("verifies GitHub signatures and exhaustively maps terminal outcomes", () => {
expect(Object.keys(GITHUB_OUTCOME_MAP).sort()).toEqual([
"action_required", "cancelled", "error", "failure", "neutral", "skipped", "stale", "startup_failure", "success", "timed_out",
]);
for (const [outcome, expected] of Object.entries(GITHUB_OUTCOME_MAP)) {
for (const kind of ["check_suite", "workflow_run", "status"] as const) {
const body = payload(kind, outcome);
const ctx = context(body, kind);
expect(githubSource.verify(ctx).valid).toBe(true);
const signal = githubSource.normalize(body, ctx);
expect(signal).toMatchObject({ severity: expected.severity, resolution: expected.resolution });
expect(signal?.recoveryOnly).toBe("recoveryOnly" in expected ? true : undefined);
}
}
});
it("leaves unknown outcomes visible and pending events non-actionable", () => {
const unknown = payload("check_suite", "future_outcome");
expect(githubSource.normalize(unknown, context(unknown, "check_suite"))).toMatchObject({ severity: "warning", resolution: "open" });
const pending = { repository: { full_name: "org/repo" }, state: "pending", context: "build", sha: "abc" };
expect(githubSource.normalize(pending, context(pending, "status"))).toBeNull();
});
it("honors a present event header instead of falling back to a status-shaped payload", () => {
const status = payload("status", "failure");
expect(githubSource.normalize(status, context(status, "ping"))).toBeNull();
expect(githubSource.normalize(status, context(status, "issues"))).toBeNull();
const headerless = context(status, "status");
delete headerless.headers["x-github-event"];
expect(githubSource.normalize(status, headerless)).toMatchObject({ groupingKey: "github:org/repo:status:build:abc123" });
});

View File

@@ -25,6 +25,41 @@ pgDescribe("monitor-store PostgreSQL project binding", () => {
afterEach(h.afterEach);
afterAll(h.afterAll);
it("resolves only one newest incident and makes concurrent recovery idempotent", async () => {
const layer = { ...h.layer(), projectId: "monitor-atomic-project" };
const key = "atomic-incident";
const firstRecoveryAt = "2026-08-09T11:00:00.000Z";
const secondRecoveryAt = "2026-08-09T12:00:00.000Z";
await ingestIncidentSignal(layer, { groupingKey: key, title: "first", at: "2026-08-09T10:00:00.000Z" });
const [one, two] = await Promise.all([
resolveIncident(layer, key, firstRecoveryAt),
resolveIncident(layer, key, secondRecoveryAt),
]);
expect([one, two].filter(Boolean)).toHaveLength(1);
const row = (await layer.db.select().from(schema.project.incidents)).find((incident) => incident.groupingKey === key);
expect(row).toMatchObject({ status: "resolved" });
expect(row?.resolvedAt).toBe(one ? firstRecoveryAt : secondRecoveryAt);
await layer.db.insert(schema.project.incidents).values([
{ projectId: layer.projectId, incidentId: "older", groupingKey: "multi", title: "older", status: "open", openedAt: "2026-08-09T10:00:00.000Z", createdAt: "2026-08-09T10:00:00.000Z", updatedAt: "2026-08-09T10:00:00.000Z" },
{ projectId: layer.projectId, incidentId: "newer", groupingKey: "multi", title: "newer", status: "open", openedAt: "2026-08-09T11:00:00.000Z", createdAt: "2026-08-09T11:00:00.000Z", updatedAt: "2026-08-09T11:00:00.000Z" },
]);
expect((await resolveIncident(layer, "multi", "2026-08-09T12:00:00.000Z"))?.incidentId).toBe("newer");
let rows = (await layer.db.select().from(schema.project.incidents)).filter((row) => row.groupingKey === "multi");
expect(rows.find((row) => row.incidentId === "newer")).toMatchObject({ status: "resolved", resolvedAt: "2026-08-09T12:00:00.000Z" });
expect(rows.find((row) => row.incidentId === "older")).toMatchObject({ status: "open", resolvedAt: null });
expect((await resolveIncident(layer, "multi", "2026-08-09T13:00:00.000Z"))?.incidentId).toBe("older");
rows = (await layer.db.select().from(schema.project.incidents)).filter((row) => row.groupingKey === "multi");
const resolvedAts = rows.map((entry) => entry.resolvedAt);
expect(await resolveIncident(layer, "multi", "2026-08-09T14:00:00.000Z")).toBeNull();
expect((await layer.db.select().from(schema.project.incidents)).filter((entry) => entry.groupingKey === "multi").map((entry) => entry.resolvedAt)).toEqual(resolvedAts);
const reopened = await ingestIncidentSignal(layer, { groupingKey: "multi", title: "reopened", at: "2026-08-09T15:00:00.000Z" });
expect(reopened.created).toBe(true);
expect((await resolveIncident(layer, "multi", "2026-08-09T16:00:00.000Z"))?.incidentId).toBe(reopened.incident.incidentId);
expect((await layer.db.select().from(schema.project.incidents)).filter((entry) => entry.groupingKey === "multi" && entry.status === "resolved")).toHaveLength(3);
});
it("rejects unbound writes without rows and persists bound writes", async () => {
const layer = h.layer();
const deployment = { deploymentId: "bound-deployment", deployedAt: "2026-07-14T01:00:00.000Z" };

View File

@@ -17,6 +17,7 @@ import { sentrySource } from "../signal-sources/sentry.js";
import { datadogSource } from "../signal-sources/datadog.js";
import { pagerdutySource } from "../signal-sources/pagerduty.js";
import { gitlabSource } from "../signal-sources/gitlab.js";
import { GITHUB_OUTCOME_MAP, githubSource } from "../signal-sources/github.js";
function sign(body: string, secret: string): string {
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
@@ -64,12 +65,13 @@ async function makeDbStore() {
async function incidents(layer: AsyncDataLayer) {
// FNXC:PostgresCutover 2026-07-16-06:30: inspect seeded connector rows via
// the project schema instead of the removed synchronous SQLite Database API.
return await layer.db.execute(sql`SELECT grouping_key AS "groupingKey", source, severity, status, meta::text AS meta FROM project.incidents WHERE project_id = ${layer.projectId} ORDER BY id ASC`) as Array<{
return await layer.db.execute(sql`SELECT grouping_key AS "groupingKey", source, severity, status, resolved_at AS "resolvedAt", meta::text AS meta FROM project.incidents WHERE project_id = ${layer.projectId} ORDER BY id ASC`) as Array<{
groupingKey: string;
source: string | null;
severity: string | null;
status: string;
meta: string | null;
resolvedAt: string | null;
}>;
}
@@ -79,6 +81,7 @@ const SECRETS: Record<string, string> = {
FUSION_SIGNAL_DATADOG_SECRET: "datadog-secret",
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd-secret",
FUSION_SIGNAL_GITLAB_SECRET: "gitlab-secret",
FUSION_SIGNAL_GITHUB_SECRET: "github-secret",
};
const savedEnv: Record<string, string | undefined> = {};
@@ -106,6 +109,24 @@ function ctxFor(source: SignalSource, payload: object, headers: Record<string, s
return { rawBody, headers: lower, body: payload };
}
function githubPayload(kind: "check_suite" | "workflow_run" | "status", outcome: string) {
const repository = { full_name: "org/repo", html_url: "https://github.com/org/repo" };
if (kind === "status") {
return { repository, state: outcome, context: "build", sha: "abc123", target_url: "https://github.com/org/repo/actions/1", created_at: "2026-08-09T12:00:00.000Z" };
}
const run = { status: "completed", conclusion: outcome, head_sha: "abc123", head_branch: "main", updated_at: "2026-08-09T12:00:00.000Z", app: { slug: "checks" } };
return kind === "check_suite" ? { repository, check_suite: run } : { repository, workflow: { name: "build" }, workflow_run: run };
}
function githubContext(payload: object, event: "check_suite" | "workflow_run" | "status" | "ping", delivery: string) {
const raw = JSON.stringify(payload);
return ctxFor(githubSource, payload, {
"x-hub-signature-256": `sha256=${sign(raw, SECRETS.FUSION_SIGNAL_GITHUB_SECRET)}`,
"x-github-event": event,
"x-github-delivery": delivery,
});
}
function signedSignalContext(source: SignalSource, payload: object) {
const raw = JSON.stringify(payload);
switch (source.provider) {
@@ -122,16 +143,22 @@ function signedSignalContext(source: SignalSource, payload: object) {
return ctxFor(source, payload, { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` });
case "gitlab":
return ctxFor(source, payload, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET });
case "github":
return ctxFor(source, payload, {
"x-hub-signature-256": `sha256=${sign(raw, SECRETS.FUSION_SIGNAL_GITHUB_SECRET)}`,
"x-github-delivery": "github-delivery",
});
}
}
pgDescribe("getSignalSource registry", () => {
it("resolves all five providers and rejects unknown", () => {
it("resolves all six providers and rejects unknown", () => {
expect(getSignalSource("webhook")).toBe(webhookSource);
expect(getSignalSource("sentry")).toBe(sentrySource);
expect(getSignalSource("datadog")).toBe(datadogSource);
expect(getSignalSource("pagerduty")).toBe(pagerdutySource);
expect(getSignalSource("gitlab")).toBe(gitlabSource);
expect(getSignalSource("github")).toBe(githubSource);
expect(getSignalSource("bogus")).toBeUndefined();
});
});
@@ -576,6 +603,106 @@ pgDescribe("ingestSignal — GitLab adapter", () => {
});
});
pgDescribe("ingestSignal — GitHub CI recovery", () => {
it("opens failures and resolves green runs without tasks or durable duplicate incidents", async () => {
const { layer, store } = await makeDbStore();
const failure = githubPayload("check_suite", "failure");
const open = await ingestSignal({
source: githubSource,
store,
...githubContext(failure, "check_suite", "github-failure"),
nonceCache: new DeliveryNonceCache(),
});
expect(open.status).toBe(201);
expect(store._tasks).toHaveLength(1);
expect(await incidents(layer)).toMatchObject([{
groupingKey: "github:org/repo:check_suite:checks:abc123",
source: "github",
severity: "error",
status: "open",
}]);
const success = githubPayload("check_suite", "success");
const resolved = await ingestSignal({
source: githubSource,
store,
...githubContext(success, "check_suite", "github-success"),
nonceCache: new DeliveryNonceCache(),
});
expect(resolved).toMatchObject({ status: 200, recoveryResolved: true });
expect(resolved.taskId).toBeUndefined();
expect(store._tasks).toHaveLength(1);
const afterResolution = await incidents(layer);
expect(afterResolution).toHaveLength(1);
expect(afterResolution[0]).toMatchObject({ status: "resolved" });
const resolvedAt = afterResolution[0].resolvedAt;
const redelivery = await ingestSignal({
source: githubSource,
store,
...githubContext(success, "check_suite", "github-success-redelivery"),
nonceCache: new DeliveryNonceCache(),
});
expect(redelivery).toMatchObject({ status: 200, recoveryResolved: false });
expect(store._tasks).toHaveLength(1);
expect(await incidents(layer)).toEqual(expect.arrayContaining([expect.objectContaining({ status: "resolved", resolvedAt })]));
expect(await incidents(layer)).toHaveLength(1);
const cold = await ingestSignal({
source: githubSource,
store,
...githubContext(githubPayload("workflow_run", "success"), "workflow_run", "github-cold-green"),
nonceCache: new DeliveryNonceCache(),
});
expect(cold).toMatchObject({ status: 200, recoveryResolved: false });
expect(store._tasks).toHaveLength(1);
expect(await incidents(layer)).toHaveLength(1);
const reopened = await ingestSignal({
source: githubSource,
store,
...githubContext(failure, "check_suite", "github-failure-reopen"),
nonceCache: new DeliveryNonceCache(),
});
expect(reopened.status).toBe(201);
expect(store._tasks).toHaveLength(2);
expect(await incidents(layer)).toHaveLength(2);
});
it("routes all supported GitHub event kinds through the production ingestion seam", async () => {
for (const kind of ["check_suite", "workflow_run", "status"] as const) {
const { layer, store } = await makeDbStore();
const result = await ingestSignal({
source: githubSource,
store,
...githubContext(githubPayload(kind, "failure"), kind, `github-${kind}-failure`),
nonceCache: new DeliveryNonceCache(),
});
expect(result.status).toBe(201);
expect(store._tasks).toHaveLength(1);
expect(await incidents(layer)).toMatchObject([expect.objectContaining({ source: "github", severity: "error", status: "open" })]);
}
});
it("rejects GitHub auth failures and accepts pending or ping as non-actionable", async () => {
const payload = githubPayload("check_suite", "failure");
const rawBody = Buffer.from(JSON.stringify(payload));
const missingSignature = await ingestSignal({ source: githubSource, store: makeStore(), rawBody, headers: { "x-github-event": "check_suite" }, body: payload, nonceCache: new DeliveryNonceCache() });
expect(missingSignature.status).toBe(401);
delete process.env.FUSION_SIGNAL_GITHUB_SECRET;
const missingSecret = await ingestSignal({ source: githubSource, store: makeStore(), ...githubContext(payload, "check_suite", "missing-secret"), nonceCache: new DeliveryNonceCache() });
expect(missingSecret.status).toBe(401);
process.env.FUSION_SIGNAL_GITHUB_SECRET = SECRETS.FUSION_SIGNAL_GITHUB_SECRET;
const store = makeStore();
const pending = { repository: { full_name: "org/repo" }, state: "pending", context: "build", sha: "abc123" };
expect((await ingestSignal({ source: githubSource, store, ...githubContext(pending, "status", "github-pending"), nonceCache: new DeliveryNonceCache() })).status).toBe(200);
const ping = { repository: { full_name: "org/repo" }, state: "failure", context: "build", sha: "abc123" };
expect((await ingestSignal({ source: githubSource, store, ...githubContext(ping, "ping", "github-ping"), nonceCache: new DeliveryNonceCache() })).status).toBe(200);
expect(store._tasks).toHaveLength(0);
});
});
pgDescribe("ingestSignal — incident capture", () => {
it("writes source and normalized severity for all configured providers", async () => {
const cases = [
@@ -923,6 +1050,7 @@ pgDescribe("helpers", () => {
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd",
FUSION_SIGNAL_GITLAB_SECRET: "gl",
})).toEqual(["webhook", "pagerduty", "gitlab"]);
expect(resolveConfiguredSignalProviders({ FUSION_SIGNAL_GITHUB_SECRET: "gh" })).toEqual(["github"]);
});
it("signalToTaskInput omits column so the store resolves the default-workflow intake (triage)", () => {

View File

@@ -331,14 +331,11 @@ export async function ingestIncidentSignal(
return { incident, created: true };
}
/**
* Resolve an open incident for a grouping key (sets `status = resolved` +
* `resolvedAt`). Returns the resolved incident, or null if none was open. The
* resolution feeds MTTR via {@link aggregateMonitorMetrics}.
*
* FNXC:RuntimeSatelliteAsync 2026-06-24-13:25:
* Backend dual-path: delegates to resolveIncidentAsync when AsyncDataLayer.
*/
/*
FNXC:Monitor 2026-08-09-13:23:
Taskless GitHub CI recovery relies on persisted resolution rather than nonce dedup. The conditional status predicate makes concurrent SQLite/Postgres resolvers safe, while the LIMIT 1 subquery preserves the longstanding newest-one contract: multiple open rows are legal and older rows must not be mass-resolved.
*/
/** Resolves the newest open incident for a grouping key in one conditional UPDATE (at most one row). The winning caller receives that row; already-resolved/no-open/racing callers receive null and resolvedAt is written once. */
export async function resolveIncident(
db: Database | AsyncDataLayer,
groupingKey: string,
@@ -352,14 +349,17 @@ export async function resolveIncident(
return resolveIncidentAsync(layer.db, groupingKey, at, monitorProjectId(layer));
}
const sqliteDb = db as Database;
const open = getOpenIncidentByGroupingKey(sqliteDb, groupingKey);
if (!open) return null;
const now = at ?? new Date().toISOString();
sqliteDb.prepare(
`UPDATE incidents SET status = 'resolved', resolvedAt = ?, updatedAt = ? WHERE incidentId = ?`,
).run(now, now, open.incidentId);
const result = sqliteDb.prepare(
`UPDATE incidents SET status = 'resolved', resolvedAt = ?, updatedAt = ?
WHERE status = 'open' AND incidentId = (
SELECT incidentId FROM incidents WHERE groupingKey = ? AND status = 'open'
ORDER BY openedAt DESC, id DESC LIMIT 1
) RETURNING incidentId`,
).get(now, now, groupingKey) as { incidentId: string } | undefined;
if (!result) return null;
sqliteDb.bumpLastModified();
return getIncident(sqliteDb, open.incidentId);
return getIncident(sqliteDb, result.incidentId);
}
/**

View File

@@ -18,6 +18,7 @@ import { sentrySource } from "../signal-sources/sentry.js";
import { datadogSource } from "../signal-sources/datadog.js";
import { pagerdutySource } from "../signal-sources/pagerduty.js";
import { gitlabSource } from "../signal-sources/gitlab.js";
import { githubSource } from "../signal-sources/github.js";
import type { ApiRouteRegistrar } from "./types.js";
import { requireAsyncLayer } from "../require-async-layer.js";
@@ -48,6 +49,7 @@ const SIGNAL_SOURCES: Record<SignalProvider, SignalSource> = {
datadog: datadogSource,
pagerduty: pagerdutySource,
gitlab: gitlabSource,
github: githubSource,
};
export function getSignalSource(provider: string): SignalSource | undefined {
@@ -172,6 +174,8 @@ export interface SignalIngestResult {
status: number;
taskId?: string;
deduped?: boolean;
/** True only when this delivery's conditional update resolved the newest open incident. */
recoveryResolved?: boolean;
error?: string;
}
@@ -208,6 +212,26 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
return { status: 200, taskId: existing.id, deduped: true };
}
/*
FNXC:CommandCenterSignals 2026-08-09-13:23:
Green GitHub CI must not create a triage card. It has no task-marker dedup and
nonce memory is process-local, so recording it would manufacture duplicate
short-lived resolved incidents and poison MTTR. Persisted atomic resolve is a
durable no-op for cold, redelivered, and concurrent greens; existing resolved
signals retain their record-then-resolve cold-resolve behavior.
*/
if (signal.recoveryOnly === true) {
try {
const at = signalTimestampToIso(signal.timestamp) ?? new Date().toISOString();
const layer = requireAsyncLayer(store, "Signal incident storage");
const resolved = await resolveIncident(layer, signal.groupingKey, at);
return { status: 200, recoveryResolved: resolved !== null };
} catch (err) {
severityAuditLog.error("[signal-incident-bridge] Failed to resolve connector recovery", err);
return { status: 200, recoveryResolved: false };
}
}
// 5. Create the triage task.
const task = await store.createTask(signalToTaskInput(signal));
@@ -311,6 +335,7 @@ export const registerSignalRoutes: ApiRouteRegistrar = (ctx) => {
ok: result.status < 400,
taskId: result.taskId,
deduped: result.deduped ?? false,
recoveryResolved: result.recoveryResolved ?? false,
});
});
};

View File

@@ -20,7 +20,11 @@ import { createHmac, timingSafeEqual } from "node:crypto";
export type SignalSeverity = "critical" | "error" | "warning" | "info";
/** Supported external signal providers. */
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook" | "gitlab";
/*
FNXC:CommandCenterSignals 2026-08-09-13:23:
GitHub CI needs a dedicated inbound provider so completed check outcomes can reach Signals without adding polling or outbound GitHub API calls.
*/
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook" | "gitlab" | "github";
/** Normalized lifecycle intent for an ingested signal. */
export type SignalResolution = "open" | "resolved";
@@ -73,6 +77,17 @@ export interface Signal {
* Connector events must distinguish fire from recovery so incident-backed Signals metrics can preserve status. Omitted means "open" for backward-compatible task creation; "resolved" routes the grouped incident to resolveIncident instead of opening another occurrence.
*/
resolution?: SignalResolution;
/**
* FNXC:CommandCenterSignals 2026-08-09-13:23:
* GitHub green CI runs must not create one triage task per run. A recovery-only
* adapter opts into taskless recovery: it only atomically resolves the newest
* open incident for its grouping key and never records/opens an incident.
* Taskless signals have no durable delivery marker, so persisted open-gated
* resolution makes redelivery and concurrent delivery no-ops. This differs
* from `resolution: "resolved"` alone, which retains record-then-resolve
* behavior for every existing adapter.
*/
recoveryOnly?: boolean;
/**
* Optional canonical URL back to the source. Treated as SSRF-untrusted: it is
* stored as data and only rendered as an external link, never fetched server

View File

@@ -0,0 +1,135 @@
import {
applySignalCaps,
verifyHmacSignature,
type Signal,
type SignalSource,
type SignalVerifyContext,
type SignalVerifyResult,
} from "../signal-source.js";
type ObjectValue = Record<string, unknown>;
type GitHubKind = "check_suite" | "workflow_run" | "status";
/** GitHub terminal outcomes folded across check-suite, workflow-run, and status events. */
export const GITHUB_OUTCOME_MAP = {
failure: { severity: "error", resolution: "open" },
timed_out: { severity: "error", resolution: "open" },
startup_failure: { severity: "error", resolution: "open" },
stale: { severity: "error", resolution: "open" },
error: { severity: "error", resolution: "open" },
action_required: { severity: "warning", resolution: "open" },
cancelled: { severity: "warning", resolution: "open" },
success: { severity: "info", resolution: "resolved", recoveryOnly: true },
neutral: { severity: "info", resolution: "resolved", recoveryOnly: true },
skipped: { severity: "info", resolution: "resolved", recoveryOnly: true },
} as const;
type Outcome = keyof typeof GITHUB_OUTCOME_MAP;
function asObject(value: unknown): ObjectValue | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? value as ObjectValue : undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function firstString(...values: unknown[]): string | undefined {
return values.map(asString).find(Boolean);
}
function timestamp(...values: unknown[]): number | undefined {
for (const value of values) {
const string = asString(value);
if (string) {
const parsed = Date.parse(string);
if (Number.isFinite(parsed)) return parsed;
}
const number = asNumber(value);
if (number !== undefined) return number;
}
return undefined;
}
function stripSig(value: string | undefined): string | undefined {
return value?.startsWith("sha256=") ? value.slice("sha256=".length) : value;
}
function fallbackKind(payload: ObjectValue): GitHubKind | null {
if (asObject(payload.check_suite)) return "check_suite";
if (asObject(payload.workflow_run)) return "workflow_run";
return asString(payload.state) ? "status" : null;
}
function pullNumbers(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const numbers = value.map(asObject).map((item) => asNumber(item?.number)).filter((n): n is number => n !== undefined);
return numbers.length ? numbers : undefined;
}
/*
FNXC:CommandCenterSignals 2026-08-09-13:23:
GitHub queued and in-progress CI is not an alert and must not create an incident that no terminal outcome can reliably resolve. Unknown future terminal values remain fail-visible opens rather than silently becoming green recovery.
*/
export const githubSource: SignalSource = {
provider: "github",
secretEnvVar: "FUSION_SIGNAL_GITHUB_SECRET",
verify(ctx: SignalVerifyContext): SignalVerifyResult {
if (!ctx.secret) return { valid: false, status: 401, error: "GitHub webhook secret is not configured" };
const signature = stripSig(ctx.headers["x-hub-signature-256"]);
if (!signature) return { valid: false, status: 401, error: "Missing X-Hub-Signature-256 header" };
if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) return { valid: false, status: 401, error: "Invalid GitHub signature" };
return { valid: true };
},
normalize(payload: unknown, ctx: SignalVerifyContext): Signal | null {
const p = asObject(payload);
if (!p) throw new Error("Payload must be a JSON object");
const headerKind = asString(ctx.headers["x-github-event"])?.toLowerCase();
/*
FNXC:CommandCenterSignals 2026-08-09-13:36:
GitHub's event header is authoritative when present. Payload-shape routing exists only for headerless test/proxy deliveries; falling back after an unsupported header could ingest an unrelated GitHub event that happens to contain a status-like field.
*/
const kind = headerKind === undefined
? fallbackKind(p)
: headerKind === "check_suite" || headerKind === "workflow_run" || headerKind === "status"
? headerKind
: null;
if (!kind) return null;
const repository = asObject(p.repository);
const repositoryName = asString(repository?.full_name);
if (!repositoryName) throw new Error("Missing GitHub repository.full_name");
const repositoryUrl = asString(repository?.html_url);
const event = kind === "check_suite" ? asObject(p.check_suite) : kind === "workflow_run" ? asObject(p.workflow_run) : p;
if (!event) throw new Error(`Missing GitHub ${kind} payload`);
const status = asString(event.status ?? event.state)?.toLowerCase();
const conclusion = kind === "status" ? status : asString(event.conclusion)?.toLowerCase();
if ((kind !== "status" && (status !== "completed" || !conclusion)) || (kind === "status" && status === "pending")) return null;
const sha = firstString(event.head_sha, event.sha);
if (!sha) throw new Error("Missing GitHub head SHA");
const workflow = asObject(p.workflow);
const app = asObject(event.app);
const checkName = kind === "workflow_run" ? asString(workflow?.name) : kind === "check_suite" ? asString(app?.slug) : asString(event.context);
if (!checkName) throw new Error("Missing GitHub check name");
const outcome = conclusion ?? "unknown";
const mapped = GITHUB_OUTCOME_MAP[outcome as Outcome] ?? { severity: "warning" as const, resolution: "open" as const };
const branch = firstString(event.head_branch, event.branch) ?? "unknown branch";
const shortSha = sha.slice(0, 12);
const groupingKey = `github:${repositoryName}:${kind}:${checkName}:${sha}`;
const at = timestamp(event.updated_at, event.completed_at, event.created_at, p.updated_at, p.created_at);
const delivery = asString(ctx.headers["x-github-delivery"]);
const externalId = delivery ? `delivery:${delivery}` : `${groupingKey}:${outcome}:${at ?? "latest"}`;
const link = firstString(event.html_url, event.target_url, repositoryUrl ? `${repositoryUrl.replace(/\/+$/, "")}/commit/${sha}` : undefined);
const signal: Signal = {
source: "github", externalId, groupingKey,
title: `GitHub ${repositoryName} ${checkName}: ${outcome}`,
body: `Branch: ${branch}\nCommit: ${shortSha}\nCheck: ${checkName}\nOutcome: ${outcome}`,
severity: mapped.severity,
resolution: mapped.resolution,
...("recoveryOnly" in mapped && mapped.recoveryOnly ? { recoveryOnly: true } : {}),
link, timestamp: at,
meta: { kind, repository: repositoryName, branch, sha, checkName, status, conclusion, runAttempt: asNumber(event.run_attempt), pullRequests: pullNumbers(event.pull_requests) },
};
return applySignalCaps(signal);
},
};