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:
committed by
gsxdsm
parent
09491671ea
commit
60d3da7394
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user