FN-8903: add event-driven GitHub CI merge checks

Persist ingested GitHub CI signals and use them to assess merge readiness.

- Store project-scoped GitHub check states with retention maintenance.
- Resolve configured required checks from ingested signals during PR merge decisions.
- Add delivery, lifecycle, persistence, and retention coverage with operator documentation.

Files changed:
 .changeset/fn-8903-event-driven-checks.md          |   7 ++
 docs/architecture.md                               |   1 +
 docs/settings-reference.md                         |   2 +-
 docs/signals-connectors.md                         |   2 +-
 .../src/commands/__tests__/task-lifecycle.test.ts  |  50 ++++++++-
 packages/cli/src/commands/task-lifecycle.ts        |   7 +-
 .../core/src/__tests__/ingested-checks.test.ts     |  66 +++++++++++
 .../postgres/github-check-states.pg.test.ts        |  71 ++++++++++++
 packages/core/src/config/index.ts                  |   1 +
 packages/core/src/config/ingested-checks.ts        |  32 ++++++
 packages/core/src/index.ts                         |  10 ++
 .../0048_fn_8903_github_check_states.sql           |  32 ++++++
 packages/core/src/postgres/schema-applier.ts       |  15 ++-
 packages/core/src/postgres/schema/project.ts       |  29 ++++-
 .../core/src/task-store/async/async-ci-checks.ts   | 104 ++++++++++++++++++
 packages/core/src/task-store/async/index.ts        |   1 +
 packages/core/src/types.ts                         |   2 +
 packages/dashboard/src/__tests__/github.test.ts    | 121 ++++++++++++++++++++-
 .../src/__tests__/register-signal-routes.test.ts   |  81 +++++++++++++-
 packages/dashboard/src/github.ts                   |  74 +++++++++----
 .../dashboard/src/routes/register-git-github.ts    |  29 +++--
 .../dashboard/src/routes/register-signal-routes.ts |  10 +-
 .../src/routes/register-task-workflow-routes.ts    |  14 ++-
 packages/dashboard/src/signal-source.ts            |  23 ++++
 packages/dashboard/src/signal-sources/github.ts    |   2 +
 .../self-healing-github-check-retention.test.ts    | 121 +++++++++++++++++++++
 packages/engine/src/self-healing.ts                |  23 ++++
 27 files changed, 878 insertions(+), 52 deletions(-)

Fusion-Task-Id: FN-8903
Fusion-Task-Lineage: b2643587-c568-4b6c-8b3a-d50a6165963d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-09 09:14:56 -07:00
parent 673b962dfe
commit c7c879905b
27 changed files with 878 additions and 52 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let verified GitHub CI signals update configured merge checks without waiting for polling.
category: feature
dev: Adds github_check_states migration 0048, resolveIngestedChecks gate input, and prune-github-check-states maintenance.

View File

@@ -709,6 +709,7 @@ Runtime action-gate flow (v1):
- Reports Health Check (FN-8569): engine-side `classifyReportHealth` treats any non-empty `pauseReason` as authoritative over `state`, so a live-looking row with an `error-unrecoverable`, retry-exhausted, or model-unavailable park marker is rendered operator-actionable rather than healthy. The classifier deliberately excludes `lastError`, which remains diagnostic history; `@fusion/core` must not import this engine helper. `AgentStore.updateAgentState()` performs best-effort `pauseReason` cleanup only when resuming from `paused`/`error` into a live state, preserves `lastError`, and does not make state/marker writes atomic because independent marker writers can still create a desynced row.
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
- Batch 1 maintenance includes `reconcile-orphaned-task-dirs` (FN-6783), a paused-safe housekeeping step that calls `TaskStore.reconcileOrphanedTaskDirs()` so valid live `.fusion/tasks/{ID}/task.json` records missing from PostgreSQL become visible without waiting for process restart. The guard skips any ID already present in active, soft-deleted, archived, or tombstoned storage and emits `task:reconcile-orphaned-task-dir` only for recovered rows.
- Batch 1 `prune-github-check-states` removes expired project-scoped event-driven CI state on a six-hour per-project cadence. It is scheduled rather than delivery-driven so the 14-day retention still applies after webhooks stop; failures only log and never interrupt maintenance.
- Batch 1 maintenance also includes `reconcile-phantom-committed-reservations` (FN-7069), which calls `TaskStore.reconcilePhantomCommittedReservations()` for committed task-ID reservations that have no live/soft-deleted/archived task row and no `.fusion/tasks/{ID}/task.json`. The sweep prunes orphaned `activityLog` rows and `agents`/cascaded `agentRuns`, preserves `runAuditEvents`, and keeps the reservation `committed` per FN-5105 so the ID is permanently reserved rather than resurrected or handed out again.
- Startup recovery and Batch 1 both call `reconcileStaleSymbolLocks()` (FN-8305). It expires only project-scoped held locks whose lease elapsed or whose owner task is terminal/missing, preserves live owners, and does not alter task lifecycle, scheduler admission, worktrees, semaphores, or verification. The audit surface is `symbol-lock:reconcile-stale` plus a deduplicated `symbol-lock:reconcile-stale-no-action` idle signal.
- FN-8405 supplies the prerequisite declaration/resolution seam: `Task.declaredSymbols` is the durable source and `TaskStore.resolveTaskSymbolsForWorkItem({ taskId })` resolves through the owning task without reading its prompt. Scheduler admission does not acquire or admit locks here; FN-8306 is the separate consumer.

View File

@@ -495,7 +495,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `githubNativeAutoMerge` | `boolean` | `false` | Pull-request-mode-only opt-in that enables GitHub native auto-merge (`gh pr merge --auto` or GraphQL). The repository must permit auto-merge; rejection fails closed and Fusion does not fall back to an immediate merge. Fusion stays in review until a later poll observes GitHub's merged PR state. |
| `requiredChecks` | `string[]` | unset | Pull-request-mode only, opt-in Fusion-side required check names. Names match GitHub check names exactly and case-sensitively, independently of GitHub required-status-check configuration. `success`, `skipped`, and `neutral` satisfy a name; every other state, an absent name, and a named check outside GraphQL's first 100 contexts block the merge (the latter reports a truncated-list reason). Empty/unset preserves GitHub-delegated behavior. |
| `requiredChecks` | `string[]` | unset | Pull-request-mode only, opt-in Fusion-side required check names. Names match GitHub check names exactly and case-sensitively. Verified ingested GitHub CI for the exact project/repo/head can satisfy or block a named check event-driven; disagreement blocks. `success`, `skipped`, and `neutral` satisfy a name; every other state, an absent name, and a named check outside GraphQL's first 100 contexts block the merge. Empty/unset restores polled-only GitHub-delegated behavior. |
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
| `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. |
| `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. |

View File

