feat(FN-4766): merge fusion/fn-4766

This commit is contained in:
gsxdsm
2026-05-17 19:11:40 -07:00
parent 9475639a48
commit bf0e9a3e37
5 changed files with 312 additions and 6 deletions

View File

@@ -18,7 +18,7 @@ vi.mock("../../api", () => ({
createPr: mocks.createPr,
}));
const metadata = { title: "AI title", body: "AI body", templateUsed: true };
const metadata = { title: "AI title", body: "## Summary\n\n## Changes\n\n## Testing\n\n## Linked Task\n", templateUsed: true };
const preflight = {
branchOnRemote: true,
commitsPresent: true,
@@ -67,7 +67,7 @@ describe("PrCreateModal", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
it("loads metadata/preflight/options on open", async () => {
it("loads metadata/preflight/options on open and renders key sections", async () => {
renderModal();
await waitFor(() => {
expect(mocks.generatePrMetadata).toHaveBeenCalledTimes(1);
@@ -75,7 +75,23 @@ describe("PrCreateModal", () => {
expect(mocks.fetchPrOptions).toHaveBeenCalledTimes(1);
});
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
expect(await screen.findByDisplayValue("AI body")).toBeInTheDocument();
const bodyInput = (await screen.findByLabelText(/body/i)) as HTMLTextAreaElement;
expect(bodyInput).toBeInTheDocument();
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("## Changes");
expect(bodyInput.value).toContain("## Testing");
expect(bodyInput.value).toContain("## Linked Task");
expect(screen.getByText(/pre-flight checks/i)).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeInTheDocument();
expect(screen.getByLabelText(/create as draft/i)).toBeInTheDocument();
expect(screen.getByText("Reviewers")).toBeInTheDocument();
expect(screen.getByText("Assignees")).toBeInTheDocument();
expect(screen.getByText("Labels")).toBeInTheDocument();
expect(screen.getByText(/diff & commit preview/i)).toBeInTheDocument();
expect(screen.getByText("Commits")).toBeInTheDocument();
expect(screen.getByText("Changed files")).toBeInTheDocument();
expect(screen.getByText(/using/i)).toBeInTheDocument();
});
it("regenerates and reverts AI content", async () => {
@@ -104,6 +120,8 @@ describe("PrCreateModal", () => {
const { onCreated, addToast, onClose } = renderModal();
await screen.findByDisplayValue("AI title");
fireEvent.change(screen.getByLabelText(/base branch/i), { target: { value: "develop" } });
fireEvent.click(screen.getByLabelText(/create as draft/i));
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
fireEvent.click(screen.getByRole("button", { name: /reviewer 1/i }));
fireEvent.change(screen.getByPlaceholderText("Filter assignees"), { target: { value: "assign" } });
@@ -111,9 +129,20 @@ describe("PrCreateModal", () => {
fireEvent.change(screen.getByPlaceholderText("Filter labels"), { target: { value: "bug" } });
fireEvent.click(screen.getByRole("button", { name: "bug" }));
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
const submitButton = screen.getByRole("button", { name: "Create draft PR" });
expect(submitButton).toHaveClass("btn-primary");
fireEvent.click(submitButton);
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({ reviewers: ["rev1"], assignees: ["assign1"], labels: ["bug"] });
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({
title: "AI title",
body: metadata.body.trim(),
base: "develop",
draft: true,
reviewers: ["rev1"],
assignees: ["assign1"],
labels: ["bug"],
});
expect(onCreated).toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith("Created PR #12", "success");
expect(onClose).toHaveBeenCalled();

View File

@@ -28,6 +28,13 @@ const mockPrInfo = {
lastCommentAt: "2026-01-01T00:00:00.000Z",
};
const checksByRollup = {
success: [{ name: "build", required: true, state: "success", detailsUrl: "https://ci.example/build" }],
failure: [{ name: "build", required: true, state: "failure", detailsUrl: "https://ci.example/build" }],
pending: [{ name: "build", required: true, state: "pending", detailsUrl: "https://ci.example/build" }],
none: [],
} as const;
describe("PrPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -145,6 +152,42 @@ describe("PrPanel", () => {
expect(await screen.findByText(/No checks reported yet/i)).toBeInTheDocument();
});
it.each([
{ status: "open", rollup: "success", expectMerge: true, expectReadonly: false, expectChecksVisible: true },
{ status: "draft", rollup: "failure", expectMerge: false, expectReadonly: true, expectChecksVisible: true },
{ status: "merged", rollup: "pending", expectMerge: false, expectReadonly: true, expectChecksVisible: false },
{ status: "closed", rollup: "none", expectMerge: false, expectReadonly: true, expectChecksVisible: false },
])("handles state=$status and checks=$rollup", async ({ status, rollup, expectMerge, expectReadonly, expectChecksVisible }) => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: { ...mockPrInfo, status, draft: status === "draft" },
checks: checksByRollup[rollup],
reviewDecision: null,
blockingReasons: status === "open" && rollup === "success" ? [] : ["waiting"],
mergeReady: status === "open" && rollup === "success",
});
render(<PrPanel taskId="FN-001" prInfo={{ ...mockPrInfo, status, draft: status === "draft" }} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
fireEvent.click(screen.getByTitle("Refresh PR status"));
await screen.findByText(/View on GitHub/i);
if (expectChecksVisible) {
expect(screen.getByText(/passing, .*failing, .*pending/i)).toBeInTheDocument();
} else {
expect(screen.queryByText(/passing, .*failing, .*pending/i)).toBeNull();
}
const mergeButton = screen.queryByRole("button", { name: /merge pull request/i });
if (expectMerge) {
expect(mergeButton).toBeInTheDocument();
} else {
expect(mergeButton).toBeNull();
}
if (expectReadonly) {
expect(screen.queryByRole("combobox")).toBeNull();
}
});
it("renders review decision states", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ prInfo: mockPrInfo, checks: [], reviewDecision: "CHANGES_REQUESTED", blockingReasons: [] })
@@ -192,6 +235,29 @@ describe("PrPanel", () => {
});
});
it("shows reviewer feedback hint when changes are requested and task is in todo", async () => {
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({
snapshot: {
decision: "CHANGES_REQUESTED",
items: [
{
id: "gh-review-1",
state: "CHANGES_REQUESTED",
body: "Please split this function",
author: { login: "reviewer" },
htmlUrl: "https://github.com/owner/repo/pull/42#review-1",
},
],
},
comments: [],
});
render(<PrPanel taskId="FN-001" taskColumn="todo" prInfo={mockPrInfo} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
expect(await screen.findByText(/Auto-moved to Todo/i)).toBeInTheDocument();
expect(screen.getByText(/Please split this function/i)).toBeInTheDocument();
});
it("shows conflict hint from blocking reasons after refresh", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: mockPrInfo,

View File

@@ -0,0 +1,117 @@
// @vitest-environment node
import { describe, it, expect, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { request as performRequest } from "../test-request.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Task",
description: "desc",
column: "in-review",
status: "in-review",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
autoMergeOnGreen: false,
autoMergeStrategy: "squash",
lastMergeError: "old",
lastMergeErrorAt: new Date().toISOString(),
},
...overrides,
} as Task;
}
function createStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
describe("PR auto-merge routes", () => {
it("updates auto-merge settings and clears stale merge error fields", async () => {
const task = createTask();
const store = createStore(task);
const app = createServer(store);
const response = await performRequest(
app,
"POST",
"/api/tasks/FN-001/pr/auto-merge",
JSON.stringify({ enabled: true, strategy: "rebase" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(store.updatePrInfo).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({
autoMergeOnGreen: true,
autoMergeStrategy: "rebase",
lastMergeError: undefined,
lastMergeErrorAt: undefined,
}),
);
});
it("rejects invalid auto-merge strategy", async () => {
const app = createServer(createStore(createTask()));
const response = await performRequest(
app,
"POST",
"/api/tasks/FN-001/pr/auto-merge",
JSON.stringify({ enabled: true, strategy: "invalid" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("Invalid auto-merge strategy");
});
it("rejects invalid merge method on merge endpoint", async () => {
const app = createServer(createStore(createTask()));
const response = await performRequest(
app,
"POST",
"/api/tasks/FN-001/pr/merge",
JSON.stringify({ method: "invalid" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("Invalid merge method");
});
});

View File

@@ -0,0 +1,94 @@
// @vitest-environment node
import { describe, it, expect, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { get as performGet, request as performRequest } from "../test-request.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Task",
description: "desc",
column: "in-review",
status: "in-review",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
},
comments: [],
...overrides,
} as Task;
}
function createStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
describe("PR routes contract", () => {
it("returns structured 404 when task has no PR for status/refresh/reviews", async () => {
const app = createServer(createStore(createTask({ prInfo: undefined })));
const statusRes = await performGet(app, "/api/tasks/FN-001/pr/status");
const refreshRes = await performRequest(app, "POST", "/api/tasks/FN-001/pr/refresh", "{}", { "content-type": "application/json" });
const reviewsRes = await performGet(app, "/api/tasks/FN-001/pr/reviews");
for (const res of [statusRes, refreshRes, reviewsRes]) {
expect(res.status).toBe(404);
expect(res.body).toMatchObject({ error: expect.stringContaining("no associated PR") });
}
});
it("rejects invalid PR create request body with structured error", async () => {
const app = createServer(createStore(createTask({ prInfo: undefined })));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({}), {
"content-type": "application/json",
});
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns conflict when task already has PR", async () => {
const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "x" }), {
"content-type": "application/json",
});
expect(response.status).toBe(409);
expect(response.body.error).toContain("already has PR");
});
});

View File

@@ -21,7 +21,7 @@ const qualityAppTests = [
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
];