diff --git a/.changeset/fn-8903-event-driven-checks.md b/.changeset/fn-8903-event-driven-checks.md new file mode 100644 index 0000000000..f019a36580 --- /dev/null +++ b/.changeset/fn-8903-event-driven-checks.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 5e0de8d7ae..b1ad0ea1a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6190b83d23..cd2d1694d2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/docs/signals-connectors.md b/docs/signals-connectors.md index 5e1bb99394..b2bd3ef76b 100644 --- a/docs/signals-connectors.md +++ b/docs/signals-connectors.md @@ -128,7 +128,7 @@ Normalization: ## GitHub -Set `FUSION_SIGNAL_GITHUB_SECRET` and configure `https:///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:///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: diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 54a56585ac..54ba9a1344 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -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("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).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).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 }> })._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).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", diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 3cabbd3c11..b5f396b739 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -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; createPr(params: { owner?: string; repo?: string; title: string; body: string; head: string; base?: string }): Promise; - 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 }): 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; diff --git a/packages/core/src/__tests__/ingested-checks.test.ts b/packages/core/src/__tests__/ingested-checks.test.ts new file mode 100644 index 0000000000..6527ea7677 --- /dev/null +++ b/packages/core/src/__tests__/ingested-checks.test.ts @@ -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[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, + }); + }); +}); diff --git a/packages/core/src/__tests__/postgres/github-check-states.pg.test.ts b/packages/core/src/__tests__/postgres/github-check-states.pg.test.ts new file mode 100644 index 0000000000..1da6014947 --- /dev/null +++ b/packages/core/src/__tests__/postgres/github-check-states.pg.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 26f7e4b787..7b1e9e724d 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -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"; diff --git a/packages/core/src/config/ingested-checks.ts b/packages/core/src/config/ingested-checks.ts new file mode 100644 index 0000000000..796303d837 --- /dev/null +++ b/packages/core/src/config/ingested-checks.ts @@ -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(input: { + polled: T[]; ingested: IngestedCheckState[]; requiredCheckNames: string[]; repo: string; headSha?: string; +}): { checks: T[]; appliedNames: Set } { + 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(); + 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 }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 414182004f..3c71dfe41f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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. diff --git a/packages/core/src/postgres/migrations/0048_fn_8903_github_check_states.sql b/packages/core/src/postgres/migrations/0048_fn_8903_github_check_states.sql new file mode 100644 index 0000000000..7084015e67 --- /dev/null +++ b/packages/core/src/postgres/migrations/0048_fn_8903_github_check_states.sql @@ -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(); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index b53fce2371..26de8e9e8c 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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 }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index d2285ba7f3..e5a6563e14 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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", diff --git a/packages/core/src/task-store/async/async-ci-checks.ts b/packages/core/src/task-store/async/async-ci-checks.ts new file mode 100644 index 0000000000..54c04603b0 --- /dev/null +++ b/packages/core/src/task-store/async/async-ci-checks.ts @@ -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; +} + +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 { + 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 { + 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 { + 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); +} diff --git a/packages/core/src/task-store/async/index.ts b/packages/core/src/task-store/async/index.ts index d5f89f8185..8d0bbb4cc3 100644 --- a/packages/core/src/task-store/async/index.ts +++ b/packages/core/src/task-store/async/index.ts @@ -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"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7a7f4076d9..77031ee356 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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: diff --git a/packages/dashboard/src/__tests__/github.test.ts b/packages/dashboard/src/__tests__/github.test.ts index 9b162873a5..f354fb1070 100644 --- a/packages/dashboard/src/__tests__/github.test.ts +++ b/packages/dashboard/src/__tests__/github.test.ts @@ -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(); }); }); diff --git a/packages/dashboard/src/__tests__/register-signal-routes.test.ts b/packages/dashboard/src/__tests__/register-signal-routes.test.ts index d914c04c1d..6d650b0ddc 100644 --- a/packages/dashboard/src/__tests__/register-signal-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-signal-routes.test.ts @@ -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()), + 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 = { FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret", FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret", @@ -88,6 +111,7 @@ const savedEnv: Record = {}; 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 { 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(); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 6c5bbc5adb..6811230ba9 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -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; } 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 { + private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[], resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"]): Promise { const resolved = this.resolveRepo(owner, repo); const pr = await runGhJsonAsync([ "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 { + private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[], resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"]): Promise { 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([ + let checks = await Promise.resolve(runGhJsonAsync([ "pr", "checks", String(number), "--repo", `${resolved.owner}/${resolved.repo}`, "--json", "name,state,link,startedAt,completedAt,bucket", - ]).catch(async () => { + ])).catch(async () => { const allChecks = await runGhJsonAsync([ "pr", "checks", String(number), "--repo", `${resolved.owner}/${resolved.repo}`, "--json", "name,state,link,startedAt,completedAt", ]); - const requiredChecks = await runGhJsonAsync([ + const requiredChecks = await Promise.resolve(runGhJsonAsync([ "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), }; } diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index b36ac81269..fed076910d 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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> | 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, diff --git a/packages/dashboard/src/routes/register-signal-routes.ts b/packages/dashboard/src/routes/register-signal-routes.ts index ca8451ab9a..7b799d4b35 100644 --- a/packages/dashboard/src/routes/register-signal-routes.ts +++ b/packages/dashboard/src/routes/register-signal-routes.ts @@ -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 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(); + }); +}); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index a725727e49..ba0ae75ed8 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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(); + private readonly githubCheckStateRetentionLastPrunedAt = new Map(); 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 { + 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",