feat(FN-2915): merge fusion/fn-2915

- feat(FN-2915): include github issue refs in commit workflows
- refactor(dashboard): simplify setup wizard manual step
- fix(engine): prevent phantom merges when verification fix runs without a commit

Fusion-Task-Id: FN-2915
This commit is contained in:
Fusion
2026-04-28 21:13:30 -07:00
committed by gsxdsm
parent 5ca067346f
commit a8dbdbc017
7 changed files with 139 additions and 13 deletions

View File

@@ -116,20 +116,20 @@ describe("CLI bundle output", () => {
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("pi-claude-cli source imports cross-spawn", () => {
it("pi-claude-cli source imports spawn from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
expect(processManagerSource).toMatch(/import\s+spawn\s+from\s*["']cross-spawn["']/);
expect(processManagerSource).toMatch(/import\s+\{\s*spawn[\s\S]*\}\s+from\s*["']node:child_process["']/);
});
it("pi-claude-cli package.json includes cross-spawn dependency", () => {
it("pi-claude-cli package.json does not require cross-spawn dependency", () => {
const packageJson = JSON.parse(
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
) as {
dependencies?: Record<string, string>;
};
expect(packageJson.dependencies?.["cross-spawn"]).toBeDefined();
expect(packageJson.dependencies?.["cross-spawn"]).toBeUndefined();
});
it("runtime native assets are staged after build:exe", () => {

View File

@@ -80,7 +80,7 @@ describe("SetupWizardModal", () => {
);
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
expect(screen.getByText(/Let's set up your first project/)).toBeDefined();
expect(screen.getByText("Project Name")).toBeDefined();
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
expect(screen.getByText("Advanced settings")).toBeDefined();
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
@@ -192,6 +192,7 @@ describe("SetupWizardModal", () => {
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
expect(screen.getByLabelText("Repository URL")).toBeDefined();
@@ -217,6 +218,7 @@ describe("SetupWizardModal", () => {
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
fireEvent.change(screen.getByLabelText("Repository URL"), {
target: { value: "https://github.com/runfusion/fusion.git" },
@@ -249,6 +251,7 @@ describe("SetupWizardModal", () => {
const registerBtn = screen.getByText("Register Project").closest("button")!;
expect(registerBtn.disabled).toBe(true);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), {
target: { value: "/tmp/repo" },
@@ -272,6 +275,7 @@ describe("SetupWizardModal", () => {
/>
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.click(screen.getByLabelText("Clone Git Repository"));
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
@@ -308,8 +312,9 @@ describe("SetupWizardModal", () => {
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(screen.getByText("Path does not exist")).toBeDefined();
expect(mockRegisterProject).toHaveBeenCalled();
});
expect(await screen.findByText("Path does not exist")).toBeDefined();
});
it("shows completion state after successful registration", async () => {
@@ -342,9 +347,10 @@ describe("SetupWizardModal", () => {
fireEvent.click(screen.getByText("Register Project"));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeDefined();
expect(screen.getByText("Get Started")).toBeDefined();
expect(mockRegisterProject).toHaveBeenCalled();
});
expect(await screen.findByText("All Set!")).toBeDefined();
expect(await screen.findByText("Get Started")).toBeDefined();
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});

View File

@@ -2417,6 +2417,28 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("do not defer them to a separate task");
});
it("includes source issue reference in commit instruction when task has github sourceIssue", () => {
const task = createMockTaskDetail({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "2915",
issueNumber: 2915,
},
} as any);
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — description" -m "Ref: runfusion/fusion#2915"');
});
it("omits source issue reference from commit instruction when sourceIssue is missing", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — description"');
expect(result).not.toContain(' -m "Ref:');
});
it("omits Project Commands section when neither command is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {} as any);

View File

@@ -5194,6 +5194,34 @@ describe("aiMergeTask — fresh session and compaction recovery", () => {
// ── Merge Prompt Truncation Tests ─────────────────────────────────────
describe("buildSourceIssueRef", () => {
it("returns owner/repo#number for GitHub source issues", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "123",
issueNumber: 123,
})).toBe("runfusion/fusion#123");
});
it("returns empty string for non-GitHub providers", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "gitlab",
repository: "group/project",
externalIssueId: "123",
issueNumber: 123,
})).toBe("");
});
it("returns empty string for nullish source issue", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef(undefined)).toBe("");
expect(buildSourceIssueRef(null)).toBe("");
});
});
describe("buildMergePrompt — truncation behavior", () => {
it("truncates commit log when exceeding MERGE_COMMIT_LOG_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("../merger.js");
@@ -5331,6 +5359,37 @@ describe("buildMergePrompt — truncation behavior", () => {
expect(prompt).not.toContain("Be sure to include");
});
it("includes source issue reference guidance when provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
sourceIssueRef: "runfusion/fusion#2915",
});
expect(prompt).toContain("Include this in the commit message body:");
expect(prompt).toContain("Ref: runfusion/fusion#2915");
});
it("omits source issue reference guidance when not provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
});
expect(prompt).not.toContain("Include this in the commit message body:");
expect(prompt).not.toContain("Ref: runfusion/fusion#2915");
});
});
// ── Context Limit Recovery Tests ─────────────────────────────────────

View File

@@ -333,6 +333,7 @@ If the task's PROMPT.md includes a "Documentation Requirements" section listing
## Git discipline
- Commit after completing each step (not after every file change)
- Use conventional commit messages prefixed with the task ID
- When the task has a GitHub issue reference, include \`Ref: owner/repo#N\` in the commit body
- Do NOT commit broken or half-implemented code
## Worktree Boundaries
@@ -5336,6 +5337,10 @@ export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, setting
? ` --author="${settings?.commitAuthorName || "Fusion"} <${settings?.commitAuthorEmail || "noreply@runfusion.ai"}>"`
: "";
const sourceIssueRef = task.sourceIssue?.provider === "github" && task.sourceIssue.repository && task.sourceIssue.issueNumber
? `${task.sourceIssue.repository}#${task.sourceIssue.issueNumber}`
: "";
// Build step progress for resume
const hasProgress = task.steps.length > 0 && task.steps.some((s) => s.status !== "pending");
let progressSection = "";
@@ -5455,7 +5460,7 @@ ${hasProgress
Use \`fn_task_update\` to report progress on every step transition.
Use \`fn_task_log\` for important actions and decisions.
Use \`fn_task_create\` for truly separate follow-up work, not for fixes required to get tests, build, or typecheck back to green.
Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N — description"${authorArg}\`
Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N — description"${sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : ""}${authorArg}\`
When all steps are complete: call \`fn_task_done()\`
If a build command is configured, run that exact command in this worktree before calling \`fn_task_done()\`.

View File

@@ -146,6 +146,7 @@ import {
type Settings,
type AgentPromptsConfig,
type CanonicalMergeConflictStrategy,
type TaskSourceIssue,
} from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -1698,6 +1699,12 @@ function getCommitAuthorArg(settings: {
return ` --author="${name} <${email}>"`;
}
export function buildSourceIssueRef(sourceIssue?: TaskSourceIssue | null): string {
if (!sourceIssue || sourceIssue.provider !== "github") return "";
if (!sourceIssue.repository || !sourceIssue.issueNumber) return "";
return `${sourceIssue.repository}#${sourceIssue.issueNumber}`;
}
/**
* Build the merge system prompt. When `includeTaskId` is true (default),
* the commit format uses `<type>(<scope>): <summary>` where scope is the
@@ -1714,6 +1721,7 @@ Message format:
- **Scope:** the task ID (e.g., KB-001)
- **Summary:** one line describing what the squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
- **GitHub reference:** when the prompt includes a source issue reference, add \`Ref: owner/repo#N\` to the commit body
${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
Example:
@@ -1732,6 +1740,7 @@ Message format:
- **Type:** feat, fix, refactor, docs, test, chore
- **Summary:** one line describing what the squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
- **GitHub reference:** when the prompt includes a source issue reference, add \`Ref: owner/repo#N\` to the commit body
${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
Do NOT include a scope in the commit message type.
@@ -2275,6 +2284,7 @@ export async function aiMergeTask(
}
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
const worktreePath = task.worktree;
const result: MergeResult = {
task,
@@ -2749,6 +2759,7 @@ export async function aiMergeTask(
commitLog,
diffStat,
includeTaskId,
sourceIssueRef,
smartConflictResolution,
mergeConflictStrategy,
attemptNum,
@@ -3481,6 +3492,7 @@ interface MergeAttemptParams {
commitLog: string;
diffStat: string;
includeTaskId: boolean;
sourceIssueRef?: string;
smartConflictResolution: boolean;
mergeConflictStrategy: CanonicalMergeConflictStrategy;
attemptNum: 1 | 2 | 3;
@@ -3525,6 +3537,7 @@ async function executeMergeAttempt(
commitLog,
diffStat,
includeTaskId,
sourceIssueRef,
smartConflictResolution,
attemptNum,
options,
@@ -3773,6 +3786,7 @@ async function executeMergeAttempt(
options,
testCommand,
buildCommand,
sourceIssueRef,
});
// Handle build failure
@@ -3871,7 +3885,7 @@ async function attemptWithSideStrategy(
side: "theirs" | "ours" = "theirs",
aiTracker?: AiInvocationTracker,
): Promise<boolean> {
const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
const { rootDir, branch, commitLog, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
@@ -3925,8 +3939,9 @@ async function attemptWithSideStrategy(
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskIdTrailerArg(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
await execAsync(
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${trailerArg}${authorArg}`,
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir },
);
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
@@ -3966,6 +3981,7 @@ interface AiAgentParams {
includeTaskId: boolean;
hasConflicts: boolean;
simplifiedContext: boolean;
sourceIssueRef?: string;
options: MergerOptions;
testCommand?: string;
buildCommand?: string;
@@ -4003,6 +4019,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
includeTaskId,
hasConflicts,
simplifiedContext,
sourceIssueRef,
options,
testCommand,
buildCommand,
@@ -4133,6 +4150,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
testCommand,
buildCommand,
authorArg,
sourceIssueRef,
});
// Attempt prompting with fresh session (first attempt).
@@ -4171,6 +4189,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
testCommand,
buildCommand,
authorArg,
sourceIssueRef,
});
try {
@@ -4224,8 +4243,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskIdTrailerArg(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
await execAsync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${trailerArg}${authorArg}`,
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir },
);
} else {
@@ -4264,13 +4284,14 @@ interface MergePromptParams {
diffStat: string;
hasConflicts: boolean;
simplifiedContext?: boolean;
sourceIssueRef?: string;
testCommand?: string;
buildCommand?: string;
authorArg?: string;
}
export function buildMergePrompt(params: MergePromptParams): string {
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, testCommand, buildCommand, authorArg } = params;
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, sourceIssueRef, testCommand, buildCommand, authorArg } = params;
// Apply truncation to prevent context overflow for large branches/diffs
const truncatedCommitLog = truncateWithEllipsis(commitLog, MERGE_COMMIT_LOG_MAX_CHARS);
@@ -4312,6 +4333,14 @@ export function buildMergePrompt(params: MergePromptParams): string {
);
}
if (sourceIssueRef) {
parts.push(
"",
"Include this in the commit message body:",
`- Ref: ${sourceIssueRef}`,
);
}
// Add test command section if provided
if (testCommand) {
parts.push(