Address PR review feedback (#1461)

- pr-nodes: forward post-increment entity to deps.respond (responseRounds off-by-one); branch-group entity fallback now keys on task.branchContext.groupId, not task.id
- core: hoist shared autoMergeGateReason into @fusion/core pr-entity.ts; CLI + dashboard import the single definition (R13)
- cli: fn pr create also writes the unified PR entity (ensure → open), keeping legacy prInfo; resolveBranchHeadOid uses argv-safe execFileAsync (injection fix)
- changeset: fn-pr-commands bumped minor → major (breaking CLI removal per AGENTS.md)
- tests: bin.test.ts daemon fixture off reserved port 4040; lazy-loaded-views title 19 → 20 views

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-06 00:09:53 -07:00
parent 1922b5efc2
commit 841ff9d838
12 changed files with 196 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
---
"@runfusion/fusion": minor
"@runfusion/fusion": major
---
Add the unified `fn pr` command namespace for CLI parity with the dashboard's

View File

@@ -805,10 +805,10 @@ describe("bin command routing and fallbacks", () => {
});
it("routes daemon command with all flags", async () => {
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);
await runBin(["daemon", "--port", "5055", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);
expect(commandMocks.runDaemon).toHaveBeenCalledWith({
port: 4040,
port: 5055,
paused: true,
interactive: false,
host: "127.0.0.1",

View File

@@ -16,15 +16,31 @@ vi.mock("@fusion/engine", () => ({
releaseHeldTaskByEvent: (...args: unknown[]) => releaseHeldTaskByEvent(...args),
}));
// @fusion/dashboard is only touched by runPrCreate (not exercised here); stub it
// so importing the module never pulls the heavy dashboard graph.
// @fusion/dashboard is touched by runPrCreate; stub it so importing the module
// never pulls the heavy dashboard graph. `createPr` is controllable so the create
// path can be asserted to write the unified PR entity.
const createPr = vi.fn();
vi.mock("@fusion/dashboard", () => ({
GitHubClient: class {},
GitHubClient: class {
createPr(...args: unknown[]) {
return createPr(...args);
}
},
generatePrMetadata: vi.fn(),
}));
// gh-cli helpers used by runPrCreate (repo resolution + auth gating).
vi.mock("@fusion/core/gh-cli", () => ({
classifyGhError: vi.fn(() => ({ message: "err" })),
getGhErrorMessage: vi.fn(() => "err"),
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
isGhAuthenticated: vi.fn(() => true),
isGhAvailable: vi.fn(() => true),
}));
const { resolveProject } = await import("../project-context.js");
const {
runPrCreate,
runPrList,
runPrShow,
runPrApprove,
@@ -86,6 +102,54 @@ describe("fn pr commands", () => {
vi.restoreAllMocks();
});
// ── create → legacy prInfo + unified PR entity ──────────────────────────────
it("runPrCreate writes the unified PR entity (not just legacy prInfo)", async () => {
const prInfo = {
url: "https://github.com/owner/repo/pull/7",
number: 7,
status: "open",
title: "T",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
delete process.env.GITHUB_REPOSITORY;
createPr.mockResolvedValue(prInfo);
const getTask = vi.fn().mockResolvedValue({
id: "FN-001",
title: "Task one",
description: "do a thing",
column: "in-review",
prInfo: undefined,
});
const updatePrInfo = vi.fn();
const ensurePrEntityForSource = vi.fn().mockReturnValue(makeEntity({ id: "PR-NEW", state: "creating" }));
const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ id: "PR-NEW" }));
const logEntry = vi.fn();
mockStore({ getTask, updatePrInfo, ensurePrEntityForSource, updatePrEntity, logEntry });
await runPrCreate("FN-001", { ai: false });
// Legacy field is still written (additive, migration-safe).
expect(updatePrInfo).toHaveBeenCalledWith("FN-001", prInfo);
// Unified entity is created via the same store path the pr-create node uses.
expect(ensurePrEntityForSource).toHaveBeenCalledWith({
sourceType: "task",
sourceId: "FN-001",
repo: "owner/repo",
headBranch: "fusion/fn-001",
baseBranch: "main",
state: "creating",
});
// …then flipped to open with the persisted PR number/url.
expect(updatePrEntity).toHaveBeenCalledWith("PR-NEW", {
state: "open",
prNumber: 7,
prUrl: "https://github.com/owner/repo/pull/7",
});
});
// ── read commands ──────────────────────────────────────────────────────────
it("runPrList reads active entities from the store", async () => {

View File

@@ -2,7 +2,7 @@ import {
TaskStore,
isPrEntityActive,
isPrEntityActionable,
isPrEntityAutoMergeReady,
autoMergeGateReason,
type PrEntity,
type PrThreadState,
} from "@fusion/core";
@@ -199,8 +199,27 @@ export async function runPrCreate(id: string, options: PrCreateOptions = {}, pro
reviewers: options.reviewers,
});
// Store PR info
// Store PR info (legacy field, still read by some surfaces during migration).
await store.updatePrInfo(task.id, prInfo);
// Also write the unified PR entity via the SAME store path the pr-create
// workflow node uses (mirrors pr-nodes.ts: ensure → flip to open with the
// persisted PR number/url). Without this the PR would be invisible to
// `fn pr list/show`, the reconciler, and the workflow nodes (R13 parity).
const entity = store.ensurePrEntityForSource({
sourceType: "task",
sourceId: task.id,
repo: `${owner}/${repo}`,
headBranch: branchName,
baseBranch: prInfo.baseBranch,
state: "creating",
});
store.updatePrEntity(entity.id, {
state: "open",
prNumber: prInfo.number,
prUrl: prInfo.url,
});
await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`);
console.log();
@@ -235,16 +254,6 @@ function requireEntity(store: TaskStore, id: string): PrEntity {
return entity;
}
function autoMergeReason(entity: PrEntity): string {
if (!entity.autoMerge) return "Auto-merge off";
if (entity.mergeable === "conflicting") return "Blocked: conflict";
if (entity.reviewDecision !== "APPROVED") return "Waiting for approval";
if (entity.checksRollup !== "success") return "Waiting for checks";
if (entity.mergeable !== "clean") return "Waiting for checks";
if (isPrEntityAutoMergeReady(entity)) return "Ready to merge";
return "Waiting for checks";
}
export async function runPrList(projectName?: string) {
const { store } = await getPrContext(projectName);
const entities = store.listActivePrEntities();
@@ -284,7 +293,7 @@ export async function runPrShow(id: string, projectName?: string) {
console.log(` Mergeable: ${entity.mergeable ?? "unknown"}`);
console.log(` Review: ${entity.reviewDecision ?? "none"}`);
console.log(` Checks: ${entity.checksRollup ?? "none"}`);
console.log(` Auto-merge: ${entity.autoMerge ? "on" : "off"} (${autoMergeReason(entity)})`);
console.log(` Auto-merge: ${entity.autoMerge ? "on" : "off"} (${autoMergeGateReason(entity)})`);
console.log(` Active: ${isPrEntityActive(entity) ? "yes" : "no"}; actionable: ${isPrEntityActionable(entity) ? "yes" : "no"}`);
console.log(` Rounds: ${entity.responseRounds}; threads: ${threads.length} (${pending} pending, ${disagreed} disagreed)`);
console.log();
@@ -363,5 +372,5 @@ export async function runPrAutomerge(id: string, enabled: boolean | undefined, p
const next = typeof enabled === "boolean" ? enabled : !entity.autoMerge;
const updated = store.updatePrEntity(id, { autoMerge: next });
console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeReason(updated)})\n`);
console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`);
}

View File

@@ -340,7 +340,7 @@ export function syncGroupPrCallback(
* without the stale-head guard, which the reconcile still corroborates. */
async function resolveBranchHeadOid(cwd: string, branch: string): Promise<string | undefined> {
try {
const { stdout } = await execAsync(`git rev-parse "${branch}"`, { cwd, timeout: 30_000 });
const { stdout } = await execFileAsync("git", ["rev-parse", branch], { cwd, timeout: 30_000 });
const oid = stdout.trim();
return oid.length > 0 ? oid : undefined;
} catch {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
autoMergeGateReason,
isPrBacked,
isPrEntityActionable,
isPrEntityActive,
@@ -71,4 +72,19 @@ describe("PR entity predicates", () => {
expect(isPrEntityAutoMergeReady({ ...base, mergeable: "unknown" })).toBe(false);
expect(isPrEntityAutoMergeReady({ ...base, unverified: true })).toBe(false);
});
it("autoMergeGateReason is the single R13-shared status string for both surfaces", () => {
const ready = entity({
autoMerge: true,
reviewDecision: "APPROVED",
checksRollup: "success",
mergeable: "clean",
});
expect(autoMergeGateReason(ready)).toBe("Ready to merge");
expect(autoMergeGateReason({ ...ready, autoMerge: false })).toBe("Auto-merge off");
expect(autoMergeGateReason({ ...ready, mergeable: "conflicting" })).toBe("Blocked: conflict");
expect(autoMergeGateReason({ ...ready, reviewDecision: "CHANGES_REQUESTED" })).toBe("Waiting for approval");
expect(autoMergeGateReason({ ...ready, checksRollup: "pending" })).toBe("Waiting for checks");
expect(autoMergeGateReason({ ...ready, mergeable: "unknown" })).toBe("Waiting for checks");
});
});

View File

@@ -611,6 +611,7 @@ export {
isPrBacked,
isPrEntityActionable,
isPrEntityAutoMergeReady,
autoMergeGateReason,
} from "./pr-entity.js";
export {
findVitestProcessIds,

View File

@@ -63,3 +63,21 @@ export function isPrEntityAutoMergeReady(
if (entity.mergeable !== "clean") return false;
return true;
}
/**
* The live auto-merge gate reason shown next to the toggle (R11). Mirrors the
* auto-merge-ready predicate ordering so every surface (the dashboard route and
* the `fn pr` CLI) reports the same status and never disagrees with what the gate
* will actually do. Shared in @fusion/core (R13) so the two surfaces cannot drift.
*/
export function autoMergeGateReason(
entity: Pick<PrEntity, "state" | "unverified" | "autoMerge" | "reviewDecision" | "checksRollup" | "mergeable">,
): string {
if (!entity.autoMerge) return "Auto-merge off";
if (entity.mergeable === "conflicting") return "Blocked: conflict";
if (entity.reviewDecision !== "APPROVED") return "Waiting for approval";
if (entity.checksRollup !== "success") return "Waiting for checks";
if (entity.mergeable !== "clean") return "Waiting for checks";
if (isPrEntityAutoMergeReady(entity)) return "Ready to merge";
return "Waiting for checks";
}

View File

@@ -76,7 +76,7 @@ function extractAppLazyViews(appSource: string): Set<string> {
}
describe("AGENTS lazy-loaded views inventory", () => {
it("documents the App-level lazy views accurately and keeps the curated 19-view list in sync", () => {
it("documents the App-level lazy views accurately and keeps the curated 20-view list in sync", () => {
const agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8");
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8");

View File

@@ -4,6 +4,7 @@ import {
isPrEntityActive,
isPrEntityActionable,
isPrEntityAutoMergeReady,
autoMergeGateReason,
} from "@fusion/core";
import { badRequest, notFound, ApiError } from "../api-error.js";
@@ -41,20 +42,10 @@ function parseProjectId(req: Request): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/**
* The live auto-merge gate reason shown next to the toggle (R11). Mirrors the
* engine's auto-merge-ready predicate ordering so the UI never disagrees with
* what the gate will actually do.
*/
export function autoMergeGateReason(entity: PrEntity): string {
if (!entity.autoMerge) return "Auto-merge off";
if (entity.mergeable === "conflicting") return "Blocked: conflict";
if (entity.reviewDecision !== "APPROVED") return "Waiting for approval";
if (entity.checksRollup !== "success") return "Waiting for checks";
if (entity.mergeable !== "clean") return "Waiting for checks";
if (isPrEntityAutoMergeReady(entity)) return "Ready to merge";
return "Waiting for checks";
}
// `autoMergeGateReason` is the single R13-shared definition in @fusion/core
// (consumed here and by the `fn pr` CLI); re-exported so existing dashboard
// importers keep working.
export { autoMergeGateReason };
/** Whether the entity is in a hard conflict (Merge must be disabled, R11). */
export function isPrConflicting(entity: PrEntity): boolean {

View File

@@ -185,14 +185,46 @@ describe("PR node handlers (U3)", () => {
expect(store.getPrEntity(created.id)?.responseRounds).toBe(1);
});
it("pr-respond delegates to the injected respond callback", async () => {
store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
const respond = vi.fn(async () => ({ value: "fixed" as const, contextPatch: { k: "v" } }));
it("pr-respond delegates to the injected respond callback with the POST-increment entity", async () => {
const created = store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
store.updatePrEntity(created.id, { responseRounds: 3 });
let forwardedRounds: number | undefined;
const respond: PrNodeDeps["respond"] = async (input) => {
forwardedRounds = input.entity.responseRounds;
return { value: "fixed" as const, contextPatch: { k: "v" } };
};
const handlers = createPrNodeHandlers(deps({ respond }));
const result = await handlers["pr-respond"]({ id: "r", kind: "pr-respond" } as WorkflowIrNode, ctx());
expect(result).toEqual({ outcome: "success", value: "fixed", contextPatch: { k: "v" } });
expect(respond).toHaveBeenCalledTimes(1);
// The handler must forward the entity returned by updatePrEntity (post-increment),
// not the stale pre-increment copy — otherwise the R8 cap check fires one round late.
expect(forwardedRounds).toBe(4);
});
it("pr-merge / pr-respond resolve a branch-group entity via branchContext.groupId, not task id", async () => {
// Branch-group PR entities are keyed by the GROUP id (sourceId = branch_groups.id).
// A shared-mode task carries that id on branchContext.groupId, NOT task.id.
const groupId = "BG-1";
store.ensurePrEntityForSource({
sourceType: "branch-group",
sourceId: groupId,
repo: "owner/repo",
headBranch: "fusion/bg-1",
state: "open",
prNumber: 11,
});
const groupCtx = {
task: { id: "T-shared", branchContext: { groupId } } as unknown as TaskDetail,
settings: undefined,
context: {},
} as WorkflowNodeExecutionContext;
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
const handlers = createPrNodeHandlers(deps({ mergePr }));
const merge = await handlers["pr-merge"]({ id: "m", kind: "pr-merge" } as WorkflowIrNode, groupCtx);
expect(merge).toEqual({ outcome: "success", value: "merged-requested" });
expect(mergePr).toHaveBeenCalledTimes(1);
});
it("unwired pr-* deps fail closed (value:pr-nodes-unwired)", async () => {

View File

@@ -48,6 +48,22 @@ export interface PrNodeStore extends PrResponseRunStore {
updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity;
}
/**
* Resolve the single live PR entity backing a task: prefer the task-keyed entity,
* then fall back to the branch-group entity. Branch-group PR entities are keyed by
* the branch GROUP id (sourceId = branch_groups.id, per migration 113), which the
* task carries on `branchContext.groupId` — NOT the task id. Falling back on
* `task.id` can never match a branch-group entity, so a shared-mode task would
* spuriously resolve to no-entity.
*/
function resolveActivePrEntity(store: PrNodeStore, task: TaskDetail): PrEntity | null {
const taskEntity = store.getActivePrEntityBySource("task", task.id);
if (taskEntity) return taskEntity;
const groupId = task.branchContext?.groupId;
if (!groupId) return null;
return store.getActivePrEntityBySource("branch-group", groupId);
}
/** Identity of the PR an entity is created for, resolved from the task + node. */
export interface PrSourceDescriptor extends PrEntityCreateInput {}
@@ -339,8 +355,7 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record<
// merge request emits value:"merged-requested".
const prMerge: WorkflowNodeHandler = async (node, ctx) => {
const store = deps.getStore();
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
const entity = resolveActivePrEntity(store, ctx.task);
if (!entity) {
audit("pr-merge-no-entity", `pr-merge node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
@@ -386,8 +401,7 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record<
// responseRounds (the R8 iteration-cap counter, survives restart).
const prRespond: WorkflowNodeHandler = async (node, ctx) => {
const store = deps.getStore();
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
const entity = resolveActivePrEntity(store, ctx.task);
if (!entity) {
audit("pr-respond-no-entity", `pr-respond node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
@@ -400,8 +414,11 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record<
return { outcome: "success", value: "not-actionable" };
}
// Bump the rework-cycle counter (R8 cap backing; persisted).
store.updatePrEntity(entity.id, { responseRounds: entity.responseRounds + 1 });
// Bump the rework-cycle counter (R8 cap backing; persisted). Forward the
// POST-update entity so runPrResponseRun's cap check (`responseRounds > cap`)
// sees this round's count — passing the stale pre-increment entity fires the
// cap one round too late.
const updatedEntity = store.updatePrEntity(entity.id, { responseRounds: entity.responseRounds + 1 });
if (!deps.respond) {
// U3 default: inert but routable. U5 wires the real review-response run.
@@ -410,7 +427,7 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record<
let result: PrRespondCallResult;
try {
result = await deps.respond({ task: ctx.task, node, entity, context: ctx.context });
result = await deps.respond({ task: ctx.task, node, entity: updatedEntity, context: ctx.context });
} catch (err) {
const reason = classifyError(err);
audit("pr-respond-error", `pr-respond node '${node.id}' response run failed: ${reason}`);
@@ -456,8 +473,7 @@ export function createAutoMergeGateHandler(deps: Pick<PrNodeDeps, "getStore" | "
};
return async (node, ctx) => {
const store = deps.getStore();
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
const entity = resolveActivePrEntity(store, ctx.task);
if (!entity) {
// No live entity → cannot auto-merge; park for manual handling (never block).