feat(FN-4993): wire pr create flags router and AI metadata parity

Fusion-Task-Id: FN-4993
Fusion-Task-Lineage: 20ce6d5f-ef33-49e6-8519-648d769cc473
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 05:35:00 -07:00
committed by gsxdsm
parent 09491671ea
commit 60d3da7394
6 changed files with 239 additions and 41 deletions

View File

@@ -648,6 +648,31 @@ describe("bin command routing and fallbacks", () => {
expect(logSpy).toHaveBeenCalledWith("Try: fn research create | list | show | export | cancel | retry");
});
it("routes top-level pr create with draft/no-ai/reviewer flags", async () => {
await runBin(["pr", "create", "FN-001", "--draft", "--no-ai", "--reviewer", "alice", "--reviewer", "bob"]);
expect(commandMocks.runTaskPrCreate).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ draft: true, ai: false, reviewers: ["alice", "bob"] }),
undefined,
);
});
it("routes task pr-create alias with same flags", async () => {
await runBin(["task", "pr-create", "FN-001", "--draft"]);
expect(commandMocks.runTaskPrCreate).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ draft: true, ai: true }),
undefined,
);
});
it("errors on missing pr subcommand", async () => {
await expect(runBin(["pr"]))
.rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: pr ");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: fn pr create <task-id>"));
});
it("routes desktop flags to runDesktop", async () => {
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
expect(commandMocks.runDesktop).toHaveBeenCalledWith({

View File

@@ -288,12 +288,12 @@ Usage:
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]
Inspect, reclaim, or discard stranded task branches
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]
Alias of: fn pr create
fn task import <owner/repo> [opts] Import GitHub issues as tasks
PR:
fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai]
fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]
Create a GitHub PR for a task (default: AI-generated title/body)
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
Create and optionally wait for a cited-research run (search/fetch/synthesis)
@@ -459,6 +459,31 @@ function getFlagValueNumber(args: string[], flag: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
function parsePrCreateOptions(args: string[]) {
const title = getFlagValue(args, "--title");
const base = getFlagValue(args, "--base");
const body = getFlagValue(args, "--body");
const draft = args.includes("--draft");
const ai = !args.includes("--no-ai");
const reviewers: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--reviewer" && i + 1 < args.length) {
reviewers.push(args[i + 1]);
i += 1;
}
}
return {
title,
base,
body,
draft,
ai,
reviewers: reviewers.length > 0 ? reviewers : undefined,
};
}
/**
* Locate `@runfusion/fusion`'s own version by walking up from the running
* `bin.js`. Mirrors `packages/dashboard/src/cli-package-version.ts` but is
@@ -708,6 +733,26 @@ async function main() {
break;
}
case "pr": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const id = args[2];
if (!id) {
console.error("Usage: fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]");
process.exit(1);
}
await runTaskPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName);
break;
}
default:
console.error(`Unknown subcommand: pr ${subcommand || ""}`);
console.error("Usage: fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]");
process.exit(1);
}
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {
@@ -1190,31 +1235,11 @@ async function main() {
case "pr-create": {
const id = args[2];
if (!id) {
console.error("Usage: fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]");
console.error("Usage: fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]");
process.exit(1);
}
// Parse optional flags
let title: string | undefined;
let base: string | undefined;
let body: string | undefined;
const titleIdx = args.indexOf("--title");
if (titleIdx !== -1 && titleIdx + 1 < args.length) {
title = args[titleIdx + 1];
}
const baseIdx = args.indexOf("--base");
if (baseIdx !== -1 && baseIdx + 1 < args.length) {
base = args[baseIdx + 1];
}
const bodyIdx = args.indexOf("--body");
if (bodyIdx !== -1 && bodyIdx + 1 < args.length) {
body = args[bodyIdx + 1];
}
await runTaskPrCreate(id, { title, base, body }, projectName);
await runTaskPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName);
break;
}
case "import": {

View File

@@ -65,6 +65,7 @@ vi.mock("@fusion/dashboard", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
createPr: vi.fn(),
})),
generatePrMetadata: vi.fn(),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
}));
@@ -111,7 +112,7 @@ import {
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { GitHubClient } from "@fusion/dashboard";
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js";
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
@@ -2605,6 +2606,7 @@ describe("runTaskPrCreate", () => {
vi.mocked(GitHubClient).mockImplementation(() => ({
createPr: mockCreatePr,
} as unknown as GitHubClient));
vi.mocked(generatePrMetadata).mockResolvedValue({ title: "AI Generated Title", body: "AI Generated Body", templateUsed: false });
// Setup gh-cli mocks
vi.mocked(isGhAvailable).mockReturnValue(true);
@@ -2670,7 +2672,7 @@ describe("runTaskPrCreate", () => {
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo({ title: "My Task Title" }));
await runTaskPrCreate("FN-001", {});
await runTaskPrCreate("FN-001", { ai: false });
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
title: "My Task Title",
@@ -2683,7 +2685,7 @@ describe("runTaskPrCreate", () => {
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("FN-001", {});
await runTaskPrCreate("FN-001", { ai: false });
// Title should be first 50 chars of description, sentence-cased, with ellipsis if truncated
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
@@ -2889,4 +2891,47 @@ describe("runTaskPrCreate", () => {
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
describe("flag coverage", () => {
it.each([
{ draft: true, reviewers: ["alice", "bob"] },
{ draft: false, reviewers: undefined },
])("forwards draft/reviewers %#", async ({ draft, reviewers }) => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("FN-001", { draft, reviewers, ai: false });
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
draft,
reviewers,
}));
});
});
describe("AI metadata parity", () => {
it("uses generated metadata when title/body are not provided", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
vi.mocked(generatePrMetadata).mockResolvedValueOnce({ title: "AI Title", body: "AI Body", templateUsed: false });
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("FN-001", {});
expect(generatePrMetadata).toHaveBeenCalledTimes(1);
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({ title: "AI Title", body: "AI Body" }));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Using AI-generated title/body"));
});
it("skips AI metadata when --no-ai is set", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("FN-001", { ai: false });
expect(generatePrMetadata).not.toHaveBeenCalled();
});
});
});

View File

@@ -1439,6 +1439,11 @@ export interface PrCreateOptions {
title?: string;
base?: string;
body?: string;
draft?: boolean;
/** When true (default), call generatePrMetadata for title/body unless user provided both. */
ai?: boolean;
/** Repeatable --reviewer flag values. */
reviewers?: string[];
}
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
@@ -1501,18 +1506,38 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
// Build branch name using the established project convention
const branchName = `fusion/${id.toLowerCase()}`;
// Build PR title
let title: string;
if (options.title) {
title = options.title;
} else if (task.title) {
title = task.title;
} else {
// Generate from description (first 50 chars, sentence case)
const desc = task.description.trim();
title = desc.charAt(0).toUpperCase() + desc.slice(1, 50);
if (desc.length > 50) {
title += "…";
// Build deterministic fallback PR title
const fallbackTitle = options.title
? options.title
: task.title
? task.title
: (() => {
const desc = task.description.trim();
let derived = desc.charAt(0).toUpperCase() + desc.slice(1, 50);
if (desc.length > 50) {
derived += "…";
}
return derived;
})();
let resolvedTitle = fallbackTitle;
let resolvedBody = options.body;
const shouldUseAi = options.ai !== false && !(options.title && options.body);
if (shouldUseAi) {
try {
const repoRoot = await getProjectPath(projectName);
const settings = "getSettings" in store ? await store.getSettings() : {};
const generated = await dashboard.generatePrMetadata({ task, repoRoot, settings });
if (!options.title) {
resolvedTitle = generated.title;
}
if (!options.body) {
resolvedBody = generated.body;
}
console.log(" → Using AI-generated title/body (use --no-ai to skip)");
} catch (err) {
process.stderr.write(`AI metadata generation failed; using fallback PR metadata. ${getGhErrorMessage(err)}\n`);
}
}
@@ -1523,10 +1548,12 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
const prInfo = await client.createPr({
owner,
repo,
title,
body: options.body,
title: resolvedTitle,
body: resolvedBody,
head: branchName,
base: options.base,
draft: options.draft,
reviewers: options.reviewers,
});
// Store PR info