@@ -128,7 +128,7 @@ Normalization:
## 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`.
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`. Terminal outcomes are also stored in `github_check_states` by `(project, repo, head SHA, check name)`. When `requiredChecks` is configured, exact-head rows may fill a missing polled check or block a disagreement; missing heads, cross-project rows, and stale commits never substitute. Newer delivery timestamps win retries, and engine batch-1 `prune-github-check-states` removes rows after 14 days even when deliveries stop.
Normalization uses:

View File

@@ -11,6 +11,7 @@ const execFileCalls = vi.hoisted(
() => [] as Array<{ file: string; args: string[]; cwd: string | undefined }>,
);
const refreshFixture = vi.hoisted(() => ({ next: 0, branch: "fusion/fn-9601" }));
const createIngestedCheckResolverMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
@@ -72,10 +73,11 @@ vi.mock("@fusion/core", async () => {
release: vi.fn(async () => undefined),
quarantine: vi.fn(async () => undefined),
})),
createIngestedCheckResolver: createIngestedCheckResolverMock,
};
});
import { acquireWorktreePathReservation, getCurrentRepo, getPushRepo } from "@fusion/core";
import { acquireWorktreePathReservation, createIngestedCheckResolver, getCurrentRepo, getPushRepo } from "@fusion/core";
import { activeSessionRegistry } from "@fusion/engine";
import {
cleanupMergedTaskArtifacts,
@@ -198,6 +200,7 @@ describe("processPullRequestMergeTask", () => {
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" });
// Same-repo default: push owner matches fetch owner so heads stay unqualified.
vi.mocked(getPushRepo).mockReturnValue({ owner: "owner", repo: "repo" });
vi.mocked(createIngestedCheckResolver).mockReset().mockReturnValue(undefined);
});
describe("central-install repo threading (gh-4)", () => {
@@ -1687,6 +1690,51 @@ describe("processPullRequestMergeTask", () => {
expect(result).toBe("merged");
expect(github.mergePr).toHaveBeenCalled();
});
it("uses a scoped ingested resolver so green proceeds and failure remains awaiting checks", async () => {
const task: MockTask = {
id: "FN-9103-event", title: "event checks", description: "desc", column: "in-review",
prInfo: { number: 101, url: "https://github.com/owner/repo/pull/101", status: "open", headBranch: "fusion/fn-9103-event", baseBranch: "main" },
};
const store = makeStore(task, { requiredChecks: ["build"] });
const layer = { projectId: "project-a" };
(store as Record<string, unknown>).getAsyncLayer = vi.fn(() => layer);
const ingestedResolver = vi.fn().mockResolvedValue([
{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "success", reportedAt: "2026-08-09T00:00:00.000Z" },
]);
vi.mocked(createIngestedCheckResolver).mockReturnValue(ingestedResolver);
const github = {
findPrForBranch: vi.fn(), createPr: vi.fn(),
getPrMergeStatus: vi.fn(async (_owner, _repo, _number, options) => {
const checks = await options.resolveIngestedChecks({ owner: "owner", repo: "repo", headSha: "abc123" });
return {
prInfo: { ...task.prInfo!, mergeable: "clean" as const }, reviewDecision: null, checks: [],
mergeReady: checks[0]?.state === "success", blockingReasons: checks[0]?.state === "success" ? [] : ["required checks not successful: build (failure)"],
};
}),
mergePr: vi.fn(async () => ({ ...task.prInfo!, status: "merged" as const })),
};
expect(await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).toBe("merged");
expect(createIngestedCheckResolver).toHaveBeenCalledWith(layer);
expect(github.mergePr).toHaveBeenCalled();
ingestedResolver.mockResolvedValueOnce([{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "failure", reportedAt: "2026-08-09T00:00:00.000Z" }]);
const failedStore = makeStore(task, { requiredChecks: ["build"] });
(failedStore as Record<string, unknown>).getAsyncLayer = vi.fn(() => layer);
expect(await processPullRequestMergeTask(failedStore as never, "/repo", task.id, github as never, () => undefined)).toBe("waiting");
expect((failedStore as { _updates: Array<{ patch: Record<string, unknown> }> })._updates.at(-1)?.patch).toEqual({ status: "awaiting-pr-checks" });
});
it("omits the resolver for a layerless or unscoped store", async () => {
const task: MockTask = { id: "FN-9103-no-layer", title: "checks", description: "desc", column: "in-review", prInfo: { number: 102, url: "https://github.com/owner/repo/pull/102", status: "open" } };
const store = makeStore(task, { requiredChecks: ["build"] });
(store as Record<string, unknown>).getAsyncLayer = vi.fn(() => undefined);
const github = { findPrForBranch: vi.fn(), createPr: vi.fn(), getPrMergeStatus: vi.fn(async () => ({ prInfo: { ...task.prInfo!, mergeable: "clean" as const }, reviewDecision: null, checks: [], mergeReady: false, blockingReasons: ["required check not reported: build"] })), mergePr: vi.fn() };
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
expect(github.getPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 102, { requiredCheckNames: ["build"] });
});
it("waits for a configured Fusion check, forwards normalized names, and does not merge", async () => {
const task: MockTask = {
id: "FN-9103", title: "test", description: "desc", column: "in-review",

View File

@@ -40,6 +40,8 @@ import {
acquireWorktreePathReservation,
type WorktreePathReservation,
resolveRequiredCheckNames,
createIngestedCheckResolver,
type IngestedCheckState,
} from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
@@ -88,7 +90,7 @@ import type {
interface GitHubOperations {
findPrForBranch(params: { owner?: string; repo?: string; head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
createPr(params: { owner?: string; repo?: string; title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
getPrMergeStatus(owner?: string, repo?: string, number?: number, options?: { requiredCheckNames?: string[] }): Promise<{
getPrMergeStatus(owner?: string, repo?: string, number?: number, options?: { requiredCheckNames?: string[]; resolveIngestedChecks?: (input: { owner: string; repo: string; headSha: string }) => Promise<IngestedCheckState[]> }): Promise<{
prInfo: PrInfo;
reviewDecision: string | null;
checks: Array<{ name: string; required: boolean; state: string }>;
@@ -1313,8 +1315,9 @@ export async function processPullRequestMergeTask(
// Defensive: keep the base settings if effective resolution fails entirely.
}
const requiredCheckNames = resolveRequiredCheckNames(settings);
const ingestedCheckResolver = createIngestedCheckResolver(store.getAsyncLayer?.());
const getPrMergeStatus = (number: number) => requiredCheckNames.length > 0
? github.getPrMergeStatus(prRepo.owner, prRepo.repo, number, { requiredCheckNames })
? github.getPrMergeStatus(prRepo.owner, prRepo.repo, number, { requiredCheckNames, ...(ingestedCheckResolver ? { resolveIngestedChecks: ingestedCheckResolver } : {}) })
: github.getPrMergeStatus(prRepo.owner, prRepo.repo, number);
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
const projectDefaultBranch = resolvedIntegrationBranch;

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { mergeIngestedCheckStates } from "../config/ingested-checks.js";
const required = ["ci/build"];
const green = {
repo: "owner/repo",
headSha: "abc",
checkName: "ci/build",
state: "success",
reportedAt: "2026-01-01T00:00:00.000Z",
};
function merge(overrides: Partial<Parameters<typeof mergeIngestedCheckStates>[0]> = {}) {
return mergeIngestedCheckStates({
polled: [],
ingested: [green],
requiredCheckNames: required,
repo: "owner/repo",
headSha: "abc",
...overrides,
});
}
describe("mergeIngestedCheckStates", () => {
it("preserves the polled list when required checks are disabled", () => {
const polled = [{ name: "ci/build", required: true, state: "pending" }];
const result = merge({ polled, requiredCheckNames: [] });
expect(result.checks).toBe(polled);
expect(result.appliedNames).toEqual(new Set());
});
it("fills an absent required check only for the exact repository commit", () => {
const result = merge({ repo: "OWNER/REPO", headSha: "ABC" });
expect(result.checks).toMatchObject([{ name: "ci/build", state: "success", required: true }]);
expect(result.appliedNames).toEqual(new Set(["ci/build"]));
});
it("fails closed for missing, foreign, or non-required ingested state", () => {
expect(merge({ headSha: undefined }).checks).toEqual([]);
expect(merge({ headSha: "other" }).checks).toEqual([]);
expect(merge({ repo: "other/repo" }).checks).toEqual([]);
expect(merge({ ingested: [{ ...green, checkName: "optional" }] }).checks).toEqual([]);
});
it("lets an ingested terminal result replace a polled pending result", () => {
const result = merge({ polled: [{ name: "ci/build", required: true, state: "pending" }] });
expect(result.checks[0]).toMatchObject({ state: "success", required: true });
});
it("retains blocking state whenever polling and ingestion disagree", () => {
expect(merge({
polled: [{ name: "ci/build", required: true, state: "success" }],
ingested: [{ ...green, state: "failure" }],
}).checks[0]?.state).toBe("failure");
expect(merge({
polled: [{ name: "ci/build", required: true, state: "failure" }],
}).checks[0]?.state).toBe("failure");
});
it("treats unknown ingested states as blocking instead of a success", () => {
expect(merge({ ingested: [{ ...green, state: "unrecognized" }] }).checks[0]).toMatchObject({
state: "unrecognized",
required: true,
});
});
});

View File

@@ -0,0 +1,71 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import {
listGitHubCheckStatesAsync,
pruneGitHubCheckStatesAsync,
recordGitHubCheckStateAsync,
} from "../../task-store/async/async-ci-checks.js";
const pgTest = pgDescribe;
const timestamp = "2026-08-09T12:00:00.000Z";
pgTest("GitHub check-state persistence", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_github_check_states" });
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
const input = (overrides = {}) => ({
repo: "Owner/Repo",
headSha: "ABC1234",
checkName: "ci/build",
state: "success",
eventKind: "check_suite" as const,
reportedAt: timestamp,
...overrides,
});
it("stores normalized state only in its explicit project partition", async () => {
const layer = h.layer();
await recordGitHubCheckStateAsync(layer, input(), "project-a");
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "project-a"))
.resolves.toMatchObject([{ repo: "owner/repo", headSha: "abc1234", checkName: "ci/build", state: "success" }]);
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "project-b"))
.resolves.toEqual([]);
});
it("does not let an older delivery regress a newer conclusion", async () => {
const layer = h.layer();
await recordGitHubCheckStateAsync(layer, input({ state: "failure", reportedAt: "2026-08-09T13:00:00.000Z" }), "project-a");
await expect(recordGitHubCheckStateAsync(layer, input({ reportedAt: timestamp }), "project-a")).resolves.toBe(false);
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "project-a"))
.resolves.toMatchObject([{ state: "failure" }]);
});
it("rejects an absent project partition on every operation", async () => {
const layer = h.layer();
await expect(recordGitHubCheckStateAsync(layer, input(), " ")).rejects.toThrow("require asyncLayer.projectId");
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "")).rejects.toThrow("require asyncLayer.projectId");
await expect(pruneGitHubCheckStatesAsync(layer, " ")).rejects.toThrow("require asyncLayer.projectId");
});
it("prunes expired rows in only the requested project", async () => {
const layer = h.layer();
await recordGitHubCheckStateAsync(layer, input(), "project-a");
await recordGitHubCheckStateAsync(layer, input({ checkName: "ci/test" }), "project-b");
await layer.db.execute(sql`UPDATE project.github_check_states SET received_at = '2000-01-01T00:00:00.000Z'`);
await expect(pruneGitHubCheckStatesAsync(layer, "project-a")).resolves.toBe(1);
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "project-a")).resolves.toEqual([]);
await expect(listGitHubCheckStatesAsync(layer, { repo: "owner/repo", headSha: "abc1234" }, "project-b")).resolves.toHaveLength(1);
});
});

View File

@@ -6,6 +6,7 @@ export * from "./configuration-revision-store.js";
export * from "./effective-settings-overlay.js";
export * from "./experimental-features.js";
export * from "./global-settings.js";
export * from "./ingested-checks.js";
export * from "./mcp-config.js";
export * from "./mcp-discovery.js";
export * from "./moved-settings.js";

View File

@@ -0,0 +1,32 @@
export type IngestedCheckStateValue = "success" | "pending" | "failure" | "cancelled" | "timed_out" | "action_required" | "neutral" | "skipped" | "stale" | "startup_failure" | string;
export interface IngestedCheckState { repo: string; headSha: string; checkName: string; state: IngestedCheckStateValue; reportedAt: string; detailsUrl?: string; }
export interface MergeablePrCheck { name: string; required: boolean; state: IngestedCheckStateValue; detailsUrl?: string; startedAt?: string; completedAt?: string; }
const satisfying = (state: string) => state === "success" || state === "neutral" || state === "skipped";
const terminal = (state: string) => state !== "pending";
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35:
Only required, same-project resolver results for the exact PR head may fill polling gaps. An absent
head is never a wildcard, and any disagreement retains the non-satisfying state to fail closed.
*/
export function mergeIngestedCheckStates<T extends MergeablePrCheck>(input: {
polled: T[]; ingested: IngestedCheckState[]; requiredCheckNames: string[]; repo: string; headSha?: string;
}): { checks: T[]; appliedNames: Set<string> } {
if (input.requiredCheckNames.length === 0 || !input.headSha?.trim()) return { checks: input.polled, appliedNames: new Set() };
const required = new Set(input.requiredCheckNames);
const repo = input.repo.trim().toLowerCase();
const sha = input.headSha.trim().toLowerCase();
const candidates = input.ingested.filter((check) => required.has(check.checkName) && check.repo.trim().toLowerCase() === repo && check.headSha.trim().toLowerCase() === sha);
const result = [...input.polled];
const appliedNames = new Set<string>();
for (const incoming of candidates) {
const index = result.findIndex((check) => check.name === incoming.checkName);
const existing = index < 0 ? undefined : result[index];
if (existing && terminal(existing.state) && !satisfying(existing.state)) continue;
if (existing && terminal(existing.state) && satisfying(existing.state) && satisfying(incoming.state)) continue;
const next = { name: incoming.checkName, required: true, state: incoming.state, detailsUrl: incoming.detailsUrl } as T;
if (index < 0) result.push(next); else result[index] = next;
appliedNames.add(incoming.checkName);
}
return { checks: result, appliedNames };
}

View File

@@ -78,6 +78,8 @@ export {
} from "./ai/openai-models.js";
export type { OpenAiCodexProviderRegistration } from "./ai/openai-models.js";
export { resolveRequiredCheckNames } from "./config/required-checks.js";
export { mergeIngestedCheckStates } from "./config/ingested-checks.js";
export type { IngestedCheckState, IngestedCheckStateValue, MergeablePrCheck } from "./config/ingested-checks.js";
export { detectImageMimeFromBytes } from "./i18n/image-mime.js";
export type { DetectedImageMime } from "./i18n/image-mime.js";
export {
@@ -2615,6 +2617,14 @@ export {
releaseIncidentFixTaskClaimAsync,
} from "./task-store/async/async-monitor.js";
export type { Deployment as AsyncDeployment, Incident as AsyncIncident } from "./task-store/async/async-monitor.js";
export {
createIngestedCheckResolver,
listGitHubCheckStatesAsync,
pruneGitHubCheckStatesAsync,
recordGitHubCheckStateAsync,
GITHUB_CHECK_STATE_RETENTION_MS,
} from "./task-store/async/async-ci-checks.js";
export type { GitHubCheckState, GitHubCheckStateInput } from "./task-store/async/async-ci-checks.js";
// FNXC:RuntimeSatelliteCompletion 2026-06-24-23:40:
// Async AiSessionStore helpers exported for the dashboard AiSessionStore dual-path.

View File

@@ -0,0 +1,32 @@
-- FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: persist GitHub terminal CI per project and commit so required-check gates can use verified event delivery; received_at bounds scheduled retention when deliveries stop.
CREATE TABLE IF NOT EXISTS project.github_check_states (
id integer GENERATED ALWAYS AS IDENTITY NOT NULL,
project_id text NOT NULL DEFAULT '',
repo text NOT NULL,
head_sha text NOT NULL,
check_name text NOT NULL,
state text NOT NULL,
event_kind text,
external_id text,
details_url text,
reported_at text NOT NULL,
received_at text NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL,
meta jsonb,
PRIMARY KEY (project_id, id)
);
CREATE UNIQUE INDEX IF NOT EXISTS "idxGithubCheckStatesIdentity" ON project.github_check_states (project_id, repo, head_sha, check_name);
CREATE INDEX IF NOT EXISTS "idxGithubCheckStatesProjectCommit" ON project.github_check_states (project_id, repo, head_sha);
CREATE INDEX IF NOT EXISTS "idxGithubCheckStatesProjectReceived" ON project.github_check_states (project_id, received_at);
-- New project state must receive the same mandatory ownership fence as established tables.
ALTER TABLE project.github_check_states ENABLE ROW LEVEL SECURITY;
ALTER TABLE project.github_check_states FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS fusion_project_isolation ON project.github_check_states;
CREATE POLICY fusion_project_isolation ON project.github_check_states
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.github_check_states;
CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.github_check_states
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();

View File

@@ -56,7 +56,8 @@ capacity-model table drop that landed while this PR was open.
*/
/* FNXC:CrossProcessDeleteObservation 2026-08-01-11:39: advance the schema ceiling so durable consumer state exists before observers begin polling FN-8684's outbox. */
/* FNXC:MissionValidation 2026-08-01-16:21: advance the schema ceiling before validator admission reads durable content fingerprints. */
export const SCHEMA_BASELINE_VERSION = "0047";
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: SCHEMA_BASELINE_VERSION advances to 0048 for project-scoped GitHub CI check state. */
export const SCHEMA_BASELINE_VERSION = "0048";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -193,6 +194,8 @@ export const MULTI_ROLE_WORKFLOW_AGENTS_VERSION = "0045";
export const WORKFLOW_PRINCIPAL_FENCE_VERSION = "0046";
/** FNXC:TaskRecommendations 2026-08-08-05:02: explicit registration prevents recommendation JSONB upgrades being skipped. */
export const TASK_RECOMMENDATIONS_VERSION = "0047";
/** FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: explicit registration prevents event-driven merge state migration from being skipped. */
export const GITHUB_CHECK_STATES_VERSION = "0048";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -414,6 +417,7 @@ const QUEUED_EPISODE_SIGNATURE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0044_fn_87
const MULTI_ROLE_WORKFLOW_AGENTS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0045_fn_8764_multi_role_workflow_agents.sql");
const WORKFLOW_PRINCIPAL_FENCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0046_fn_8764_workflow_principal_fence.sql");
const TASK_RECOMMENDATIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0047_fn_8829_task_recommendations.sql");
const GITHUB_CHECK_STATES_MIGRATION_PATH = join(MIGRATIONS_DIR, "0048_fn_8903_github_check_states.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -531,6 +535,7 @@ export async function applySchemaBaseline(
const multiRoleWorkflowAgentsAlreadyApplied = applied.includes(MULTI_ROLE_WORKFLOW_AGENTS_VERSION);
const workflowPrincipalFenceAlreadyApplied = applied.includes(WORKFLOW_PRINCIPAL_FENCE_VERSION);
const taskRecommendationsAlreadyApplied = applied.includes(TASK_RECOMMENDATIONS_VERSION);
const githubCheckStatesAlreadyApplied = applied.includes(GITHUB_CHECK_STATES_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1161,6 +1166,14 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: run after historical baseline for both new and upgraded databases; idempotent SQL converges interrupted upgrades. */
if (!githubCheckStatesAlreadyApplied) {
const migrationSql = await readFile(GITHUB_CHECK_STATES_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${GITHUB_CHECK_STATES_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -1890,6 +1890,33 @@ export const deployments = projectSchema.table("deployments", {
index("idxDeploymentsService").on(t.service),
]);
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35:
Persist terminal GitHub CI by mandatory project, repository, and commit identity so event-driven
required checks cannot admit stale or cross-project results; received_at supports scheduled retention.
*/
export const githubCheckStates = projectSchema.table("github_check_states", {
id: integer("id").generatedAlwaysAsIdentity().notNull(),
projectId: text("project_id").notNull().default(""),
repo: text("repo").notNull(),
headSha: text("head_sha").notNull(),
checkName: text("check_name").notNull(),
state: text("state").notNull(),
eventKind: text("event_kind"),
externalId: text("external_id"),
detailsUrl: text("details_url"),
reportedAt: text("reported_at").notNull(),
receivedAt: text("received_at").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
meta: jsonb("meta"),
}, (t) => [
primaryKey({ columns: [t.projectId, t.id], name: "github_check_states_pkey" }),
uniqueIndex("idxGithubCheckStatesIdentity").on(t.projectId, t.repo, t.headSha, t.checkName),
index("idxGithubCheckStatesProjectCommit").on(t.projectId, t.repo, t.headSha),
index("idxGithubCheckStatesProjectReceived").on(t.projectId, t.receivedAt),
]);
export const incidents = projectSchema.table("incidents", {
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
projectId: text("project_id").notNull().default(""),
@@ -2398,7 +2425,7 @@ export const projectTableNames = [
"milestones", "slices", "mission_features", "ideation_sessions", "ideation_candidates", "mission_events", "plugins",
"routines", "project_insights", "project_insight_runs", "project_insight_run_events",
"todo_lists", "todo_items", "usage_events", "plugin_activations",
"knowledge_pages", "deployments", "incidents", "ai_sessions", "messages",
"knowledge_pages", "deployments", "github_check_states", "incidents", "ai_sessions", "messages",
"agent_ratings", "chat_sessions", "cli_sessions", "chat_messages",
"run_audit_events", "mission_contract_assertions", "mission_feature_assertions",
"mission_validator_runs", "mission_validator_failures",

View File

@@ -0,0 +1,104 @@
import { and, eq, lt, sql } from "drizzle-orm";
import * as schema from "../../postgres/schema/index.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
export const GITHUB_CHECK_STATE_RETENTION_MS = 14 * 86_400_000;
export interface GitHubCheckStateInput {
repo: string;
headSha: string;
checkName: string;
state: string;
eventKind?: "check_suite" | "workflow_run" | "status";
externalId?: string;
detailsUrl?: string;
reportedAt: string;
meta?: Record<string, unknown>;
}
export interface GitHubCheckState {
repo: string;
headSha: string;
checkName: string;
state: string;
eventKind?: "check_suite" | "workflow_run" | "status";
externalId?: string;
detailsUrl?: string;
reportedAt: string;
}
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35:
GitHub CI is project-owned state. Rejecting an absent partition prevents writes, reads, or retention
from silently entering the legacy bucket and lets a different project's green check satisfy a gate.
*/
function requireProjectId(projectId: string): string {
const normalized = projectId.trim();
if (!normalized) throw new Error("GitHub check state operations require asyncLayer.projectId");
return normalized;
}
function normalizeRepo(value: string): string { return value.trim().toLowerCase(); }
function normalizeSha(value: string): string { return value.trim().toLowerCase(); }
export async function recordGitHubCheckStateAsync(
layer: AsyncDataLayer,
input: GitHubCheckStateInput,
projectId: string,
): Promise<boolean> {
const ownerProjectId = requireProjectId(projectId);
const now = new Date().toISOString();
const repo = normalizeRepo(input.repo);
const headSha = normalizeSha(input.headSha);
const checkName = input.checkName.trim();
const result = await layer.db.execute(sql`
INSERT INTO project.github_check_states
(project_id, repo, head_sha, check_name, state, event_kind, external_id, details_url, reported_at, received_at, created_at, updated_at, meta)
VALUES (${ownerProjectId}, ${repo}, ${headSha}, ${checkName}, ${input.state}, ${input.eventKind ?? null}, ${input.externalId ?? null}, ${input.detailsUrl ?? null}, ${input.reportedAt}, ${now}, ${now}, ${now}, ${input.meta ?? null})
ON CONFLICT (project_id, repo, head_sha, check_name) DO UPDATE SET
state = EXCLUDED.state, event_kind = EXCLUDED.event_kind, external_id = EXCLUDED.external_id,
details_url = EXCLUDED.details_url, reported_at = EXCLUDED.reported_at, received_at = EXCLUDED.received_at,
updated_at = EXCLUDED.updated_at, meta = EXCLUDED.meta
WHERE EXCLUDED.reported_at >= project.github_check_states.reported_at
`);
return result.count > 0;
}
export async function listGitHubCheckStatesAsync(
layer: AsyncDataLayer,
input: { repo: string; headSha: string },
projectId: string,
): Promise<GitHubCheckState[]> {
const ownerProjectId = requireProjectId(projectId);
const rows = await layer.db.select().from(schema.project.githubCheckStates).where(and(
eq(schema.project.githubCheckStates.projectId, ownerProjectId),
eq(schema.project.githubCheckStates.repo, normalizeRepo(input.repo)),
eq(schema.project.githubCheckStates.headSha, normalizeSha(input.headSha)),
));
return rows.map((row) => ({
repo: row.repo, headSha: row.headSha, checkName: row.checkName, state: row.state,
eventKind: row.eventKind as GitHubCheckState["eventKind"], externalId: row.externalId ?? undefined,
detailsUrl: row.detailsUrl ?? undefined, reportedAt: row.reportedAt,
}));
}
export async function pruneGitHubCheckStatesAsync(
layer: AsyncDataLayer,
projectId: string,
retentionMs = GITHUB_CHECK_STATE_RETENTION_MS,
): Promise<number> {
const ownerProjectId = requireProjectId(projectId);
const cutoff = new Date(Date.now() - retentionMs).toISOString();
const result = await layer.db.delete(schema.project.githubCheckStates).where(and(
eq(schema.project.githubCheckStates.projectId, ownerProjectId),
lt(schema.project.githubCheckStates.receivedAt, cutoff),
));
return result.count;
}
export function createIngestedCheckResolver(layer: AsyncDataLayer | null | undefined) {
const projectId = layer?.projectId?.trim();
if (!layer || !projectId) return undefined;
return async (input: { owner: string; repo: string; headSha: string }) =>
listGitHubCheckStatesAsync(layer, { repo: `${input.owner}/${input.repo}`, headSha: input.headSha }, projectId);
}

View File

@@ -7,6 +7,7 @@ export * from "./async-archive-lineage.js";
export * from "./async-audit.js";
export * from "./async-branch-groups.js";
export * from "./async-comments-attachments.js";
export * from "./async-ci-checks.js";
export * from "./async-events.js";
export * from "./async-lifecycle.js";
export * from "./async-maintenance.js";

View File

@@ -45,6 +45,8 @@ FNXC:PrMergeRequiredChecks 2026-08-09-07:48:
The dashboard aliases @fusion/core to this browser-safe barrel. Re-export the pure shared normalizer here so Settings cannot fork name-trimming semantics from the CLI and server merge gates.
*/
export { resolveRequiredCheckNames } from "./config/required-checks.js";
export { mergeIngestedCheckStates } from "./config/ingested-checks.js";
export type { IngestedCheckState, IngestedCheckStateValue, MergeablePrCheck } from "./config/ingested-checks.js";
/*
* FNXC:WorkflowDeprecation 2026-07-15-16:35:

View File

@@ -1903,7 +1903,7 @@ describe("GitHubClient", () => {
const ghPr = {
number: 42, url: "https://github.com/owner/repo/pull/42", title: "Ready PR", state: "OPEN",
reviewDecision: null, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN",
baseRefName: "main", headRefName: "fusion/fn-8855",
baseRefName: "main", headRefName: "fusion/fn-8855", headRefOid: "abc123",
};
const apiPayload = (nodes: unknown[], hasNextPage = false) => ({
data: { repository: { pullRequest: {
@@ -1963,6 +1963,61 @@ describe("GitHubClient", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("uses a scoped ingested green check when gh polling omits a configured check", async () => {
const resolver = vi.fn().mockResolvedValue([{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "success", reportedAt: "2026-08-09T00:00:00.000Z" }]);
mockRunGhJsonAsync
.mockResolvedValueOnce(ghPr)
.mockResolvedValueOnce({ headRefOid: "abc123" })
.mockResolvedValueOnce([]);
const result = await new GitHubClient({ forceMode: "gh-cli" }).getPrMergeStatus("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(result.mergeReady).toBe(true);
expect(result.blockingReasons).not.toContain("required check not reported: build");
expect(resolver).toHaveBeenCalledWith({ owner: "owner", repo: "repo", headSha: "abc123" });
});
it("fails closed when ingested state disagrees with a successful polled check", async () => {
const resolver = vi.fn().mockResolvedValue([{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "failure", reportedAt: "2026-08-09T00:00:00.000Z" }]);
mockRunGhJsonAsync
.mockResolvedValueOnce(ghPr)
.mockResolvedValueOnce({ headRefOid: "abc123" })
.mockResolvedValueOnce([{ name: "build", state: "SUCCESS", bucket: "pass" }]);
const result = await new GitHubClient({ forceMode: "gh-cli" }).getPrMergeStatus("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(result.mergeReady).toBe(false);
expect(result.blockingReasons).toEqual(["required checks not successful: build (failure)"]);
});
it("does not invoke the resolver without a PR head OID", async () => {
const resolver = vi.fn();
mockRunGhJsonAsync
.mockResolvedValueOnce({ ...ghPr, headRefOid: undefined })
.mockResolvedValueOnce({ headRefOid: undefined })
.mockResolvedValueOnce([]);
const result = await new GitHubClient({ forceMode: "gh-cli" }).getPrMergeStatus("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(resolver).not.toHaveBeenCalled();
expect(result.blockingReasons).toContain("required check not reported: build");
});
it("uses the same event-driven state through the GraphQL transport", async () => {
const resolver = vi.fn().mockResolvedValue([{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "success", reportedAt: "2026-08-09T00:00:00.000Z" }]);
vi.spyOn(global, "fetch" as any).mockResolvedValueOnce({ ok: true, json: async () => apiPayload([]) } as any);
const result = await new GitHubClient({ token: "ghp_token", forceMode: "token" }).getPrMergeStatus("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(result.mergeReady).toBe(true);
expect(resolver).toHaveBeenCalledWith({ owner: "owner", repo: "repo", headSha: "abc123" });
vi.restoreAllMocks();
});
it("fails closed for a truncated token check list and preserves names through gh fallback", async () => {
const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
@@ -1989,7 +2044,7 @@ describe("GitHubClient", () => {
describe("getAllPrChecks", () => {
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
mockRunGhJsonAsync.mockResolvedValueOnce([
mockRunGhJsonAsync.mockResolvedValueOnce({ headRefOid: "abc123" }).mockResolvedValueOnce([
{ name: "required-ci", state: "SUCCESS", link: "https://example.com/ci", bucket: "pass" },
{ name: "optional-preview", state: "FAILURE", link: "https://example.com/preview", bucket: "none" },
]);
@@ -2003,6 +2058,65 @@ describe("GitHubClient", () => {
]);
});
it("merges an ingested required green into the gh checks view", async () => {
const resolver = vi.fn().mockResolvedValue([
{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "success", reportedAt: "2026-08-09T00:00:00.000Z" },
]);
mockRunGhJsonAsync.mockResolvedValueOnce({ headRefOid: "abc123" }).mockResolvedValueOnce([]);
const result = await client.getAllPrChecks("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(resolver).toHaveBeenCalledWith({ owner: "owner", repo: "repo", headSha: "abc123" });
expect(result.checks).toEqual([expect.objectContaining({ name: "build", required: true, state: "success" })]);
expect(result.rollupRequired).toBe("success");
});
it("uses the GraphQL head OID for event-driven failure and rejects an absent OID", async () => {
const resolver = vi.fn().mockResolvedValue([
{ repo: "owner/repo", headSha: "abc123", checkName: "build", state: "failure", reportedAt: "2026-08-09T00:00:00.000Z" },
]);
const clientWithToken = new GitHubClient({ token: "ghp_token", forceMode: "token" });
mockRunGhJsonAsync.mockRejectedValue(new Error("gh unavailable"));
const payloadForHead = (headRefOid: string | null) => ({
data: {
repository: {
pullRequest: {
headRefOid,
commits: {
nodes: [{
commit: { statusCheckRollup: { contexts: { nodes: [] } } },
}],
},
},
},
},
});
const mockFetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(payloadForHead("abc123")),
}).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(payloadForHead(null)),
});
global.fetch = mockFetch as any;
const failed = await clientWithToken.getAllPrChecks("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(failed.checks).toEqual([expect.objectContaining({ name: "build", required: true, state: "failure" })]);
expect(failed.rollupRequired).toBe("failure");
expect(resolver).toHaveBeenCalledTimes(1);
const missingHead = await clientWithToken.getAllPrChecks("owner", "repo", 42, {
requiredCheckNames: ["build"], resolveIngestedChecks: resolver,
});
expect(missingHead.checks).toEqual([]);
expect(resolver).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
it("returns all checks in API mode and ignores non-required failures for rollup", async () => {
const clientWithToken = new GitHubClient("ghp_token");
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
@@ -2055,6 +2169,9 @@ describe("GitHubClient", () => {
{ name: "required-ci", required: true, state: "success", detailsUrl: "https://example.com/required", startedAt: undefined, completedAt: undefined },
{ name: "optional-legacy", required: false, state: "failure", detailsUrl: "https://example.com/optional" },
]);
const request = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(request.query).toContain("headRefOid");
expect(request.query).not.toContain("/*");
vi.restoreAllMocks();
});
});

View File

@@ -2,7 +2,19 @@
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { aggregateSignalsAnalytics, drizzleSql as sql, type AsyncDataLayer, type Task, type TaskStore } from "@fusion/core";
const { mockIsGhAuthenticated, mockRunGhJsonAsync } = vi.hoisted(() => ({
mockIsGhAuthenticated: vi.fn(() => true),
mockRunGhJsonAsync: vi.fn(),
}));
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
isGhAuthenticated: mockIsGhAuthenticated,
runGhJsonAsync: mockRunGhJsonAsync,
}));
import { aggregateSignalsAnalytics, createIngestedCheckResolver, drizzleSql as sql, type AsyncDataLayer, type Task, type TaskStore } from "@fusion/core";
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
import {
@@ -18,6 +30,7 @@ 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";
import { GitHubClient } from "../github.js";
function sign(body: string, secret: string): string {
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
@@ -75,6 +88,16 @@ async function incidents(layer: AsyncDataLayer) {
}>;
}
async function githubCheckStates(layer: AsyncDataLayer) {
return await layer.db.execute(sql`SELECT project_id AS "projectId", repo, head_sha AS "headSha", check_name AS "checkName", state FROM project.github_check_states WHERE project_id = ${layer.projectId} ORDER BY id ASC`) as Array<{
projectId: string;
repo: string;
headSha: string;
checkName: string;
state: string;
}>;
}
const SECRETS: Record<string, string> = {
FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret",
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
@@ -88,6 +111,7 @@ const savedEnv: Record<string, string | undefined> = {};
const harnesses: PgTestHarness[] = [];
beforeEach(() => {
mockRunGhJsonAsync.mockReset();
for (const [k, v] of Object.entries(SECRETS)) {
savedEnv[k] = process.env[k];
process.env[k] = v;
@@ -112,9 +136,9 @@ function ctxFor(source: SignalSource, payload: object, headers: Record<string, s
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" };
return { repository, state: outcome, context: "build", sha: "abc1234", 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" } };
const run = { status: "completed", conclusion: outcome, head_sha: "abc1234", 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 };
}
@@ -616,11 +640,14 @@ pgDescribe("ingestSignal — GitHub CI recovery", () => {
expect(open.status).toBe(201);
expect(store._tasks).toHaveLength(1);
expect(await incidents(layer)).toMatchObject([{
groupingKey: "github:org/repo:check_suite:checks:abc123",
groupingKey: "github:org/repo:check_suite:checks:abc1234",
source: "github",
severity: "error",
status: "open",
}]);
expect(await githubCheckStates(layer)).toEqual([{
projectId: "signal-routes-project", repo: "org/repo", headSha: "abc1234", checkName: "checks", state: "failure",
}]);
const success = githubPayload("check_suite", "success");
const resolved = await ingestSignal({
@@ -635,6 +662,9 @@ pgDescribe("ingestSignal — GitHub CI recovery", () => {
const afterResolution = await incidents(layer);
expect(afterResolution).toHaveLength(1);
expect(afterResolution[0]).toMatchObject({ status: "resolved" });
expect(await githubCheckStates(layer)).toEqual([{
projectId: "signal-routes-project", repo: "org/repo", headSha: "abc1234", checkName: "checks", state: "success",
}]);
const resolvedAt = afterResolution[0].resolvedAt;
const redelivery = await ingestSignal({
@@ -669,6 +699,49 @@ pgDescribe("ingestSignal — GitHub CI recovery", () => {
expect(await incidents(layer)).toHaveLength(2);
});
it("feeds a signed GitHub green delivery through the scoped resolver into the merge gate", async () => {
const { layer, store } = await makeDbStore();
const payload = githubPayload("check_suite", "success");
expect((await ingestSignal({
source: githubSource,
store,
...githubContext(payload, "check_suite", "github-gate-green"),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(200);
mockRunGhJsonAsync
.mockResolvedValueOnce({
number: 42, url: "https://github.com/org/repo/pull/42", title: "Green PR", state: "OPEN",
isDraft: false, baseRefName: "main", headRefName: "feature", headRefOid: "abc1234",
reviewDecision: null, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN",
})
.mockResolvedValueOnce({ headRefOid: "abc1234" })
.mockResolvedValueOnce([]);
const result = await new GitHubClient({ forceMode: "gh-cli" }).getPrMergeStatus("org", "repo", 42, {
requiredCheckNames: ["checks"],
resolveIngestedChecks: createIngestedCheckResolver(layer),
});
expect(result.mergeReady).toBe(true);
expect(result.blockingReasons).not.toContain("required check not reported: checks");
});
it("drops malformed GitHub repository descriptors so they cannot persist check state", async () => {
const { layer, store } = await makeDbStore();
const payload = githubPayload("check_suite", "failure");
(payload.repository as { full_name: string }).full_name = "org/repo/foreign";
const result = await ingestSignal({
source: githubSource,
store,
...githubContext(payload, "check_suite", "github-invalid-repo"),
nonceCache: new DeliveryNonceCache(),
});
expect(result.status).toBe(201);
expect(await githubCheckStates(layer)).toEqual([]);
});
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();

View File

@@ -13,6 +13,8 @@ import {
getCurrentRepo,
runGh,
resolveRequiredCheckNames,
mergeIngestedCheckStates,
type IngestedCheckState,
} from "@fusion/core";
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
@@ -841,8 +843,9 @@ export function isPrMergeReady(input: {
}
const unsatisfied = matches.find((check) => !satisfies(check.state));
if (unsatisfied) {
const githubReason = `required checks not successful: ${name} (${unsatisfied.state})`;
if (!blockingReasons.includes(githubReason)) {
// A synthesized ingested check is required, so the legacy filter already owns its
// failure reason. Non-required poll results still need the named-policy reason.
if (!blockingChecks.some((check) => check.name === name && check.state === unsatisfied.state)) {
blockingReasons.push(`required check not successful: ${name} (${unsatisfied.state})`);
}
}
@@ -850,7 +853,8 @@ export function isPrMergeReady(input: {
return { ready: blockingReasons.length === 0, blockingReasons: [...new Set(blockingReasons)] };
}
export interface PrCheckGateOptions { requiredCheckNames?: string[]; }
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: callers provide scoped data only; isPrMergeReady remains the sole readiness verdict and never invokes a resolver without a head SHA. */
export interface PrCheckGateOptions { requiredCheckNames?: string[]; resolveIngestedChecks?: (input: { owner: string; repo: string; headSha: string }) => Promise<IngestedCheckState[]>; }
export interface GitHubClientOptions {
token?: string;
@@ -1849,22 +1853,22 @@ export class GitHubClient {
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
if (this.hasGhAuth()) {
try {
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames);
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
} catch (err) {
if (this.token) {
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise<PrMergeStatus> {
private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[], resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"]): Promise<PrMergeStatus> {
const resolved = this.resolveRepo(owner, repo);
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
@@ -1901,10 +1905,14 @@ export class GitHubClient {
startedAt: (check as GhPrCheckJson).startedAt,
completedAt: (check as GhPrCheckJson).completedAt,
} satisfies PrCheckStatus));
const ingested = requiredCheckNames.length > 0 && pr.headRefOid?.trim() && resolveIngestedChecks
? await resolveIngestedChecks({ owner: resolved.owner, repo: resolved.repo, headSha: pr.headRefOid }).catch(() => [])
: [];
const effectiveChecks = mergeIngestedCheckStates({ polled: normalizedChecks, ingested, requiredCheckNames, repo: `${resolved.owner}/${resolved.repo}`, headSha: pr.headRefOid }).checks as PrCheckStatus[];
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
checks: effectiveChecks,
mergeable,
requiredCheckNames,
});
@@ -1912,14 +1920,14 @@ export class GitHubClient {
return {
prInfo,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
checks: effectiveChecks,
mergeable,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
};
}
private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise<PrMergeStatus> {
private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[], resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"]): Promise<PrMergeStatus> {
const resolved = this.resolveRepo(owner, repo);
const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
@@ -2071,19 +2079,23 @@ export class GitHubClient {
commentCount: pr.comments.totalCount,
mergeable,
});
const ingested = requiredCheckNames.length > 0 && pr.headRefOid?.trim() && resolveIngestedChecks
? await resolveIngestedChecks({ owner: resolved.owner, repo: resolved.repo, headSha: pr.headRefOid }).catch(() => [])
: [];
const effectiveChecks = mergeIngestedCheckStates({ polled: gateChecks, ingested, requiredCheckNames, repo: `${resolved.owner}/${resolved.repo}`, headSha: pr.headRefOid ?? undefined }).checks as PrCheckStatus[];
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision,
checks: gateChecks,
checks: effectiveChecks,
mergeable,
requiredCheckNames,
checkListTruncated: Boolean(contexts?.pageInfo?.hasNextPage) && requiredCheckNames.some((name) => !gateChecks.some((check) => check.name === name)),
checkListTruncated: Boolean(contexts?.pageInfo?.hasNextPage) && requiredCheckNames.some((name) => !effectiveChecks.some((check) => check.name === name)),
});
return {
prInfo,
reviewDecision: pr.reviewDecision,
checks: gateChecks,
checks: effectiveChecks,
mergeable,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
@@ -2099,17 +2111,17 @@ export class GitHubClient {
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
if (this.hasGhAuth()) {
try {
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames);
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
} catch (err) {
if (this.token) {
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
@@ -2133,25 +2145,28 @@ export class GitHubClient {
repo: string | undefined,
number: number,
requiredCheckNames: string[] = [],
resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"],
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
const resolved = this.resolveRepo(owner, repo);
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: retrieve the PR head with this transport; missing OID deliberately admits no event state. */
const headOid = await Promise.resolve(runGhJsonAsync<{ headRefOid?: string }>(["pr", "view", String(number), "--repo", `${resolved.owner}/${resolved.repo}`, "--json", "headRefOid"])).then((pr) => pr?.headRefOid).catch(() => undefined);
let checks = await runGhJsonAsync<GhPrCheckJson[]>([
let checks = await Promise.resolve(runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--json", "name,state,link,startedAt,completedAt,bucket",
]).catch(async () => {
])).catch(async () => {
const allChecks = await runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--json", "name,state,link,startedAt,completedAt",
]);
const requiredChecks = await runGhJsonAsync<GhPrCheckJson[]>([
const requiredChecks = await Promise.resolve(runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--required",
"--json", "name,state",
]).catch(() => []);
])).catch(() => []);
const requiredNames = new Set(requiredChecks.map((check) => check.name));
return allChecks.map((check) => ({ ...check, bucket: requiredNames.has(check.name) ? "pass" : "none" }));
});
@@ -2168,9 +2183,12 @@ export class GitHubClient {
completedAt: check.completedAt,
} satisfies PrCheckStatus));
const ingested = requiredCheckNames.length > 0 && headOid?.trim() && resolveIngestedChecks
? await resolveIngestedChecks({ owner: resolved.owner, repo: resolved.repo, headSha: headOid }).catch(() => []) : [];
const effectiveChecks = mergeIngestedCheckStates({ polled: normalized, ingested, requiredCheckNames, repo: `${resolved.owner}/${resolved.repo}`, headSha: headOid }).checks as PrCheckStatus[];
return {
checks: normalized,
rollupRequired: this.computeRequiredChecksRollup(normalized),
checks: effectiveChecks,
rollupRequired: this.computeRequiredChecksRollup(effectiveChecks),
};
}
@@ -2179,8 +2197,10 @@ export class GitHubClient {
repo: string | undefined,
number: number,
requiredCheckNames: string[] = [],
resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"],
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
const resolved = this.resolveRepo(owner, repo);
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: exact PR head is mandatory before event state can affect the human checks view. */
const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: this.buildHeaders(),
@@ -2188,6 +2208,7 @@ export class GitHubClient {
query: `query PullRequestAllChecks($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
headRefOid
commits(last: 1) {
nodes {
commit {
@@ -2227,6 +2248,7 @@ export class GitHubClient {
data?: {
repository?: {
pullRequest?: {
headRefOid?: string | null;
commits: {
nodes: Array<{
commit: {
@@ -2285,9 +2307,13 @@ export class GitHubClient {
} satisfies PrCheckStatus];
});
const headOid = payload.data?.repository?.pullRequest?.headRefOid ?? undefined;
const ingested = requiredCheckNames.length > 0 && headOid?.trim() && resolveIngestedChecks
? await resolveIngestedChecks({ owner: resolved.owner, repo: resolved.repo, headSha: headOid }).catch(() => []) : [];
const effectiveChecks = mergeIngestedCheckStates({ polled: checks, ingested, requiredCheckNames, repo: `${resolved.owner}/${resolved.repo}`, headSha: headOid }).checks as PrCheckStatus[];
return {
checks,
rollupRequired: this.computeRequiredChecksRollup(checks),
checks: effectiveChecks,
rollupRequired: this.computeRequiredChecksRollup(effectiveChecks),
};
}

View File

@@ -1,4 +1,4 @@
import { createLogger, resolveRequiredCheckNames, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget } from "@fusion/core";
import { createLogger, createIngestedCheckResolver, resolveRequiredCheckNames, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-register-git-github");
import { type NextFunction, type Request, type Response } from "express";
@@ -2349,11 +2349,12 @@ export async function mergeTaskPr(
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
const client = new GitHubClient(token);
const requiredCheckNames = resolveRequiredCheckNames(settings);
/*
/*
FNXC:DashboardPrMergeGate 2026-08-09-15:43:
The pre-flight getPrMergeStatus call runs before mergePr and fails closed with an unstructured 409 when readiness or the checked head SHA is absent. After mergePr fails, the catch block performs a distinct second refresh whose classifyGhError diagnosis owns the structured 422/502 and merged-reconciliation contract. Tests must sequence mockResolvedValueOnce calls for both stages: a blanket mock is consumed by pre-flight and hides post-failure diagnosis, the FN-8855 regression that left this suite red.
*/
const mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames });
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
const mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
const nativeAutoMerge = settings.githubNativeAutoMerge === true;
if (!nativeAutoMerge && !mergeStatus.mergeReady) {
throw conflict(`PR cannot merge: ${mergeStatus.blockingReasons.join("; ")}`);
@@ -2392,7 +2393,7 @@ export async function mergeTaskPr(
} catch (error) {
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
try {
mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames });
mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
} catch {
// A refresh failure cannot invent GitHub state; retain the original command diagnosis.
}
@@ -2493,10 +2494,12 @@ export async function refreshPrInBackground(
const taskPrs = task ? getTaskPrList(task) : currentPrInfos;
const settings = await store.getSettings();
const requiredCheckNames = resolveRequiredCheckNames(settings);
const resolveIngestedChecks = createIngestedCheckResolver(store.getAsyncLayer?.());
const checkGateOptions = { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) };
for (const currentPrInfo of taskPrs) {
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, { requiredCheckNames });
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, { requiredCheckNames });
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, checkGateOptions);
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, checkGateOptions);
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
@@ -5964,6 +5967,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
const settings = await scopedStore.getSettings();
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
const checkGateOptions = { requiredCheckNames: resolveRequiredCheckNames(settings), ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) };
const client = new GitHubClient();
const refreshedEntries: Array<{
prInfo: PrInfo;
@@ -5980,8 +5985,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
for (let i = 0; i < prList.length; i += batchSize) {
const batch = prList.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (priorPr) => {
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number, { requiredCheckNames: resolveRequiredCheckNames(settings) });
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number, { requiredCheckNames: resolveRequiredCheckNames(settings) });
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number, checkGateOptions);
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number, checkGateOptions);
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
try {
@@ -6250,7 +6255,9 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
const client = new GitHubClient();
const snapshot = await client.getPrReviewSnapshot(owner, repo, primaryPr.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
const requiredCheckNames = resolveRequiredCheckNames(await scopedStore.getSettings());
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
const snapshot = await client.getPrReviewSnapshot(owner, repo, primaryPr.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
const fusionThread = (task.comments ?? []).filter((comment) =>
comment.source === "github-review" || comment.source === "github-review-comment"
);
@@ -6324,7 +6331,9 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
const client = new GitHubClient();
const checksResult = await client.getAllPrChecks(owner, repo, primaryPr.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
const requiredCheckNames = resolveRequiredCheckNames(await scopedStore.getSettings());
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
const checksResult = await client.getAllPrChecks(owner, repo, primaryPr.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
res.json({
checks: checksResult.checks,

View File

@@ -1,4 +1,4 @@
import { createLogger } from "@fusion/core";
import { createLogger, recordGitHubCheckStateAsync } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-register-signal-routes");
import type { Request, Response } from "express";
@@ -224,6 +224,10 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
try {
const at = signalTimestampToIso(signal.timestamp) ?? new Date().toISOString();
const layer = requireAsyncLayer(store, "Signal incident storage");
if (signal.ciCheck && layer.projectId?.trim()) {
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: webhook writes are best-effort; scheduled self-healing, not delivery traffic, owns expiry. */
await recordGitHubCheckStateAsync(layer, { ...signal.ciCheck, reportedAt: signal.ciCheck.reportedAt ?? at, detailsUrl: signal.ciCheck.detailsUrl ?? signal.link, externalId: signal.externalId }, layer.projectId);
}
const resolved = await resolveIncident(layer, signal.groupingKey, at);
return { status: 200, recoveryResolved: resolved !== null };
} catch (err) {
@@ -268,6 +272,10 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
if (signal.resolution === "resolved") {
await resolveIncident(layer, signal.groupingKey, at);
}
if (signal.ciCheck && layer.projectId?.trim()) {
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: retain via engine maintenance so quiet repositories expire too; never prune on ingestion. */
await recordGitHubCheckStateAsync(layer, { ...signal.ciCheck, reportedAt: signal.ciCheck.reportedAt ?? at, detailsUrl: signal.ciCheck.detailsUrl ?? signal.link, externalId: signal.externalId }, layer.projectId);
}
} catch (err) {
severityAuditLog.error("[signal-incident-bridge] Failed to record connector signal", err);
}

View File

@@ -1,4 +1,4 @@
import { createLogger, resolveRequiredCheckNames } from "@fusion/core";
import { createIngestedCheckResolver, createLogger, resolveRequiredCheckNames } from "@fusion/core";
import type { Request, Response } from "express";
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
@@ -6484,7 +6484,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
throw badRequest("Could not determine GitHub repository for PR review fetch");
}
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
const requiredCheckNames = resolveRequiredCheckNames(await scopedStore.getSettings());
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
} else {
reviewData = await buildDirectTaskReviewData(task, scopedStore);
}
@@ -6512,7 +6514,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
throw badRequest("Could not determine GitHub repository for PR review refresh");
}
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
const requiredCheckNames = resolveRequiredCheckNames(await scopedStore.getSettings());
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
} else {
reviewData = await buildDirectTaskReviewData(task, scopedStore);
}
@@ -6561,7 +6565,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
throw badRequest("Could not determine GitHub repository for PR review fetch");
}
canonicalReviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
const requiredCheckNames = resolveRequiredCheckNames(await scopedStore.getSettings());
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
canonicalReviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) });
} else {
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
}

