FN-6960: bound PR metadata generation

Keep Create PR metadata generation responsive and ensure fallback PR content remains editable.

- Race metadata collection, prompt execution, and session creation against abort/timeout signals.
- Return fallback PR metadata from the API route when generation exceeds the route budget.
- Seed the Create PR dialog with editable fallback body content after metadata failures and require a non-empty body before submit.
- Validate non-empty PR bodies in both gh CLI and API-backed PR creation paths.
- Add regression coverage for bounded metadata generation, fallback responses, and PR body validation.

Files changed:
 .changeset/fn-6960-pr-metadata-bounded.md          |  7 ++
 .../dashboard/app/components/PrCreateModal.tsx     | 34 ++++++++-
 .../components/__tests__/PrCreateModal.test.tsx    | 41 +++++++++++
 .../src/__tests__/github-create-pr.test.ts         |  6 +-
 .../src/__tests__/github-forced-mode.test.ts       |  2 +-
 packages/dashboard/src/__tests__/github.test.ts    | 21 ++----
 .../src/__tests__/pr-metadata-generator.test.ts    | 55 ++++++++++----
 .../src/__tests__/pr-routes.contract.test.ts       | 10 +++
 .../register-git-github.pr-errors.test.ts          |  2 +-
 ...it-github.pr-options-preflight-metadata.test.ts | 25 +++++++
 .../dashboard/src/__tests__/routes-auth.test.ts    | 12 +--
 .../dashboard/src/__tests__/routes-github.test.ts  |  2 +-
 packages/dashboard/src/github.ts                   | 20 ++++-
 packages/dashboard/src/pr-metadata-generator.ts    | 86 ++++++++++++++--------
 .../dashboard/src/routes/register-git-github.ts    | 67 +++++++++++++----
 15 files changed, 301 insertions(+), 89 deletions(-)

Fusion-Task-Id: FN-6960
Fusion-Task-Lineage: 1ae72062-7180-4de3-999b-98d131f00792
This commit is contained in:
gsxdsm
2026-06-24 21:27:43 -07:00
parent d7f3c7093e
commit d3593065e4
15 changed files with 301 additions and 89 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent Create PR metadata generation from hanging and provide editable fallback content.
category: fix
dev: Bounds PR metadata generation and validates non-empty PR bodies before GitHub PR creation.

View File

