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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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()\`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user