feat(pr): pr-create/pr-respond/pr-merge node kinds + handlers (U3)

Adds the three first-class PR workflow node kinds and their handlers via
createPrNodeHandlers(deps), registered in createDefaultNodeHandlers
(fail-closed pr-nodes-unwired when absent). GitHub ops are injected as
callbacks (PrNodeGithubOps) at all three CLI sites (daemon/serve/dashboard)
so the engine never imports the dashboard client (FN-3049). pr-create
routes open/failed as outcomes; pr-merge passes expectedHeadOid and never
writes 'merged' (reconcile corroborates); pr-respond delegates to an
injected respond callback (U5 fills the body). 10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 19:55:31 -07:00
parent b871046ad2
commit 4e16145aab
17 changed files with 750 additions and 13 deletions

View File

@@ -44,6 +44,7 @@ import {
processPullRequestMergeTask,
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
@@ -338,6 +339,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
});

View File

@@ -54,6 +54,7 @@ import {
processPullRequestMergeTask,
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -1614,6 +1615,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
getTaskMergeBlocker,
});

View File

@@ -44,6 +44,7 @@ import {
processPullRequestMergeTask,
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
@@ -364,6 +365,7 @@ export async function runServe(
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
});

View File

@@ -27,7 +27,7 @@ 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 } from "@fusion/engine";
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool, PrNodeGithubOps } from "@fusion/engine";
/**
* Minimal interface for GitHub operations needed by the PR merge workflow.
@@ -43,7 +43,7 @@ interface GitHubOperations {
mergeReady: boolean;
blockingReasons: string[];
}>;
mergePr(params: { number: number; method?: "merge" | "squash" | "rebase" }): Promise<PrInfo>;
mergePr(params: { number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string }): Promise<PrInfo>;
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>;
@@ -303,6 +303,90 @@ export function syncGroupPrCallback(
};
}
/** Best-effort resolve the head commit OID for a branch (so `pr-merge` can pass
* `expectedHeadOid`). Returns undefined on any failure — the merge then runs
* 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 oid = stdout.trim();
return oid.length > 0 ? oid : undefined;
} catch {
return undefined;
}
}
/** Structural detection of the dashboard `PrStaleHeadError` without importing the
* class (task-lifecycle.ts deliberately has no @fusion/dashboard dependency). */
function isStaleHeadError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "stale-head"
);
}
/**
* Build the `prNodeGithubOps` engine callbacks (U3) backing the `pr-create` /
* `pr-respond` / `pr-merge` workflow nodes. Closes over a GitHub client so the
* engine never imports the dashboard `GitHubClient` (FN-3049). Mirrors
* `createGroupPrCallback` / `syncGroupPrCallback`.
*
* - resolvePrSource: derives the single-task PR source identity (repo from the
* per-process repo, head branch from the task branch-naming convention).
* - createPr: pushes the task branch to origin, opens the PR, resolves the head OID.
* - mergePr: merges with `expectedHeadOid`; a `PrStaleHeadError` (detected
* structurally) maps to `{ status: "stale-head" }` so the node routes the race.
* - respond: omitted in U3 (U5 wires the real review-response run); the node then
* falls back to its inert `disagreed-only` default.
*/
export function createPrNodeGithubOps(
github: Pick<GitHubOperations, "createPr" | "mergePr">,
): PrNodeGithubOps {
return {
resolvePrSource: (task) => {
const repo = getCurrentRepo();
const repoSlug = repo ? `${repo.owner}/${repo.repo}` : "";
return {
sourceType: "task",
sourceId: task.id,
repo: repoSlug,
headBranch: getTaskBranchName(task.id),
};
},
createPr: async ({ task, entity }) => {
const cwd = process.cwd();
const headBranch = entity.headBranch || getTaskBranchName(task.id);
await pushTaskBranchToOrigin(cwd, headBranch);
const created = await github.createPr({
title: task.title ?? `Task ${task.id}`,
body: task.description ?? "",
head: headBranch,
base: entity.baseBranch,
});
const headOid = await resolveBranchHeadOid(cwd, headBranch);
return { prNumber: created.number, prUrl: created.url, headOid };
},
mergePr: async ({ entity }) => {
if (entity.prNumber == null) {
throw new Error(`pr-merge: entity ${entity.id} has no persisted prNumber`);
}
try {
await github.mergePr({
number: entity.prNumber,
method: "squash",
expectedHeadOid: entity.headOid,
});
return { status: "merged-requested" };
} catch (err) {
if (isStaleHeadError(err)) return { status: "stale-head" };
throw err;
}
},
};
}
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 });