@@ -39,6 +39,30 @@ type PreflightCheck = {
warning?: boolean; warning?: boolean;
}; };
/*
FNXC:PrCreateModal 2026-06-23-00:00:
The Create PR modal must stay manually usable after metadata generation fails, but non-interactive GitHub PR creation cannot accept a title-only payload. Seed an editable body fallback with the required sections so users can complete or revise the PR instead of submitting an empty body.
*/
function buildManualPrBodyFallback(taskId: string): string {
return [
"## Summary",
"",
"Summary unavailable. Add context before creating this PR.",
"",
"## Changes",
"",
"- Details unavailable.",
"",
"## Testing",
"",
"- Not provided.",
"",
"## Linked Task",
"",
`Closes ${taskId}`,
].join("\n");
}
function OptionChips<T extends { login?: string; name?: string; color?: string }>( function OptionChips<T extends { login?: string; name?: string; color?: string }>(
{ {
label, label,
@@ -208,6 +232,8 @@ export function PrCreateModal({
} catch (loadError) { } catch (loadError) {
if (requestId === requestSeqRef.current.metadata) { if (requestId === requestSeqRef.current.metadata) {
setMetadataError(getErrorMessage(loadError)); setMetadataError(getErrorMessage(loadError));
setBody((current) => (current.trim() ? current : buildManualPrBodyFallback(taskId)));
setAiBody((current) => current || buildManualPrBodyFallback(taskId));
} }
} finally { } finally {
if (requestId === requestSeqRef.current.metadata) { if (requestId === requestSeqRef.current.metadata) {
@@ -351,6 +377,8 @@ export function PrCreateModal({
} catch (regenerateError) { } catch (regenerateError) {
if (requestId === requestSeqRef.current.metadata) { if (requestId === requestSeqRef.current.metadata) {
setMetadataError(getErrorMessage(regenerateError)); setMetadataError(getErrorMessage(regenerateError));
setBody((current) => (current.trim() ? current : buildManualPrBodyFallback(taskId)));
setAiBody((current) => current || buildManualPrBodyFallback(taskId));
} }
} finally { } finally {
if (requestId === requestSeqRef.current.metadata) { if (requestId === requestSeqRef.current.metadata) {
@@ -445,7 +473,7 @@ export function PrCreateModal({
}), [assignees, baseBranch, body, draft, labels, reviewers, title]); }), [assignees, baseBranch, body, draft, labels, reviewers, title]);
const submit = useCallback(async () => { const submit = useCallback(async () => {
if (!payload.title || submitting) return; if (!payload.title || !payload.body || submitting) return;
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
setLastGhError(null); setLastGhError(null);
@@ -466,6 +494,8 @@ export function PrCreateModal({
} }
}, [addToast, onClose, onCreated, payload, projectId, submitting, taskId]); }, [addToast, onClose, onCreated, payload, projectId, submitting, taskId]);
const hasRequiredPrContent = title.trim().length > 0 && body.trim().length > 0;
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
@@ -672,7 +702,7 @@ export function PrCreateModal({
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>{t("actions.cancel", "Cancel")}</button> <button type="button" className="btn" onClick={onClose} disabled={submitting}>{t("actions.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!preflight || preflightLoading || !canSubmit || !title.trim() || submitting}> <button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!preflight || preflightLoading || !canSubmit || !hasRequiredPrContent || submitting}>
{submitting ? <RefreshCw size={14} className="spin" /> : null} {submitting ? <RefreshCw size={14} className="spin" /> : null}
{draft ? t("pr.createDraftPr", "Create draft PR") : t("pr.createPr", "Create PR")} {draft ? t("pr.createDraftPr", "Create draft PR") : t("pr.createPr", "Create PR")}
</button> </button>

View File

@@ -182,6 +182,8 @@ describe("PrCreateModal", () => {
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual PR title" } }); fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual PR title" } });
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Manual PR body" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled()); await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } }); fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
@@ -215,10 +217,49 @@ describe("PrCreateModal", () => {
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument(); expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled(); expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("## Changes");
expect(bodyInput.value).toContain("## Testing");
expect(bodyInput.value).toContain("Closes FN-4756");
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } }); fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled()); await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
}); });
it("requires a non-empty body before submitting manual PR metadata", async () => {
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
renderModal();
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "" } });
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
expect(mocks.createPr).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Manual fallback body" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
});
it("uses route fallback metadata as editable content after a timeout response", async () => {
mocks.generatePrMetadata.mockResolvedValueOnce({
title: "Fallback title",
body: "## Summary\n\nSummary unavailable.\n\n## Changes\n\n- Details unavailable.\n\n## Testing\n\n- Not provided.\n\n## Linked Task\n\nCloses FN-4756",
templateUsed: false,
});
renderModal();
expect(await screen.findByDisplayValue("Fallback title")).toBeInTheDocument();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("Closes FN-4756");
expect(screen.queryByText(/generating ai title/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("keeps options failure scoped so metadata and preflight still render", async () => { it("keeps options failure scoped so metadata and preflight still render", async () => {
mocks.fetchPrOptions.mockRejectedValueOnce(new Error("options blew up")); mocks.fetchPrOptions.mockRejectedValueOnce(new Error("options blew up"));
renderModal(); renderModal();

View File

@@ -32,7 +32,7 @@ describe("GitHubClient.createPr draft/reviewer", () => {
mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/42\n"); mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/42\n");
const client = new GitHubClient({ forceMode: "gh-cli" }); const client = new GitHubClient({ forceMode: "gh-cli" });
await client.createPr({ title: "T", head: "fusion/fn-001", base: "main", draft, reviewers }); await client.createPr({ title: "T", body: "B", head: "fusion/fn-001", base: "main", draft, reviewers });
const args = mockRunGh.mock.calls[0][0]; const args = mockRunGh.mock.calls[0][0];
expect(args.includes("--draft")).toBe(expectsDraft); expect(args.includes("--draft")).toBe(expectsDraft);
@@ -48,7 +48,7 @@ describe("GitHubClient.createPr draft/reviewer", () => {
} as any) } as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any); .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any);
await client.createPr({ title: "T", head: "fusion/fn-001", base: "main", draft: true, reviewers: ["alice", "bob"] }); await client.createPr({ title: "T", body: "B", head: "fusion/fn-001", base: "main", draft: true, reviewers: ["alice", "bob"] });
expect(String(fetchSpy.mock.calls[0][1]?.body)).toContain('"draft":true'); expect(String(fetchSpy.mock.calls[0][1]?.body)).toContain('"draft":true');
expect(fetchSpy.mock.calls[1][0]).toContain("/requested_reviewers"); expect(fetchSpy.mock.calls[1][0]).toContain("/requested_reviewers");
@@ -65,7 +65,7 @@ describe("GitHubClient.createPr draft/reviewer", () => {
} as any) } as any)
.mockResolvedValueOnce({ ok: false, status: 422, statusText: "Unprocessable", json: async () => ({ message: "invalid reviewers" }) } as any); .mockResolvedValueOnce({ ok: false, status: 422, statusText: "Unprocessable", json: async () => ({ message: "invalid reviewers" }) } as any);
const pr = await client.createPr({ title: "T", head: "fusion/fn-001", reviewers: ["alice"] }); const pr = await client.createPr({ title: "T", body: "B", head: "fusion/fn-001", reviewers: ["alice"] });
expect(pr.number).toBe(8); expect(pr.number).toBe(8);
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("failed to request reviewers")); expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("failed to request reviewers"));

View File

@@ -94,7 +94,7 @@ describe("GitHubClient forced mode", () => {
} as never); } as never);
const client = new GitHubClient("token-legacy"); const client = new GitHubClient("token-legacy");
await client.createPr({ owner: "o", repo: "r", title: "t", head: "head", base: "main" }); await client.createPr({ owner: "o", repo: "r", title: "t", body: "b", head: "head", base: "main" });
expect(mockRunGh).toHaveBeenCalledTimes(1); expect(mockRunGh).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);

View File

@@ -225,28 +225,19 @@ describe("GitHubClient", () => {
expect(result.status).toBe("open"); expect(result.status).toBe("open");
}); });
it("creates PR without body when not provided", async () => { it("rejects PR creation without a non-empty body before invoking gh", async () => {
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
const paramsWithoutBody: CreatePrParams = { const paramsWithoutBody: CreatePrParams = {
owner: "test-owner", owner: "test-owner",
repo: "test-repo", repo: "test-repo",
title: "Test PR", title: "Test PR",
head: "feature-branch", head: "feature-branch",
// body and base not provided
}; };
await client.createPr(paramsWithoutBody); await expect(client.createPr(paramsWithoutBody)).rejects.toThrow("PR body is required");
expect(mockRunGh).not.toHaveBeenCalled();
expect(mockRunGh).toHaveBeenCalledWith([ await expect(client.createPr({ ...paramsWithoutBody, body: " " })).rejects.toThrow("PR body is required");
"pr", "create", expect(mockRunGh).not.toHaveBeenCalled();
"--repo", "test-owner/test-repo",
"--title", "Test PR",
"--head", "feature-branch",
]);
// Should not include --body or --base when not provided
const callArgs = mockRunGh.mock.calls[0][0];
expect(callArgs).not.toContain("--body");
expect(callArgs).not.toContain("--base");
}); });
it("uses current repo context when owner/repo not specified", async () => { it("uses current repo context when owner/repo not specified", async () => {
@@ -255,6 +246,7 @@ describe("GitHubClient", () => {
const paramsWithoutRepo = { const paramsWithoutRepo = {
title: "Test PR", title: "Test PR",
body: "Test body",
head: "feature-branch", head: "feature-branch",
}; };
@@ -266,6 +258,7 @@ describe("GitHubClient", () => {
"--repo", "current-owner/current-repo", "--repo", "current-owner/current-repo",
"--title", "Test PR", "--title", "Test PR",
"--head", "feature-branch", "--head", "feature-branch",
"--body", "Test body",
]); ]);
expect(result.number).toBe(5); expect(result.number).toBe(5);
}); });

