feat(pr): node-agnostic PR reconcile + hold-release firing (U4)
Adds PrReconciler — a per-repo, self-owned polling loop (started from the runtime layer in project-engine.ts, NOT the scheduler) that ETag-probes GitHub, deep-fetches on change, persists mirror state, clears unverified on first reconcile, and fires releaseHeldTaskByEvent(github:pr-<event>) for transitions (changes-requested/approved/conflict/conflict-cleared/ merged/closed). Drops terminal entities; persists an audit event on error. GitHub ops injected via PrReconcileGithubOps at the 3 CLI sites; engine never imports the dashboard client. scheduler.ts stays PR-free (R20), pinned by a regression test. 8 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
createPrReconcileGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
@@ -340,6 +341,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
createPrReconcileGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
@@ -1616,6 +1617,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
createPrReconcileGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
@@ -366,6 +367,7 @@ export async function runServe(
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -27,7 +27,14 @@ import type { TaskStore } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
|
||||
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool, PrNodeGithubOps } from "@fusion/engine";
|
||||
import type {
|
||||
CreateGroupPrFn,
|
||||
SyncGroupPrFn,
|
||||
WorktreePool,
|
||||
PrNodeGithubOps,
|
||||
PrReconcileGithubOps,
|
||||
PrReconcileFetchResult,
|
||||
} from "@fusion/engine";
|
||||
|
||||
/**
|
||||
* Minimal interface for GitHub operations needed by the PR merge workflow.
|
||||
@@ -47,6 +54,13 @@ interface GitHubOperations {
|
||||
getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo>;
|
||||
updatePr(params: { owner?: string; repo?: string; number: number; title?: string; body?: string }): Promise<PrInfo>;
|
||||
closePr(params: { number: number }): Promise<PrInfo>;
|
||||
/** ETag-conditional change probe (U2/U4); 304 ⇒ unchanged, rate-limit-free. */
|
||||
probePrChanged(
|
||||
owner: string | undefined,
|
||||
repo: string | undefined,
|
||||
number: number,
|
||||
etag?: string,
|
||||
): Promise<{ changed: boolean; etag?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,6 +401,67 @@ export function createPrNodeGithubOps(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the entity's `owner/repo` repo slug into its components, tolerating an
|
||||
* empty/single-segment value (returns undefined owner/repo so the client falls
|
||||
* back to its configured repo).
|
||||
*/
|
||||
function splitRepoSlug(repo: string): { owner: string | undefined; name: string | undefined } {
|
||||
const [owner, name] = repo.split("/");
|
||||
return { owner: owner || undefined, name: name || undefined };
|
||||
}
|
||||
|
||||
/** Map a GitHub `PrStatus` to the reconcile fetch result's coarse PR state. */
|
||||
function mapPrStatusToFetchState(status: PrInfo["status"]): "open" | "merged" | "closed" {
|
||||
if (status === "merged") return "merged";
|
||||
if (status === "closed") return "closed";
|
||||
// "open" and "draft" both reconcile as open.
|
||||
return "open";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `prReconcileGithubOps` engine callbacks (U4) backing the
|
||||
* node-agnostic {@link PrReconciler}. Closes over the dashboard `GitHubClient`
|
||||
* so the engine never imports it (FN-3049), exactly like
|
||||
* {@link createPrNodeGithubOps}. Wired at the same three CLI composition sites.
|
||||
*
|
||||
* - probe: ETag-conditional change probe (304 ⇒ unchanged ⇒ skip deep-fetch).
|
||||
* - fetchPrState: deep-fetch the GitHub-corroborated mirror. A 404 (PR not
|
||||
* found) maps to `{ exists: false }` so the reconcile clears fictional
|
||||
* unverified entities (R19).
|
||||
*/
|
||||
export function createPrReconcileGithubOps(
|
||||
github: Pick<GitHubOperations, "probePrChanged" | "getPrStatus">,
|
||||
): PrReconcileGithubOps {
|
||||
return {
|
||||
probe: (repo, prNumber, etag) => {
|
||||
const { owner, name } = splitRepoSlug(repo);
|
||||
return github.probePrChanged(owner, name, prNumber, etag);
|
||||
},
|
||||
fetchPrState: async (repo, prNumber): Promise<PrReconcileFetchResult> => {
|
||||
const { owner, name } = splitRepoSlug(repo);
|
||||
let info: PrInfo;
|
||||
try {
|
||||
info = await github.getPrStatus(owner ?? "", name ?? "", prNumber);
|
||||
} catch (err) {
|
||||
// A 404 / "not found" means there is no PR behind this entity.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (/not found|404/i.test(message)) return { exists: false };
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
prState: mapPrStatusToFetchState(info.status),
|
||||
prNumber: info.number,
|
||||
prUrl: info.url,
|
||||
mergeable: info.mergeable,
|
||||
checksRollup: info.checkRollup,
|
||||
reviewDecision: info.lastReviewDecision ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 });
|
||||
|
||||
Reference in New Issue
Block a user