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
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@fusion/core", async () => {
|
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
|
return {
|
|
...actual,
|
|
isGhAvailable: vi.fn(() => true),
|
|
isGhAuthenticated: vi.fn(() => true),
|
|
runGh: vi.fn(),
|
|
runGhAsync: vi.fn(),
|
|
runGhJson: vi.fn(),
|
|
runGhJsonAsync: vi.fn(),
|
|
getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))),
|
|
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
|
|
};
|
|
});
|
|
|
|
import { runGh } from "@fusion/core";
|
|
import { GitHubClient } from "../github.js";
|
|
|
|
const mockRunGh = vi.mocked(runGh);
|
|
|
|
describe("GitHubClient.createPr draft/reviewer", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it.each([
|
|
{ draft: true, reviewers: ["alice", "bob"], expectsDraft: true, expectsReviewer: true },
|
|
{ draft: undefined, reviewers: [], expectsDraft: false, expectsReviewer: false },
|
|
])("passes gh flags %#", async ({ draft, reviewers, expectsDraft, expectsReviewer }) => {
|
|
mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/42\n");
|
|
const client = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
await client.createPr({ title: "T", body: "B", head: "fusion/fn-001", base: "main", draft, reviewers });
|
|
|
|
const args = mockRunGh.mock.calls[0][0];
|
|
expect(args.includes("--draft")).toBe(expectsDraft);
|
|
expect(args.includes("--reviewer")).toBe(expectsReviewer);
|
|
});
|
|
|
|
it("sends draft and reviewers through REST path", async () => {
|
|
const client = new GitHubClient({ token: "ghp_token", forceMode: "token" });
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ number: 7, html_url: "https://github.com/owner/repo/pull/7", title: "T", state: "open", draft: true, head: { ref: "fusion/fn-001" }, base: { ref: "main" }, comments: 0 }),
|
|
} as any)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any);
|
|
|
|
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(fetchSpy.mock.calls[1][0]).toContain("/requested_reviewers");
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("continues when reviewer request fails on REST path", async () => {
|
|
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
const client = new GitHubClient({ token: "ghp_token", forceMode: "token" });
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ number: 8, html_url: "https://github.com/owner/repo/pull/8", title: "T", state: "open", head: { ref: "fusion/fn-001" }, base: { ref: "main" }, comments: 0 }),
|
|
} as any)
|
|
.mockResolvedValueOnce({ ok: false, status: 422, statusText: "Unprocessable", json: async () => ({ message: "invalid reviewers" }) } as any);
|
|
|
|
const pr = await client.createPr({ title: "T", body: "B", head: "fusion/fn-001", reviewers: ["alice"] });
|
|
|
|
expect(pr.number).toBe(8);
|
|
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("failed to request reviewers"));
|
|
fetchSpy.mockRestore();
|
|
stderrSpy.mockRestore();
|
|
});
|
|
});
|