View File

@@ -57,6 +57,14 @@ function setupExec(outputs: Record<string, string>) {
}); });
} }
function expectFallbackBody(body: string) {
expect(body).toContain("## Summary");
expect(body).toContain("## Changes");
expect(body).toContain("## Testing");
expect(body).toContain("## Linked Task");
expect(body).toContain("Closes FN-4991");
}
describe("generatePrMetadata", () => { describe("generatePrMetadata", () => {
let repoRoot: string; let repoRoot: string;
@@ -167,11 +175,12 @@ describe("generatePrMetadata", () => {
body: expect.stringContaining("Closes FN-4991"), body: expect.stringContaining("Closes FN-4991"),
templateUsed: false, templateUsed: false,
}); });
expectFallbackBody(result.body);
}); });
it("returns fallback when prompt times out", async () => { it("returns fallback when createFnAgent times out before a session exists", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
promptMock.mockImplementation(() => new Promise(() => undefined)); vi.mocked(createFnAgent).mockImplementationOnce(() => new Promise(() => undefined) as never);
const resultPromise = generatePrMetadata({ const resultPromise = generatePrMetadata({
task: createTask(), task: createTask(),
@@ -187,7 +196,32 @@ describe("generatePrMetadata", () => {
body: expect.stringContaining("Closes FN-4991"), body: expect.stringContaining("Closes FN-4991"),
templateUsed: false, templateUsed: false,
}); });
expect(disposeMock).toHaveBeenCalledTimes(1); const result = await resultPromise;
expectFallbackBody(result.body);
expect(promptMock).not.toHaveBeenCalled();
expect(disposeMock).not.toHaveBeenCalled();
});
it("returns fallback when prompt times out", async () => {
vi.useFakeTimers();
promptMock.mockImplementation(() => new Promise(() => undefined));
const resultPromise = generatePrMetadata({
task: createTask(),
repoRoot,
settings: {} as never,
timeoutMs: 1_000,
});
await vi.advanceTimersByTimeAsync(1_000);
await expect(resultPromise).resolves.toEqual({
title: "Route contracts",
body: expect.stringContaining("Closes FN-4991"),
templateUsed: false,
});
const result = await resultPromise;
expectFallbackBody(result.body);
}); });
it("returns fallback immediately when caller signal is already aborted", async () => { it("returns fallback immediately when caller signal is already aborted", async () => {
@@ -210,15 +244,9 @@ describe("generatePrMetadata", () => {
expect(promptMock).not.toHaveBeenCalled(); expect(promptMock).not.toHaveBeenCalled();
}); });
it("returns fallback and disposes the session when caller aborts mid-generation", async () => { it("returns fallback when caller aborts mid-generation", async () => {
const controller = new AbortController(); const controller = new AbortController();
let promptStarted!: () => void; vi.mocked(createFnAgent).mockImplementationOnce(() => new Promise(() => undefined) as never);
const promptStartedPromise = new Promise<void>((resolve) => {
promptStarted = resolve;
});
promptMock.mockImplementation(() => new Promise(() => {
promptStarted();
}));
const resultPromise = generatePrMetadata({ const resultPromise = generatePrMetadata({
task: createTask(), task: createTask(),
@@ -227,7 +255,6 @@ describe("generatePrMetadata", () => {
signal: controller.signal, signal: controller.signal,
}); });
await promptStartedPromise;
controller.abort(); controller.abort();
await expect(resultPromise).resolves.toEqual({ await expect(resultPromise).resolves.toEqual({
@@ -235,7 +262,7 @@ describe("generatePrMetadata", () => {
body: expect.stringContaining("Closes FN-4991"), body: expect.stringContaining("Closes FN-4991"),
templateUsed: false, templateUsed: false,
}); });
expect(promptMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) })); const result = await resultPromise;
expect(disposeMock).toHaveBeenCalledTimes(1); expectFallbackBody(result.body);
}); });
}); });