View File

@@ -88,6 +88,12 @@ export interface Signal {
* behavior for every existing adapter.
*/
recoveryOnly?: boolean;
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35:
Only the GitHub adapter supplies terminal CI data; absence on all other sources keeps their
incident behavior unchanged. Invalid repo/SHA drops the entire descriptor rather than guessing.
*/
ciCheck?: { repo: string; headSha: string; checkName: string; state: string; eventKind: "check_suite" | "workflow_run" | "status"; reportedAt?: string; detailsUrl?: string };
/**
* 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
@@ -331,6 +337,22 @@ export function applySignalCaps(signal: Signal): Signal {
signal.link && isSafeExternalUrl(signal.link)
? capString(signal.link, SIGNAL_FIELD_CAPS.link)
: undefined;
const ciCheck = signal.ciCheck;
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-15:42:
A check-state repository must be the webhook's exact owner/repository slug. Reject malformed
slugs with the descriptor so an external payload cannot create a state the merge gate might
later compare against an unrelated repository.
*/
const repo = ciCheck?.repo.trim();
const headSha = ciCheck?.headSha.trim();
const validCiCheck = ciCheck && repo && headSha
&& repo.length <= SIGNAL_FIELD_CAPS.groupingKey
&& /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)
&& /^[0-9a-f]{7,64}$/i.test(headSha)
&& ciCheck.checkName.trim()
? { ...ciCheck, repo, headSha, checkName: capString(ciCheck.checkName, SIGNAL_FIELD_CAPS.groupingKey), detailsUrl: ciCheck.detailsUrl ? capString(ciCheck.detailsUrl, SIGNAL_FIELD_CAPS.link) : undefined }
: undefined;
return {
...signal,
title: capString(signal.title, SIGNAL_FIELD_CAPS.title) || "(untitled signal)",
@@ -338,5 +360,6 @@ export function applySignalCaps(signal: Signal): Signal {
groupingKey: capString(signal.groupingKey, SIGNAL_FIELD_CAPS.groupingKey),
link,
meta,
ciCheck: validCiCheck,
};
}

