feat(FN-3428): add project-node path mapping APIs and agent detail review-b

Merges FN-3428's branch-field contract and merge target override/default behavior tests across core, dashboard, and engine, plus a companion fix that sweeps subsumed autostash orphans and surfaces outcomes on the task feed. Adds project-node path mapping persistence APIs and schema (FN-3503), new ag

Fusion-Task-Id: FN-3428
This commit is contained in:
Fusion
2026-05-07 12:43:55 -07:00
committed by gsxdsm
parent a1ae903d2d
commit 455819ac75
6 changed files with 180 additions and 10 deletions

View File

@@ -546,26 +546,35 @@ describe("TaskStore", () => {
expect(detail.branch).toBe("fusion/fn-001-custom");
});
it("updates and clears branch fields via null patch semantics", async () => {
it("preserves branch/baseBranch independently and clears with null without disturbing unrelated fields", async () => {
const task = await store.createTask({
description: "Branch field update",
title: "Keep this title",
baseBranch: "main",
branch: "fusion/fn-001-initial",
});
const updated = await store.updateTask(task.id, {
baseBranch: "release/2026.05",
const updatedBranchOnly = await store.updateTask(task.id, {
branch: "fusion/fn-001-updated",
});
expect(updated.baseBranch).toBe("release/2026.05");
expect(updated.branch).toBe("fusion/fn-001-updated");
expect(updatedBranchOnly.branch).toBe("fusion/fn-001-updated");
expect(updatedBranchOnly.baseBranch).toBe("main");
const cleared = await store.updateTask(task.id, {
baseBranch: null,
branch: null,
const updatedBaseOnly = await store.updateTask(task.id, {
baseBranch: "release/2026.05",
});
expect(cleared.baseBranch).toBeUndefined();
expect(cleared.branch).toBeUndefined();
expect(updatedBaseOnly.baseBranch).toBe("release/2026.05");
expect(updatedBaseOnly.branch).toBe("fusion/fn-001-updated");
const clearedBranch = await store.updateTask(task.id, { branch: null });
expect(clearedBranch.branch).toBeUndefined();
expect(clearedBranch.baseBranch).toBe("release/2026.05");
expect(clearedBranch.title).toBe("Keep this title");
const clearedBaseBranch = await store.updateTask(task.id, { baseBranch: null });
expect(clearedBaseBranch.baseBranch).toBeUndefined();
expect(clearedBaseBranch.branch).toBeUndefined();
expect(clearedBaseBranch.title).toBe("Keep this title");
});
it("round-trips branch fields through listTasks and reload", async () => {

View File

@@ -457,6 +457,17 @@ describe("updateTask", () => {
});
});
it("omits branch and baseBranch from update payload when unset", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, title: "Updated" }));
await updateTask("FN-001", { title: "Updated" });
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body).not.toHaveProperty("branch");
expect(body).not.toHaveProperty("baseBranch");
});
it("sends sourceIssue object when source metadata is provided", async () => {
const sourceIssue = {
provider: "github",
@@ -588,6 +599,17 @@ describe("createTask", () => {
expect(body.baseBranch).toBe("main");
});
it("omits branch and baseBranch in create payload when unset", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
await createTask({ description: "Task without branch fields" });
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body).not.toHaveProperty("branch");
expect(body).not.toHaveProperty("baseBranch");
});
it("serializes nodeId in create payload when execution target is specified", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
...FAKE_CREATED_TASK,

View File

@@ -664,6 +664,27 @@ describe("Board", () => {
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
});
it("renders server-filtered branch-target results without additional client filtering", () => {
const branchFilteredTasks: Task[] = [
createTask({
id: "FN-3428",
description: "Task targeting release branch",
column: "todo",
branch: "feature/fn-3428",
baseBranch: "release/2026-05",
}),
];
renderBoard({ tasks: branchFilteredTasks, searchQuery: "release/2026-05" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]") as Task[];
expect(todoTasks).toHaveLength(1);
expect(todoTasks[0]?.id).toBe("FN-3428");
expect(todoTasks[0]?.branch).toBe("feature/fn-3428");
expect(todoTasks[0]?.baseBranch).toBe("release/2026-05");
});
it("shows all tasks for whitespace-only search query (server treats as empty)", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", description: "First task", column: "todo" }),

View File

@@ -235,6 +235,32 @@ describe("TaskCard", () => {
expect(screen.queryByText("Branch")).toBeNull();
});
it("renders merge target from task.baseBranch, not prInfo.baseBranch metadata", () => {
render(
<TaskCard
task={makeTask({
branch: "fusion/fn-001",
baseBranch: "release/task-target",
prInfo: {
url: "https://github.com/runfusion/fusion/pull/10",
number: 10,
status: "open",
title: "PR title",
headBranch: "feature/pr-head",
baseBranch: "main",
commentCount: 0,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("release/task-target")).toBeDefined();
expect(screen.queryByText("main")).toBeNull();
});
it("shows both chips when branch and base branch are both non-default", () => {
const { container } = render(
<TaskCard

View File

@@ -1113,6 +1113,32 @@ describe("TaskDetailModal", () => {
});
});
it("saves changed baseBranch independently of branch", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "develop" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(container.querySelector("#task-base-branch") as HTMLInputElement, { target: { value: "release/2026-05" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { baseBranch: "release/2026-05" }, undefined);
});
});
it("sends null branch fields when working/base branches are cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);

View File

@@ -962,6 +962,72 @@ describe("aiMergeTask — task.branch field", () => {
});
});
describe("aiMergeTask — merge-target branch resolution", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("uses task.baseBranch as merge-target context when provided", async () => {
const store = createMockStore({
id: "FN-050",
branch: "feature/fn-050-work",
baseBranch: "release/2026-05",
worktree: "/tmp/root/.worktrees/KB-050",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('git merge-base "feature/fn-050-work" "release/2026-05"'),
),
).toBe(true);
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('git merge-base "feature/fn-050-work" "main"'),
),
).toBe(false);
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('rev-parse --verify "feature/fn-050-work"'),
),
).toBe(true);
});
it("defaults merge-target context to main when task.baseBranch is missing", async () => {
const store = createMockStore({
id: "FN-050",
branch: "feature/fn-050-work",
baseBranch: undefined,
worktree: "/tmp/root/.worktrees/KB-050",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('git merge-base "feature/fn-050-work" "main"'),
),
).toBe(true);
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('rev-parse --verify "feature/fn-050-work"'),
),
).toBe(true);
});
});
describe("aiMergeTask — empty squash merge (branch already merged via dep)", () => {
beforeEach(() => {
vi.clearAllMocks();