View File

@@ -85,6 +85,16 @@ describe("PR routes contract", () => {
expect(response.body.error).toContain("title is required"); expect(response.body.error).toContain("title is required");
}); });
it("rejects PR create requests with a title but no body before GitHub invocation", async () => {
const app = createServer(createStore(createTask({ prInfo: undefined })));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "Manual title", body: " " }), {
"content-type": "application/json",
});
expect(response.status).toBe(400);
expect(response.body.error).toContain("body is required");
});
it("does not return conflict when task already has PR", async () => { it("does not return conflict when task already has PR", async () => {
const app = createServer(createStore(createTask())); const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "x" }), { const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "x" }), {

View File

@@ -80,7 +80,7 @@ describe("PR route structured GitHub errors", () => {
vi.spyOn(GitHubClient.prototype, "findPrForBranch").mockRejectedValue(new Error("authentication required 401")); vi.spyOn(GitHubClient.prototype, "findPrForBranch").mockRejectedValue(new Error("authentication required 401"));
const app = createServer(createStore(task)); const app = createServer(createStore(task));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "PR title" }), { "content-type": "application/json" }); const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "PR title", body: "PR body" }), { "content-type": "application/json" });
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(response.body.details.githubError.code).toBe("not-authenticated"); expect(response.body.details.githubError.code).toBe("not-authenticated");

View File