View File

@@ -128,6 +128,8 @@ export const githubSource: SignalSource = {
resolution: mapped.resolution,
...("recoveryOnly" in mapped && mapped.recoveryOnly ? { recoveryOnly: true } : {}),
link, timestamp: at,
/* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: terminal payload identity, never ambient repository config, is the only admissible event-driven check source. */
ciCheck: { repo: repositoryName, headSha: sha, checkName, state: outcome, eventKind: kind, reportedAt: at ? new Date(at).toISOString() : undefined, detailsUrl: link },
meta: { kind, repository: repositoryName, branch, sha, checkName, status, conclusion, runAttempt: asNumber(event.run_attempt), pullRequests: pullNumbers(event.pull_requests) },
};
return applySignalCaps(signal);

View File

@@ -0,0 +1,121 @@
import { EventEmitter } from "node:events";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
listGitHubCheckStatesAsync,
recordGitHubCheckStateAsync,
} from "@fusion/core";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../../core/src/__test-utils__/pg-test-harness.js";
import { SelfHealingManager } from "../self-healing.js";
function createStore(layer: { projectId?: string } | null) {
return Object.assign(new EventEmitter(), {
getAsyncLayer: vi.fn(() => layer),
getSettings: vi.fn().mockResolvedValue({ globalPause: true, enginePaused: true, maintenanceIntervalMs: 0 }),
listTasks: vi.fn().mockResolvedValue([]),
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 0, checkpointed: 0 }),
}) as any;
}
/** Keeps the scheduler test on the registered retention step, not unrelated filesystem or git maintenance. */
function stubUnrelatedBatchOneSteps(manager: SelfHealingManager): void {
for (const method of [
"pruneWorktrees",
"cleanupOrphans",
"cleanupStaleTempMergeWorktrees",
"cleanupOrphanedBranches",
"reconcileStaleSymbolLocks",
"maintainTaskFts",
"enforceWorktreeCap",
]) {
vi.spyOn(manager as any, method).mockResolvedValue(0);
}
}
describe("GitHub check-state maintenance retention", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-08-09T14:35:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("skips layer-less and unscoped stores rather than pruning an unscoped partition", async () => {
const layerless = new SelfHealingManager(createStore(null), { rootDir: "/tmp/test-project" });
const unscoped = new SelfHealingManager(createStore({ projectId: "" }), { rootDir: "/tmp/test-project" });
await (layerless as any).pruneGitHubCheckStatesForMaintenance();
await (unscoped as any).pruneGitHubCheckStatesForMaintenance();
expect((layerless as any).githubCheckStateRetentionLastPrunedAt).toEqual(new Map());
expect((unscoped as any).githubCheckStateRetentionLastPrunedAt).toEqual(new Map());
layerless.stop();
unscoped.stop();
});
it("keeps a failed retention attempt diagnostic-only and continues batch-1 maintenance", async () => {
const layer = { projectId: "project-a", db: {} };
const manager = new SelfHealingManager(createStore(layer), { rootDir: "/tmp/test-project" });
stubUnrelatedBatchOneSteps(manager);
const cleanupOrphans = vi.spyOn(manager as any, "cleanupOrphans");
await expect((manager as any).runMaintenance()).resolves.toBeUndefined();
expect(cleanupOrphans).toHaveBeenCalledOnce();
expect((manager as any).githubCheckStateRetentionLastPrunedAt).toEqual(new Map());
manager.stop();
});
});
pgDescribe("GitHub check-state retention uses the production maintenance scheduler", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_github_check_retention",
projectId: "project-a",
});
beforeAll(h.beforeAll);
beforeEach(async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-08-09T14:35:00.000Z"));
await h.beforeEach();
});
afterEach(async () => {
vi.useRealTimers();
await h.afterEach();
});
afterAll(h.afterAll);
it("prunes expired rows through runMaintenance, gates repeats, and retries after six hours without a delivery", async () => {
const layer = h.layer();
const manager = new SelfHealingManager(createStore(layer), { rootDir: "/tmp/test-project" });
stubUnrelatedBatchOneSteps(manager);
const input = {
repo: "owner/repo",
headSha: "abcdef1",
checkName: "ci/build",
state: "success",
reportedAt: "2026-08-01T00:00:00.000Z",
};
// FNXC:PrMergeEventDrivenChecks 2026-08-09-15:59:
// Exercise the scheduled owner against a real row: retention must delete expired state even
// when no further webhook delivery arrives, rather than relying on a mocked prune callback.
await recordGitHubCheckStateAsync(layer, input, "project-a");
vi.advanceTimersByTime(14 * 86_400_000 + 1);
await (manager as any).runMaintenance();
await expect(listGitHubCheckStatesAsync(layer, input, "project-a")).resolves.toEqual([]);
const firstPrunedAt = (manager as any).githubCheckStateRetentionLastPrunedAt.get("project-a");
await (manager as any).runMaintenance();
expect((manager as any).githubCheckStateRetentionLastPrunedAt.get("project-a")).toBe(firstPrunedAt);
vi.advanceTimersByTime(6 * 60 * 60 * 1000);
await (manager as any).runMaintenance();
expect((manager as any).githubCheckStateRetentionLastPrunedAt.get("project-a")).toBeGreaterThan(firstPrunedAt);
manager.stop();
});
});

