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:
7
.changeset/fn-8903-event-driven-checks.md
Normal file
7
.changeset/fn-8903-event-driven-checks.md
Normal 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.
|
||||||
@@ -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.
|
- 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
|
- `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 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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. |
|
| `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). |
|
| `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. |
|
| `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"`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ Normalization:
|
|||||||
|
|
||||||
## GitHub
|
## 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:
|
Normalization uses:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const execFileCalls = vi.hoisted(
|
|||||||
() => [] as Array<{ file: string; args: string[]; cwd: string | undefined }>,
|
() => [] as Array<{ file: string; args: string[]; cwd: string | undefined }>,
|
||||||
);
|
);
|
||||||
const refreshFixture = vi.hoisted(() => ({ next: 0, branch: "fusion/fn-9601" }));
|
const refreshFixture = vi.hoisted(() => ({ next: 0, branch: "fusion/fn-9601" }));
|
||||||
|
const createIngestedCheckResolverMock = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("node:fs/promises", async () => {
|
vi.mock("node:fs/promises", async () => {
|
||||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||||
return {
|
return {
|
||||||
@@ -72,10 +73,11 @@ vi.mock("@fusion/core", async () => {
|
|||||||
release: vi.fn(async () => undefined),
|
release: vi.fn(async () => undefined),
|
||||||
quarantine: 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 { activeSessionRegistry } from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
cleanupMergedTaskArtifacts,
|
cleanupMergedTaskArtifacts,
|
||||||
@@ -198,6 +200,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" });
|
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" });
|
||||||
// Same-repo default: push owner matches fetch owner so heads stay unqualified.
|
// Same-repo default: push owner matches fetch owner so heads stay unqualified.
|
||||||
vi.mocked(getPushRepo).mockReturnValue({ owner: "owner", repo: "repo" });
|
vi.mocked(getPushRepo).mockReturnValue({ owner: "owner", repo: "repo" });
|
||||||
|
vi.mocked(createIngestedCheckResolver).mockReset().mockReturnValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("central-install repo threading (gh-4)", () => {
|
describe("central-install repo threading (gh-4)", () => {
|
||||||
@@ -1687,6 +1690,51 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(result).toBe("merged");
|
expect(result).toBe("merged");
|
||||||
expect(github.mergePr).toHaveBeenCalled();
|
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 () => {
|
it("waits for a configured Fusion check, forwards normalized names, and does not merge", async () => {
|
||||||
const task: MockTask = {
|
const task: MockTask = {
|
||||||
id: "FN-9103", title: "test", description: "desc", column: "in-review",
|
id: "FN-9103", title: "test", description: "desc", column: "in-review",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
acquireWorktreePathReservation,
|
acquireWorktreePathReservation,
|
||||||
type WorktreePathReservation,
|
type WorktreePathReservation,
|
||||||
resolveRequiredCheckNames,
|
resolveRequiredCheckNames,
|
||||||
|
createIngestedCheckResolver,
|
||||||
|
type IngestedCheckState,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||||
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
|
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
|
||||||
@@ -88,7 +90,7 @@ import type {
|
|||||||
interface GitHubOperations {
|
interface GitHubOperations {
|
||||||
findPrForBranch(params: { owner?: string; repo?: string; head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
|
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>;
|
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;
|
prInfo: PrInfo;
|
||||||
reviewDecision: string | null;
|
reviewDecision: string | null;
|
||||||
checks: Array<{ name: string; required: boolean; state: string }>;
|
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.
|
// Defensive: keep the base settings if effective resolution fails entirely.
|
||||||
}
|
}
|
||||||
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
||||||
|
const ingestedCheckResolver = createIngestedCheckResolver(store.getAsyncLayer?.());
|
||||||
const getPrMergeStatus = (number: number) => requiredCheckNames.length > 0
|
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);
|
: github.getPrMergeStatus(prRepo.owner, prRepo.repo, number);
|
||||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
const projectDefaultBranch = resolvedIntegrationBranch;
|
||||||
|
|||||||
66
packages/core/src/__tests__/ingested-checks.test.ts
Normal file
66
packages/core/src/__tests__/ingested-checks.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ export * from "./configuration-revision-store.js";
|
|||||||
export * from "./effective-settings-overlay.js";
|
export * from "./effective-settings-overlay.js";
|
||||||
export * from "./experimental-features.js";
|
export * from "./experimental-features.js";
|
||||||
export * from "./global-settings.js";
|
export * from "./global-settings.js";
|
||||||
|
export * from "./ingested-checks.js";
|
||||||
export * from "./mcp-config.js";
|
export * from "./mcp-config.js";
|
||||||
export * from "./mcp-discovery.js";
|
export * from "./mcp-discovery.js";
|
||||||
export * from "./moved-settings.js";
|
export * from "./moved-settings.js";
|
||||||
|
|||||||
32
packages/core/src/config/ingested-checks.ts
Normal file
32
packages/core/src/config/ingested-checks.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ export {
|
|||||||
} from "./ai/openai-models.js";
|
} from "./ai/openai-models.js";
|
||||||
export type { OpenAiCodexProviderRegistration } from "./ai/openai-models.js";
|
export type { OpenAiCodexProviderRegistration } from "./ai/openai-models.js";
|
||||||
export { resolveRequiredCheckNames } from "./config/required-checks.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 { detectImageMimeFromBytes } from "./i18n/image-mime.js";
|
||||||
export type { DetectedImageMime } from "./i18n/image-mime.js";
|
export type { DetectedImageMime } from "./i18n/image-mime.js";
|
||||||
export {
|
export {
|
||||||
@@ -2615,6 +2617,14 @@ export {
|
|||||||
releaseIncidentFixTaskClaimAsync,
|
releaseIncidentFixTaskClaimAsync,
|
||||||
} from "./task-store/async/async-monitor.js";
|
} from "./task-store/async/async-monitor.js";
|
||||||
export type { Deployment as AsyncDeployment, Incident as AsyncIncident } 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:
|
// FNXC:RuntimeSatelliteCompletion 2026-06-24-23:40:
|
||||||
// Async AiSessionStore helpers exported for the dashboard AiSessionStore dual-path.
|
// Async AiSessionStore helpers exported for the dashboard AiSessionStore dual-path.
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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: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. */
|
/* 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. */
|
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||||
const INITIAL_SCHEMA_VERSION = "0000";
|
const INITIAL_SCHEMA_VERSION = "0000";
|
||||||
@@ -193,6 +194,8 @@ export const MULTI_ROLE_WORKFLOW_AGENTS_VERSION = "0045";
|
|||||||
export const WORKFLOW_PRINCIPAL_FENCE_VERSION = "0046";
|
export const WORKFLOW_PRINCIPAL_FENCE_VERSION = "0046";
|
||||||
/** FNXC:TaskRecommendations 2026-08-08-05:02: explicit registration prevents recommendation JSONB upgrades being skipped. */
|
/** FNXC:TaskRecommendations 2026-08-08-05:02: explicit registration prevents recommendation JSONB upgrades being skipped. */
|
||||||
export const TASK_RECOMMENDATIONS_VERSION = "0047";
|
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. */
|
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
|
||||||
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
|
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 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 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 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
|
* 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 multiRoleWorkflowAgentsAlreadyApplied = applied.includes(MULTI_ROLE_WORKFLOW_AGENTS_VERSION);
|
||||||
const workflowPrincipalFenceAlreadyApplied = applied.includes(WORKFLOW_PRINCIPAL_FENCE_VERSION);
|
const workflowPrincipalFenceAlreadyApplied = applied.includes(WORKFLOW_PRINCIPAL_FENCE_VERSION);
|
||||||
const taskRecommendationsAlreadyApplied = applied.includes(TASK_RECOMMENDATIONS_VERSION);
|
const taskRecommendationsAlreadyApplied = applied.includes(TASK_RECOMMENDATIONS_VERSION);
|
||||||
|
const githubCheckStatesAlreadyApplied = applied.includes(GITHUB_CHECK_STATES_VERSION);
|
||||||
assertBinaryNotOlderThanDatabase(applied);
|
assertBinaryNotOlderThanDatabase(applied);
|
||||||
let schemaChanged = false;
|
let schemaChanged = false;
|
||||||
|
|
||||||
@@ -1161,6 +1166,14 @@ export async function applySchemaBaseline(
|
|||||||
schemaChanged = true;
|
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 };
|
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1890,6 +1890,33 @@ export const deployments = projectSchema.table("deployments", {
|
|||||||
index("idxDeploymentsService").on(t.service),
|
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", {
|
export const incidents = projectSchema.table("incidents", {
|
||||||
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
|
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
|
||||||
projectId: text("project_id").notNull().default(""),
|
projectId: text("project_id").notNull().default(""),
|
||||||
@@ -2398,7 +2425,7 @@ export const projectTableNames = [
|
|||||||
"milestones", "slices", "mission_features", "ideation_sessions", "ideation_candidates", "mission_events", "plugins",
|
"milestones", "slices", "mission_features", "ideation_sessions", "ideation_candidates", "mission_events", "plugins",
|
||||||
"routines", "project_insights", "project_insight_runs", "project_insight_run_events",
|
"routines", "project_insights", "project_insight_runs", "project_insight_run_events",
|
||||||
"todo_lists", "todo_items", "usage_events", "plugin_activations",
|
"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",
|
"agent_ratings", "chat_sessions", "cli_sessions", "chat_messages",
|
||||||
"run_audit_events", "mission_contract_assertions", "mission_feature_assertions",
|
"run_audit_events", "mission_contract_assertions", "mission_feature_assertions",
|
||||||
"mission_validator_runs", "mission_validator_failures",
|
"mission_validator_runs", "mission_validator_failures",
|
||||||
|
|||||||
104
packages/core/src/task-store/async/async-ci-checks.ts
Normal file
104
packages/core/src/task-store/async/async-ci-checks.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export * from "./async-archive-lineage.js";
|
|||||||
export * from "./async-audit.js";
|
export * from "./async-audit.js";
|
||||||
export * from "./async-branch-groups.js";
|
export * from "./async-branch-groups.js";
|
||||||
export * from "./async-comments-attachments.js";
|
export * from "./async-comments-attachments.js";
|
||||||
|
export * from "./async-ci-checks.js";
|
||||||
export * from "./async-events.js";
|
export * from "./async-events.js";
|
||||||
export * from "./async-lifecycle.js";
|
export * from "./async-lifecycle.js";
|
||||||
export * from "./async-maintenance.js";
|
export * from "./async-maintenance.js";
|
||||||
|
|||||||
@@ -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.
|
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 { 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:
|
* FNXC:WorkflowDeprecation 2026-07-15-16:35:
|
||||||
|
|||||||
@@ -1903,7 +1903,7 @@ describe("GitHubClient", () => {
|
|||||||
const ghPr = {
|
const ghPr = {
|
||||||
number: 42, url: "https://github.com/owner/repo/pull/42", title: "Ready PR", state: "OPEN",
|
number: 42, url: "https://github.com/owner/repo/pull/42", title: "Ready PR", state: "OPEN",
|
||||||
reviewDecision: null, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN",
|
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) => ({
|
const apiPayload = (nodes: unknown[], hasNextPage = false) => ({
|
||||||
data: { repository: { pullRequest: {
|
data: { repository: { pullRequest: {
|
||||||
@@ -1963,6 +1963,61 @@ describe("GitHubClient", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
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({
|
const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1989,7 +2044,7 @@ describe("GitHubClient", () => {
|
|||||||
|
|
||||||
describe("getAllPrChecks", () => {
|
describe("getAllPrChecks", () => {
|
||||||
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
|
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: "required-ci", state: "SUCCESS", link: "https://example.com/ci", bucket: "pass" },
|
||||||
{ name: "optional-preview", state: "FAILURE", link: "https://example.com/preview", bucket: "none" },
|
{ 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 () => {
|
it("returns all checks in API mode and ignores non-required failures for rollup", async () => {
|
||||||
const clientWithToken = new GitHubClient("ghp_token");
|
const clientWithToken = new GitHubClient("ghp_token");
|
||||||
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
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: "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" },
|
{ 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();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
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 { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||||
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
|
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +30,7 @@ import { datadogSource } from "../signal-sources/datadog.js";
|
|||||||
import { pagerdutySource } from "../signal-sources/pagerduty.js";
|
import { pagerdutySource } from "../signal-sources/pagerduty.js";
|
||||||
import { gitlabSource } from "../signal-sources/gitlab.js";
|
import { gitlabSource } from "../signal-sources/gitlab.js";
|
||||||
import { GITHUB_OUTCOME_MAP, githubSource } from "../signal-sources/github.js";
|
import { GITHUB_OUTCOME_MAP, githubSource } from "../signal-sources/github.js";
|
||||||
|
import { GitHubClient } from "../github.js";
|
||||||
|
|
||||||
function sign(body: string, secret: string): string {
|
function sign(body: string, secret: string): string {
|
||||||
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
|
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> = {
|
const SECRETS: Record<string, string> = {
|
||||||
FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret",
|
FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret",
|
||||||
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
|
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
|
||||||
@@ -88,6 +111,7 @@ const savedEnv: Record<string, string | undefined> = {};
|
|||||||
const harnesses: PgTestHarness[] = [];
|
const harnesses: PgTestHarness[] = [];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockRunGhJsonAsync.mockReset();
|
||||||
for (const [k, v] of Object.entries(SECRETS)) {
|
for (const [k, v] of Object.entries(SECRETS)) {
|
||||||
savedEnv[k] = process.env[k];
|
savedEnv[k] = process.env[k];
|
||||||
process.env[k] = v;
|
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) {
|
function githubPayload(kind: "check_suite" | "workflow_run" | "status", outcome: string) {
|
||||||
const repository = { full_name: "org/repo", html_url: "https://github.com/org/repo" };
|
const repository = { full_name: "org/repo", html_url: "https://github.com/org/repo" };
|
||||||
if (kind === "status") {
|
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 };
|
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(open.status).toBe(201);
|
||||||
expect(store._tasks).toHaveLength(1);
|
expect(store._tasks).toHaveLength(1);
|
||||||
expect(await incidents(layer)).toMatchObject([{
|
expect(await incidents(layer)).toMatchObject([{
|
||||||
groupingKey: "github:org/repo:check_suite:checks:abc123",
|
groupingKey: "github:org/repo:check_suite:checks:abc1234",
|
||||||
source: "github",
|
source: "github",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
status: "open",
|
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 success = githubPayload("check_suite", "success");
|
||||||
const resolved = await ingestSignal({
|
const resolved = await ingestSignal({
|
||||||
@@ -635,6 +662,9 @@ pgDescribe("ingestSignal — GitHub CI recovery", () => {
|
|||||||
const afterResolution = await incidents(layer);
|
const afterResolution = await incidents(layer);
|
||||||
expect(afterResolution).toHaveLength(1);
|
expect(afterResolution).toHaveLength(1);
|
||||||
expect(afterResolution[0]).toMatchObject({ status: "resolved" });
|
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 resolvedAt = afterResolution[0].resolvedAt;
|
||||||
|
|
||||||
const redelivery = await ingestSignal({
|
const redelivery = await ingestSignal({
|
||||||
@@ -669,6 +699,49 @@ pgDescribe("ingestSignal — GitHub CI recovery", () => {
|
|||||||
expect(await incidents(layer)).toHaveLength(2);
|
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 () => {
|
it("routes all supported GitHub event kinds through the production ingestion seam", async () => {
|
||||||
for (const kind of ["check_suite", "workflow_run", "status"] as const) {
|
for (const kind of ["check_suite", "workflow_run", "status"] as const) {
|
||||||
const { layer, store } = await makeDbStore();
|
const { layer, store } = await makeDbStore();
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
runGh,
|
runGh,
|
||||||
resolveRequiredCheckNames,
|
resolveRequiredCheckNames,
|
||||||
|
mergeIngestedCheckStates,
|
||||||
|
type IngestedCheckState,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
|
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));
|
const unsatisfied = matches.find((check) => !satisfies(check.state));
|
||||||
if (unsatisfied) {
|
if (unsatisfied) {
|
||||||
const githubReason = `required checks not successful: ${name} (${unsatisfied.state})`;
|
// A synthesized ingested check is required, so the legacy filter already owns its
|
||||||
if (!blockingReasons.includes(githubReason)) {
|
// 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})`);
|
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)] };
|
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 {
|
export interface GitHubClientOptions {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -1849,22 +1853,22 @@ export class GitHubClient {
|
|||||||
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames);
|
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
|
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
|
||||||
}
|
}
|
||||||
throw new Error(getGhErrorMessage(err));
|
throw new Error(getGhErrorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.token) {
|
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.");
|
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 resolved = this.resolveRepo(owner, repo);
|
||||||
const pr = await runGhJsonAsync<GhPrViewJson>([
|
const pr = await runGhJsonAsync<GhPrViewJson>([
|
||||||
"pr", "view", String(number),
|
"pr", "view", String(number),
|
||||||
@@ -1901,10 +1905,14 @@ export class GitHubClient {
|
|||||||
startedAt: (check as GhPrCheckJson).startedAt,
|
startedAt: (check as GhPrCheckJson).startedAt,
|
||||||
completedAt: (check as GhPrCheckJson).completedAt,
|
completedAt: (check as GhPrCheckJson).completedAt,
|
||||||
} satisfies PrCheckStatus));
|
} 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({
|
const readiness = isPrMergeReady({
|
||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision ?? null,
|
reviewDecision: pr.reviewDecision ?? null,
|
||||||
checks: normalizedChecks,
|
checks: effectiveChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
requiredCheckNames,
|
requiredCheckNames,
|
||||||
});
|
});
|
||||||
@@ -1912,14 +1920,14 @@ export class GitHubClient {
|
|||||||
return {
|
return {
|
||||||
prInfo,
|
prInfo,
|
||||||
reviewDecision: pr.reviewDecision ?? null,
|
reviewDecision: pr.reviewDecision ?? null,
|
||||||
checks: normalizedChecks,
|
checks: effectiveChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
mergeReady: readiness.ready,
|
mergeReady: readiness.ready,
|
||||||
blockingReasons: readiness.blockingReasons,
|
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 resolved = this.resolveRepo(owner, repo);
|
||||||
const response = await fetch(`${this.baseUrl}/graphql`, {
|
const response = await fetch(`${this.baseUrl}/graphql`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2071,19 +2079,23 @@ export class GitHubClient {
|
|||||||
commentCount: pr.comments.totalCount,
|
commentCount: pr.comments.totalCount,
|
||||||
mergeable,
|
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({
|
const readiness = isPrMergeReady({
|
||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks: gateChecks,
|
checks: effectiveChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
requiredCheckNames,
|
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 {
|
return {
|
||||||
prInfo,
|
prInfo,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks: gateChecks,
|
checks: effectiveChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
mergeReady: readiness.ready,
|
mergeReady: readiness.ready,
|
||||||
blockingReasons: readiness.blockingReasons,
|
blockingReasons: readiness.blockingReasons,
|
||||||
@@ -2099,17 +2111,17 @@ export class GitHubClient {
|
|||||||
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames);
|
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
|
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames, options?.resolveIngestedChecks);
|
||||||
}
|
}
|
||||||
throw new Error(getGhErrorMessage(err));
|
throw new Error(getGhErrorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.token) {
|
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.");
|
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,
|
repo: string | undefined,
|
||||||
number: number,
|
number: number,
|
||||||
requiredCheckNames: string[] = [],
|
requiredCheckNames: string[] = [],
|
||||||
|
resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"],
|
||||||
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
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),
|
"pr", "checks", String(number),
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
"--repo", `${resolved.owner}/${resolved.repo}`,
|
||||||
"--json", "name,state,link,startedAt,completedAt,bucket",
|
"--json", "name,state,link,startedAt,completedAt,bucket",
|
||||||
]).catch(async () => {
|
])).catch(async () => {
|
||||||
const allChecks = await runGhJsonAsync<GhPrCheckJson[]>([
|
const allChecks = await runGhJsonAsync<GhPrCheckJson[]>([
|
||||||
"pr", "checks", String(number),
|
"pr", "checks", String(number),
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
"--repo", `${resolved.owner}/${resolved.repo}`,
|
||||||
"--json", "name,state,link,startedAt,completedAt",
|
"--json", "name,state,link,startedAt,completedAt",
|
||||||
]);
|
]);
|
||||||
const requiredChecks = await runGhJsonAsync<GhPrCheckJson[]>([
|
const requiredChecks = await Promise.resolve(runGhJsonAsync<GhPrCheckJson[]>([
|
||||||
"pr", "checks", String(number),
|
"pr", "checks", String(number),
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
"--repo", `${resolved.owner}/${resolved.repo}`,
|
||||||
"--required",
|
"--required",
|
||||||
"--json", "name,state",
|
"--json", "name,state",
|
||||||
]).catch(() => []);
|
])).catch(() => []);
|
||||||
const requiredNames = new Set(requiredChecks.map((check) => check.name));
|
const requiredNames = new Set(requiredChecks.map((check) => check.name));
|
||||||
return allChecks.map((check) => ({ ...check, bucket: requiredNames.has(check.name) ? "pass" : "none" }));
|
return allChecks.map((check) => ({ ...check, bucket: requiredNames.has(check.name) ? "pass" : "none" }));
|
||||||
});
|
});
|
||||||
@@ -2168,9 +2183,12 @@ export class GitHubClient {
|
|||||||
completedAt: check.completedAt,
|
completedAt: check.completedAt,
|
||||||
} satisfies PrCheckStatus));
|
} 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 {
|
return {
|
||||||
checks: normalized,
|
checks: effectiveChecks,
|
||||||
rollupRequired: this.computeRequiredChecksRollup(normalized),
|
rollupRequired: this.computeRequiredChecksRollup(effectiveChecks),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2179,8 +2197,10 @@ export class GitHubClient {
|
|||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
number: number,
|
number: number,
|
||||||
requiredCheckNames: string[] = [],
|
requiredCheckNames: string[] = [],
|
||||||
|
resolveIngestedChecks?: PrCheckGateOptions["resolveIngestedChecks"],
|
||||||
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
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`, {
|
const response = await fetch(`${this.baseUrl}/graphql`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.buildHeaders(),
|
headers: this.buildHeaders(),
|
||||||
@@ -2188,6 +2208,7 @@ export class GitHubClient {
|
|||||||
query: `query PullRequestAllChecks($owner: String!, $repo: String!, $number: Int!) {
|
query: `query PullRequestAllChecks($owner: String!, $repo: String!, $number: Int!) {
|
||||||
repository(owner: $owner, name: $repo) {
|
repository(owner: $owner, name: $repo) {
|
||||||
pullRequest(number: $number) {
|
pullRequest(number: $number) {
|
||||||
|
headRefOid
|
||||||
commits(last: 1) {
|
commits(last: 1) {
|
||||||
nodes {
|
nodes {
|
||||||
commit {
|
commit {
|
||||||
@@ -2227,6 +2248,7 @@ export class GitHubClient {
|
|||||||
data?: {
|
data?: {
|
||||||
repository?: {
|
repository?: {
|
||||||
pullRequest?: {
|
pullRequest?: {
|
||||||
|
headRefOid?: string | null;
|
||||||
commits: {
|
commits: {
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
commit: {
|
commit: {
|
||||||
@@ -2285,9 +2307,13 @@ export class GitHubClient {
|
|||||||
} satisfies PrCheckStatus];
|
} 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 {
|
return {
|
||||||
checks,
|
checks: effectiveChecks,
|
||||||
rollupRequired: this.computeRequiredChecksRollup(checks),
|
rollupRequired: this.computeRequiredChecksRollup(effectiveChecks),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
const severityAuditLog = createLogger("dashboard-register-git-github");
|
||||||
import { type NextFunction, type Request, type Response } from "express";
|
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 method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
|
||||||
const client = new GitHubClient(token);
|
const client = new GitHubClient(token);
|
||||||
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
||||||
/*
|
/*
|
||||||
FNXC:DashboardPrMergeGate 2026-08-09-15:43:
|
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.
|
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;
|
const nativeAutoMerge = settings.githubNativeAutoMerge === true;
|
||||||
if (!nativeAutoMerge && !mergeStatus.mergeReady) {
|
if (!nativeAutoMerge && !mergeStatus.mergeReady) {
|
||||||
throw conflict(`PR cannot merge: ${mergeStatus.blockingReasons.join("; ")}`);
|
throw conflict(`PR cannot merge: ${mergeStatus.blockingReasons.join("; ")}`);
|
||||||
@@ -2392,7 +2393,7 @@ export async function mergeTaskPr(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
|
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// A refresh failure cannot invent GitHub state; retain the original command diagnosis.
|
// 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 taskPrs = task ? getTaskPrList(task) : currentPrInfos;
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
||||||
|
const resolveIngestedChecks = createIngestedCheckResolver(store.getAsyncLayer?.());
|
||||||
|
const checkGateOptions = { requiredCheckNames, ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) };
|
||||||
|
|
||||||
for (const currentPrInfo of taskPrs) {
|
for (const currentPrInfo of taskPrs) {
|
||||||
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, { requiredCheckNames });
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, checkGateOptions);
|
||||||
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, { requiredCheckNames });
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, checkGateOptions);
|
||||||
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
|
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
|
||||||
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
||||||
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
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 settings = await scopedStore.getSettings();
|
||||||
|
const resolveIngestedChecks = createIngestedCheckResolver(scopedStore.getAsyncLayer?.());
|
||||||
|
const checkGateOptions = { requiredCheckNames: resolveRequiredCheckNames(settings), ...(resolveIngestedChecks ? { resolveIngestedChecks } : {}) };
|
||||||
const client = new GitHubClient();
|
const client = new GitHubClient();
|
||||||
const refreshedEntries: Array<{
|
const refreshedEntries: Array<{
|
||||||
prInfo: PrInfo;
|
prInfo: PrInfo;
|
||||||
@@ -5980,8 +5985,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
for (let i = 0; i < prList.length; i += batchSize) {
|
for (let i = 0; i < prList.length; i += batchSize) {
|
||||||
const batch = prList.slice(i, i + batchSize);
|
const batch = prList.slice(i, i + batchSize);
|
||||||
const results = await Promise.all(batch.map(async (priorPr) => {
|
const results = await Promise.all(batch.map(async (priorPr) => {
|
||||||
const reviewSnapshot = await client.getPrReviewSnapshot(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, { requiredCheckNames: resolveRequiredCheckNames(settings) });
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number, checkGateOptions);
|
||||||
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
||||||
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
||||||
try {
|
try {
|
||||||
@@ -6250,7 +6255,9 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new GitHubClient();
|
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) =>
|
const fusionThread = (task.comments ?? []).filter((comment) =>
|
||||||
comment.source === "github-review" || comment.source === "github-review-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 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({
|
res.json({
|
||||||
checks: checksResult.checks,
|
checks: checksResult.checks,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLogger } from "@fusion/core";
|
import { createLogger, recordGitHubCheckStateAsync } from "@fusion/core";
|
||||||
|
|
||||||
const severityAuditLog = createLogger("dashboard-register-signal-routes");
|
const severityAuditLog = createLogger("dashboard-register-signal-routes");
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -224,6 +224,10 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
|
|||||||
try {
|
try {
|
||||||
const at = signalTimestampToIso(signal.timestamp) ?? new Date().toISOString();
|
const at = signalTimestampToIso(signal.timestamp) ?? new Date().toISOString();
|
||||||
const layer = requireAsyncLayer(store, "Signal incident storage");
|
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);
|
const resolved = await resolveIncident(layer, signal.groupingKey, at);
|
||||||
return { status: 200, recoveryResolved: resolved !== null };
|
return { status: 200, recoveryResolved: resolved !== null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -268,6 +272,10 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
|
|||||||
if (signal.resolution === "resolved") {
|
if (signal.resolution === "resolved") {
|
||||||
await resolveIncident(layer, signal.groupingKey, at);
|
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) {
|
} catch (err) {
|
||||||
severityAuditLog.error("[signal-incident-bridge] Failed to record connector signal", err);
|
severityAuditLog.error("[signal-incident-bridge] Failed to record connector signal", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLogger, resolveRequiredCheckNames } from "@fusion/core";
|
import { createIngestedCheckResolver, createLogger, resolveRequiredCheckNames } from "@fusion/core";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
|
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
|
||||||
@@ -6484,7 +6484,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
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 {
|
} else {
|
||||||
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
@@ -6512,7 +6514,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
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 {
|
} else {
|
||||||
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
@@ -6561,7 +6565,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
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 {
|
} else {
|
||||||
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
|
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ export interface Signal {
|
|||||||
* behavior for every existing adapter.
|
* behavior for every existing adapter.
|
||||||
*/
|
*/
|
||||||
recoveryOnly?: boolean;
|
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
|
* 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
|
* 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)
|
signal.link && isSafeExternalUrl(signal.link)
|
||||||
? capString(signal.link, SIGNAL_FIELD_CAPS.link)
|
? capString(signal.link, SIGNAL_FIELD_CAPS.link)
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
...signal,
|
...signal,
|
||||||
title: capString(signal.title, SIGNAL_FIELD_CAPS.title) || "(untitled 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),
|
groupingKey: capString(signal.groupingKey, SIGNAL_FIELD_CAPS.groupingKey),
|
||||||
link,
|
link,
|
||||||
meta,
|
meta,
|
||||||
|
ciCheck: validCiCheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export const githubSource: SignalSource = {
|
|||||||
resolution: mapped.resolution,
|
resolution: mapped.resolution,
|
||||||
...("recoveryOnly" in mapped && mapped.recoveryOnly ? { recoveryOnly: true } : {}),
|
...("recoveryOnly" in mapped && mapped.recoveryOnly ? { recoveryOnly: true } : {}),
|
||||||
link, timestamp: at,
|
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) },
|
meta: { kind, repository: repositoryName, branch, sha, checkName, status, conclusion, runAttempt: asNumber(event.run_attempt), pullRequests: pullNumbers(event.pull_requests) },
|
||||||
};
|
};
|
||||||
return applySignalCaps(signal);
|
return applySignalCaps(signal);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR
|
|||||||
resolveProjectColumnsForRoles,
|
resolveProjectColumnsForRoles,
|
||||||
REVIEW_ROLES,
|
REVIEW_ROLES,
|
||||||
pruneTaskLifecycleEvents,
|
pruneTaskLifecycleEvents,
|
||||||
|
pruneGitHubCheckStatesAsync,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { finalizePlanningSegment } from "@fusion/core";
|
import { finalizePlanningSegment } from "@fusion/core";
|
||||||
import type { MeshLeaseManager } from "./project/mesh-lease-manager.js";
|
import type { MeshLeaseManager } from "./project/mesh-lease-manager.js";
|
||||||
@@ -898,6 +899,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
private symbolLockNoActionAudited = false;
|
private symbolLockNoActionAudited = false;
|
||||||
private maintenanceTickCounter = 0;
|
private maintenanceTickCounter = 0;
|
||||||
private readonly taskLifecycleRetentionLastPrunedAt = new Map<string, number>();
|
private readonly taskLifecycleRetentionLastPrunedAt = new Map<string, number>();
|
||||||
|
private readonly githubCheckStateRetentionLastPrunedAt = new Map<string, number>();
|
||||||
private readonly processBootStartedAt = Date.now();
|
private readonly processBootStartedAt = Date.now();
|
||||||
private lastDbCorruptionNotifiedAt: number | null = null;
|
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 {
|
private isPastInterruptedMergeGrace(task: Task, timeoutMs: number): boolean {
|
||||||
const updatedAt = task.updatedAt ? Date.parse(task.updatedAt) : 0;
|
const updatedAt = task.updatedAt ? Date.parse(task.updatedAt) : 0;
|
||||||
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false;
|
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false;
|
||||||
@@ -2551,6 +2573,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
name: "prune-task-lifecycle-events",
|
name: "prune-task-lifecycle-events",
|
||||||
fn: async () => this.pruneTaskLifecycleEventsForMaintenance(),
|
fn: async () => this.pruneTaskLifecycleEventsForMaintenance(),
|
||||||
},
|
},
|
||||||
|
{ name: "prune-github-check-states", fn: async () => this.pruneGitHubCheckStatesForMaintenance() },
|
||||||
{ name: "cleanup-orphans", fn: () => this.cleanupOrphans() },
|
{ name: "cleanup-orphans", fn: () => this.cleanupOrphans() },
|
||||||
{
|
{
|
||||||
name: "cleanup-stale-temp-merge-worktrees",
|
name: "cleanup-stale-temp-merge-worktrees",
|
||||||
|
|||||||
Reference in New Issue
Block a user