@@ -10,6 +10,11 @@ const { mockGeneratePrMetadata } = vi.hoisted(() => ({
vi.mock("../pr-metadata-generator.js", () => ({ vi.mock("../pr-metadata-generator.js", () => ({
generatePrMetadata: mockGeneratePrMetadata, generatePrMetadata: mockGeneratePrMetadata,
buildFallbackPrMetadata: (task: Task) => ({
title: task.title ?? task.id,
body: ["## Summary", "", task.description ?? "Summary unavailable.", "", "## Changes", "", "- Details unavailable.", "", "## Testing", "", "- Not provided.", "", "## Linked Task", "", `Closes ${task.id}`].join("\n"),
templateUsed: false,
}),
})); }));
import { prRouteCommandRunner } from "../routes/register-git-github.js"; import { prRouteCommandRunner } from "../routes/register-git-github.js";
@@ -126,6 +131,7 @@ describe("PR metadata/preflight/options routes", () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
if (originalRepoEnv === undefined) { if (originalRepoEnv === undefined) {
delete process.env.GITHUB_REPOSITORY; delete process.env.GITHUB_REPOSITORY;
@@ -144,9 +150,28 @@ describe("PR metadata/preflight/options routes", () => {
task: expect.objectContaining({ id: "FN-001" }), task: expect.objectContaining({ id: "FN-001" }),
repoRoot: "/tmp/project", repoRoot: "/tmp/project",
signal: expect.any(AbortSignal), signal: expect.any(AbortSignal),
timeoutMs: 25_000,
})); }));
}); });
it("POST /pr/generate-metadata returns deterministic fallback when the generator never resolves", async () => {
vi.useFakeTimers();
mockGeneratePrMetadata.mockImplementationOnce(() => new Promise(() => undefined));
const app = createServer(createStore(createTask()));
const responsePromise = performRequest(app, "POST", "/api/tasks/FN-001/pr/generate-metadata", "{}", { "content-type": "application/json" });
await vi.advanceTimersByTimeAsync(25_000);
const response = await responsePromise;
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ title: "Task", templateUsed: false });
expect(response.body.body).toContain("## Summary");
expect(response.body.body).toContain("## Changes");
expect(response.body.body).toContain("## Testing");
expect(response.body.body).toContain("## Linked Task");
expect(response.body.body).toContain("Closes FN-001");
});
it("POST /pr/generate-metadata returns 404 for missing task", async () => { it("POST /pr/generate-metadata returns 404 for missing task", async () => {
const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); const missing = Object.assign(new Error("missing"), { code: "ENOENT" });
const app = createServer(createStore(missing)); const app = createServer(createStore(missing));

View File

@@ -3058,7 +3058,7 @@ describe("Pause/Unpause endpoints", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/tasks/KB-001/pr/create", "/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }), JSON.stringify({ title: "Test PR", body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -3081,7 +3081,7 @@ describe("Pause/Unpause endpoints", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/tasks/KB-001/pr/create", "/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }), JSON.stringify({ title: "Test PR", body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -3138,7 +3138,7 @@ describe("Pause/Unpause endpoints", () => {
app, app,
"POST", "POST",
`/api/tasks/KB-RATE-${i}/pr/create`, `/api/tasks/KB-RATE-${i}/pr/create`,
JSON.stringify({ title: `Test PR ${i}` }), JSON.stringify({ title: `Test PR ${i}`, body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
// Should not get 429 from our code (may get 500 from gh CLI not being available in test) // Should not get 429 from our code (may get 500 from gh CLI not being available in test)
@@ -3166,7 +3166,7 @@ describe("Pause/Unpause endpoints", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/tasks/KB-001/pr/create", "/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }), JSON.stringify({ title: "Test PR", body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -3192,7 +3192,7 @@ describe("Pause/Unpause endpoints", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/tasks/KB-001/pr/create", "/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }), JSON.stringify({ title: "Test PR", body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -3217,7 +3217,7 @@ describe("Pause/Unpause endpoints", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/tasks/KB-999/pr/create", "/api/tasks/KB-999/pr/create",
JSON.stringify({ title: "Test PR" }), JSON.stringify({ title: "Test PR", body: "Test body" }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );

View File

@@ -2557,7 +2557,7 @@ describe("PR conflict refresh + reclaim routes", () => {
commentCount: 0, commentCount: 0,
} as any); } as any);
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/create`, JSON.stringify({ title: "New PR" }), { "content-type": "application/json" }); const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/create`, JSON.stringify({ title: "New PR", body: "New PR body" }), { "content-type": "application/json" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(store.addPrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ number: 901 })); expect(store.addPrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ number: 901 }));

View File

@@ -153,6 +153,14 @@ export interface CreateIssueParams {
labels?: string[]; labels?: string[];
} }
function requireNonEmptyPrBody(body: string | undefined): string {
const trimmed = body?.trim() ?? "";
if (!trimmed) {
throw new Error("PR body is required when creating a pull request");
}
return trimmed;
}
export interface CreatedIssue { export interface CreatedIssue {
owner: string; owner: string;
repo: string; repo: string;
@@ -828,6 +836,11 @@ export class GitHubClient {
private createPrWithGh(params: CreatePrParams): PrInfo { private createPrWithGh(params: CreatePrParams): PrInfo {
const { owner: paramOwner, repo: paramRepo, title, body, head, base, draft, reviewers } = params; const { owner: paramOwner, repo: paramRepo, title, body, head, base, draft, reviewers } = params;
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo); const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
/*
FNXC:GitHubPrCreate 2026-06-23-00:00:
The Create PR flow runs `gh pr create` non-interactively, so a title without a body fails at the CLI boundary. Validate here as a final guard in addition to dashboard/API checks and always pass `--body` with non-empty content.
*/
const prBody = requireNonEmptyPrBody(body);
// Build gh pr create command arguments (as array for safety) // Build gh pr create command arguments (as array for safety)
const args = [ const args = [
@@ -835,11 +848,9 @@ export class GitHubClient {
"--repo", `${owner}/${repo}`, "--repo", `${owner}/${repo}`,
"--title", title, "--title", title,
"--head", head, "--head", head,
"--body", prBody,
]; ];
if (body) {
args.push("--body", body);
}
if (base) { if (base) {
args.push("--base", base); args.push("--base", base);
} }
@@ -877,6 +888,7 @@ export class GitHubClient {
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> { private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main", draft, reviewers } = params; const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main", draft, reviewers } = params;
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo); const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
const prBody = requireNonEmptyPrBody(body);
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`; const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
@@ -887,7 +899,7 @@ export class GitHubClient {
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
title, title,
body: body || "", body: prBody,
head, head,
base, base,
draft: draft === true, draft: draft === true,

View File

@@ -7,7 +7,7 @@ import { resolveTitleSummarizerSettingsModel } from "@fusion/core";
import { createFnAgent } from "@fusion/engine"; import { createFnAgent } from "@fusion/engine";
const execAsync = promisify(execCb); const execAsync = promisify(execCb);
const PR_METADATA_TIMEOUT_MS = 60_000; export const PR_METADATA_TIMEOUT_MS = 60_000;
export interface GeneratedPrMetadata { export interface GeneratedPrMetadata {
title: string; title: string;
@@ -23,7 +23,7 @@ interface AiMetadataResult {
linkedTask: string; linkedTask: string;
} }
function buildFallback(task: Task): GeneratedPrMetadata { export function buildFallbackPrMetadata(task: Task): GeneratedPrMetadata {
return { return {
title: task.title ?? task.id, title: task.title ?? task.id,
body: [ body: [
@@ -136,12 +136,13 @@ function fillTemplate(template: string, result: AiMetadataResult, taskId: string
} }
async function runCommand(command: string, cwd: string, signal?: AbortSignal): Promise<string> { async function runCommand(command: string, cwd: string, signal?: AbortSignal): Promise<string> {
const { stdout } = await execAsync(command, { const commandPromise = execAsync(command, {
cwd, cwd,
timeout: 15_000, timeout: 15_000,
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
signal, signal,
}); });
const { stdout } = signal ? await raceWithAbort(commandPromise, signal) : await commandPromise;
return stdout.trim(); return stdout.trim();
} }
@@ -158,20 +159,45 @@ function throwIfAborted(signal?: AbortSignal): void {
throw signal.reason instanceof Error ? signal.reason : createAbortError(); throw signal.reason instanceof Error ? signal.reason : createAbortError();
} }
function waitForAbort(signal: AbortSignal): Promise<never> { function getAbortReason(signal: AbortSignal): Error {
return new Promise((_, reject) => { return signal.reason instanceof Error ? signal.reason : createAbortError();
if (signal.aborted) { }
reject(signal.reason instanceof Error ? signal.reason : createAbortError());
return; function raceWithAbort<T>(operation: PromiseLike<T>, signal: AbortSignal): Promise<T> {
} if (signal.aborted) {
signal.addEventListener( return Promise.reject(getAbortReason(signal));
"abort", }
() => reject(signal.reason instanceof Error ? signal.reason : createAbortError()), return new Promise<T>((resolve, reject) => {
{ once: true }, const cleanup = () => signal.removeEventListener("abort", onAbort);
const onAbort = () => {
cleanup();
reject(getAbortReason(signal));
};
signal.addEventListener("abort", onAbort, { once: true });
Promise.resolve(operation).then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
); );
}); });
} }
function disposeSessionBestEffort(session: { dispose?: () => void | Promise<void> }): void {
try {
const disposeResult = session.dispose?.();
if (disposeResult && typeof (disposeResult as Promise<void>).catch === "function") {
void (disposeResult as Promise<void>).catch(() => undefined);
}
} catch {
// best effort
}
}
function isAbortLikeError(error: unknown): boolean { function isAbortLikeError(error: unknown): boolean {
return Boolean( return Boolean(
error error
@@ -202,9 +228,13 @@ export async function generatePrMetadata(input: {
timeoutMs?: number; timeoutMs?: number;
}): Promise<GeneratedPrMetadata> { }): Promise<GeneratedPrMetadata> {
const { task, repoRoot, settings, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input; const { task, repoRoot, settings, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input;
const fallback = buildFallback(task); const fallback = buildFallbackPrMetadata(task);
const controller = new AbortController(); const controller = new AbortController();
const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError()); const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError());
/*
FNXC:PrMetadataGeneration 2026-06-23-00:00:
The Create PR dialog must receive usable title/body metadata within its UX budget even when git, template reads, provider startup, prompt streaming, or disposal misbehaves. Race every awaited operation against one shared abort signal so provider/session hangs degrade to deterministic fallback content instead of leaving the modal spinner pending indefinitely.
*/
const timeoutId = setTimeout(() => controller.abort(createAbortError()), timeoutMs); const timeoutId = setTimeout(() => controller.abort(createAbortError()), timeoutMs);
const combinedSignal = controller.signal; const combinedSignal = controller.signal;
const callerSignalWasActive = Boolean(signal && !signal.aborted); const callerSignalWasActive = Boolean(signal && !signal.aborted);
@@ -220,27 +250,27 @@ export async function generatePrMetadata(input: {
try { try {
throwIfAborted(combinedSignal); throwIfAborted(combinedSignal);
const baseBranch = await resolveBaseBranch(task, repoRoot, combinedSignal); const baseBranch = await raceWithAbort(resolveBaseBranch(task, repoRoot, combinedSignal), combinedSignal);
const [logOut, diffStatOut] = await Promise.all([ const [logOut, diffStatOut] = await raceWithAbort(Promise.all([
runCommand(`git log --no-merges ${baseBranch}..HEAD --format=%s%n%b`, repoRoot, combinedSignal).catch(() => ""), runCommand(`git log --no-merges ${baseBranch}..HEAD --format=%s%n%b`, repoRoot, combinedSignal).catch(() => ""),
runCommand(`git diff --stat ${baseBranch}..HEAD`, repoRoot, combinedSignal).catch(() => ""), runCommand(`git diff --stat ${baseBranch}..HEAD`, repoRoot, combinedSignal).catch(() => ""),
]); ]), combinedSignal);
let promptContent = ""; let promptContent = "";
try { try {
const promptPath = join(repoRoot, ".fusion", "tasks", task.id, "PROMPT.md"); const promptPath = join(repoRoot, ".fusion", "tasks", task.id, "PROMPT.md");
promptContent = (await readFile(promptPath, "utf8")).trim(); promptContent = (await raceWithAbort(readFile(promptPath, "utf8"), combinedSignal)).trim();
} catch { } catch {
promptContent = ""; promptContent = "";
} }
const templatePath = join(repoRoot, ".github", "pull_request_template.md"); const templatePath = join(repoRoot, ".github", "pull_request_template.md");
const templateExists = await access(templatePath).then(() => true).catch(() => false); const templateExists = await raceWithAbort(access(templatePath).then(() => true).catch(() => false), combinedSignal);
const template = templateExists ? await readFile(templatePath, "utf8") : ""; const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>); const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
let aiText = ""; let aiText = "";
const { session } = await createFnAgent({ const { session } = await raceWithAbort(createFnAgent({
cwd: repoRoot, cwd: repoRoot,
tools: "readonly", tools: "readonly",
defaultProvider: model.provider, defaultProvider: model.provider,
@@ -253,7 +283,7 @@ export async function generatePrMetadata(input: {
onText: (delta: string) => { onText: (delta: string) => {
aiText += delta; aiText += delta;
}, },
}); }), combinedSignal);
try { try {
const contextPrompt = [ const contextPrompt = [
@@ -270,16 +300,12 @@ export async function generatePrMetadata(input: {
].join("\n\n"); ].join("\n\n");
throwIfAborted(combinedSignal); throwIfAborted(combinedSignal);
await Promise.race([ await raceWithAbort(
(session.prompt as (prompt: string, options?: { signal?: AbortSignal }) => Promise<unknown>)(contextPrompt, { signal: combinedSignal }), (session.prompt as (prompt: string, options?: { signal?: AbortSignal }) => Promise<unknown>)(contextPrompt, { signal: combinedSignal }),
waitForAbort(combinedSignal), combinedSignal,
]); );
} finally { } finally {
try { disposeSessionBestEffort(session);
session.dispose();
} catch {
// best effort
}
} }
const parsed = parseAiResult(aiText); const parsed = parseAiResult(aiText);

View File

@@ -46,7 +46,7 @@ import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
import { KnowledgeIndexRefreshService } from "../knowledge-index-refresh.js"; import { KnowledgeIndexRefreshService } from "../knowledge-index-refresh.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js"; import * as projectStoreResolver from "../project-store-resolver.js";
import { generatePrMetadata } from "../pr-metadata-generator.js"; import { buildFallbackPrMetadata, generatePrMetadata } from "../pr-metadata-generator.js";
import { resolvePrConflicts } from "../pr-conflict-resolver.js"; import { resolvePrConflicts } from "../pr-conflict-resolver.js";
import { import {
classifyWebhookEvent, classifyWebhookEvent,
@@ -62,6 +62,7 @@ const execAsync = promisify(execCb);
const PR_ROUTE_MAX_BUFFER_BYTES = 10 * 1024 * 1024; const PR_ROUTE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const PR_PREFLIGHT_TIMEOUT_MS = 15_000; const PR_PREFLIGHT_TIMEOUT_MS = 15_000;
const PR_OPTIONS_TIMEOUT_MS = 10_000; const PR_OPTIONS_TIMEOUT_MS = 10_000;
const PR_METADATA_ROUTE_TIMEOUT_MS = 25_000;
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
export const GITHUB_TRACKING_RECONCILE_INTERVAL_MS = 15 * 60 * 1000; export const GITHUB_TRACKING_RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
@@ -4855,7 +4856,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
/** /**
* POST /api/tasks/:id/pr/create * POST /api/tasks/:id/pr/create
* Create a GitHub PR for an in-review task. * Create a GitHub PR for an in-review task.
* Body: { title: string, body?: string, base?: string } * Body: { title: string, body: string, base?: string }
* Returns: Created PrInfo * Returns: Created PrInfo
*/ */
router.post("/tasks/:id/pr/create", async (req, res) => { router.post("/tasks/:id/pr/create", async (req, res) => {
@@ -4863,16 +4864,21 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const { title, body, base } = req.body; const { title, body, base } = req.body;
if (!title || typeof title !== "string") {
throw badRequest("title is required and must be a string");
}
// Get task and validate // Get task and validate
const task = await scopedStore.getTask(req.params.id); const task = await scopedStore.getTask(req.params.id);
if (task.column !== "in-review") { if (task.column !== "in-review") {
throw badRequest("Task must be in 'in-review' column to create a PR"); throw badRequest("Task must be in 'in-review' column to create a PR");
} }
if (!title || typeof title !== "string") {
throw badRequest("title is required and must be a string");
}
if (!body || typeof body !== "string" || !body.trim()) {
throw badRequest("body is required and must be a non-empty string");
}
const prTitle = title.trim();
const prBody = body.trim();
const existingPrs = getTaskPrList(task); const existingPrs = getTaskPrList(task);
// Determine branch name from task // Determine branch name from task
@@ -4920,8 +4926,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
prInfo = await client.createPr({ prInfo = await client.createPr({
owner, owner,
repo, repo,
title, title: prTitle,
body, body: prBody,
head: branchName, head: branchName,
base, base,
}); });
@@ -5098,20 +5104,53 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
*/ */
router.post("/tasks/:id/pr/generate-metadata", async (req, res) => { router.post("/tasks/:id/pr/generate-metadata", async (req, res) => {
const controller = new AbortController(); const controller = new AbortController();
const abortRequest = () => controller.abort(); let responseCompleted = false;
req.on("close", abortRequest); const abortRequest = () => {
if (!responseCompleted && !controller.signal.aborted) {
const error = new Error("PR metadata request aborted");
error.name = "AbortError";
controller.abort(error);
}
};
const markCompleted = () => {
responseCompleted = true;
};
req.on("aborted", abortRequest);
res.on("close", abortRequest);
res.on("finish", markCompleted);
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id); const task = await scopedStore.getTask(req.params.id);
const settings = await scopedStore.getSettings(); const settings = await scopedStore.getSettings();
const metadata = await generatePrMetadata({ /*
FNXC:PrMetadataGeneration 2026-06-23-00:00:
The dashboard route owns a shorter Create PR dialog budget than the reusable generator default. If a mocked or broken generator ignores aborts and never settles, return deterministic fallback metadata before the modal crosses the 30s UX threshold; do not treat the timeout abort as a disconnected-client signal.
*/
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const generatorPromise = generatePrMetadata({
task, task,
repoRoot: scopedStore.getRootDir(), repoRoot: scopedStore.getRootDir(),
settings, settings,
signal: controller.signal, signal: controller.signal,
timeoutMs: PR_METADATA_ROUTE_TIMEOUT_MS,
}); });
if (req.destroyed || res.writableEnded || res.writableFinished || controller.signal.aborted) { void generatorPromise.catch(() => undefined);
const timeoutFallbackPromise = new Promise<Awaited<ReturnType<typeof generatePrMetadata>>>((resolve) => {
timeoutId = setTimeout(() => {
abortRequest();
resolve(buildFallbackPrMetadata(task));
}, PR_METADATA_ROUTE_TIMEOUT_MS);
});
let metadata: Awaited<ReturnType<typeof generatePrMetadata>>;
try {
metadata = await Promise.race([generatorPromise, timeoutFallbackPromise]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
if (res.writableEnded || res.writableFinished || res.destroyed) {
return; return;
} }
res.json(metadata); res.json(metadata);
@@ -5124,7 +5163,9 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
} }
rethrowAsApiError(err, "Failed to generate PR metadata"); rethrowAsApiError(err, "Failed to generate PR metadata");
} finally { } finally {
req.off("close", abortRequest); req.removeListener("aborted", abortRequest);
res.removeListener("close", abortRequest);
res.removeListener("finish", markCompleted);
} }
}); });