View File

@@ -37,6 +37,7 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR
resolveProjectColumnsForRoles,
REVIEW_ROLES,
pruneTaskLifecycleEvents,
pruneGitHubCheckStatesAsync,
} from "@fusion/core";
import { finalizePlanningSegment } from "@fusion/core";
import type { MeshLeaseManager } from "./project/mesh-lease-manager.js";
@@ -898,6 +899,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
private symbolLockNoActionAudited = false;
private maintenanceTickCounter = 0;
private readonly taskLifecycleRetentionLastPrunedAt = new Map<string, number>();
private readonly githubCheckStateRetentionLastPrunedAt = new Map<string, number>();
private readonly processBootStartedAt = Date.now();
private lastDbCorruptionNotifiedAt: number | null = null;
@@ -2179,6 +2181,26 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
}
/*
FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35:
Scheduled maintenance owns the 14-day core retention window because inactive repositories stop
sending webhooks. Failures are diagnostic-only and an empty partition is never substituted.
*/
private async pruneGitHubCheckStatesForMaintenance(): Promise<void> {
const layer = this.store.getAsyncLayer();
const projectId = layer?.projectId?.trim();
if (!layer || !projectId) return;
const now = Date.now();
const lastPrunedAt = this.githubCheckStateRetentionLastPrunedAt.get(projectId) ?? 0;
if (now - lastPrunedAt < 6 * 60 * 60 * 1000) return;
try {
await pruneGitHubCheckStatesAsync(layer, projectId);
this.githubCheckStateRetentionLastPrunedAt.set(projectId, now);
} catch (error) {
log.warn(`GitHub check-state retention failed for project ${projectId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private isPastInterruptedMergeGrace(task: Task, timeoutMs: number): boolean {
const updatedAt = task.updatedAt ? Date.parse(task.updatedAt) : 0;
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false;
@@ -2551,6 +2573,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
name: "prune-task-lifecycle-events",
fn: async () => this.pruneTaskLifecycleEventsForMaintenance(),
},
{ name: "prune-github-check-states", fn: async () => this.pruneGitHubCheckStatesForMaintenance() },
{ name: "cleanup-orphans", fn: () => this.cleanupOrphans() },
{
name: "cleanup-stale-temp-merge-worktrees",