FN-5822: wire shared branch-group visibility and merge controls

Add dashboard and API support for viewing shared branch groups and triggering group merge actions.

- add branch-group API routes and register them in integrated routers
- extend dashboard legacy API client with branch-group fetch and merge-control actions
- add BranchGroupCard UI + styles and surface it in task/detail/subtask views
- add dashboard/API test coverage for branch-group routes and UI integration points
- document branch-group behavior and add a changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5822-branch-group-dashboard.md       |   5 +
 docs/dashboard-guide.md                            |  31 ++++++
 packages/dashboard/app/api/legacy.ts               |  54 +++++++++
 .../dashboard/app/components/BranchGroupCard.css   |  83 ++++++++++++++
 .../dashboard/app/components/BranchGroupCard.tsx   | 123 +++++++++++++++++++++
 .../app/components/SubtaskBreakdownModal.tsx       |   3 +
 packages/dashboard/app/components/TaskCard.tsx     |  19 ++++
 .../dashboard/app/components/TaskDetailModal.tsx   |   4 +
 .../components/__tests__/BranchGroupCard.test.tsx  |  96 ++++++++++++++++
 .../__tests__/SubtaskBreakdownModal.test.tsx       |   1 +
 .../app/components/__tests__/TaskCard.test.tsx     |  16 ++++
 .../components/__tests__/TaskDetailModal.test.tsx  |  22 ++++
 .../src/__tests__/routes-branch-groups.test.ts     | 119 ++++++++++++++++++++
 .../src/routes/register-branch-groups-routes.ts    | 118 ++++++++++++++++++++
 .../src/routes/register-integrated-routers.ts      |  13 +++
 packages/dashboard/vitest.config.ts                |   4 +-
 16 files changed, 709 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-5822

Fusion-Task-Lineage: 199c25b8-f6ec-43b4-8fab-509f23fb5ac7
This commit is contained in:
gsxdsm
2026-06-01 04:47:19 -07:00
parent 9c29e2e776
commit e9de195ef5
16 changed files with 709 additions and 2 deletions

View File

@@ -0,0 +1,118 @@
import { Router, type Request } from "express";
import type { BranchGroup, Task, TaskStore } from "@fusion/core";
import { badRequest, notFound } from "../api-error.js";
export interface BranchGroupsRouterOptions {
promoteBranchGroup?: (input: { groupId: string; projectId?: string }) => Promise<Record<string, unknown>>;
}
function parseProjectId(req: Request): string | undefined {
const value = req.query.projectId ?? req.body?.projectId;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isMemberLanded(task: Task, group: BranchGroup): boolean {
return task.mergeDetails?.mergeConfirmed === true
&& task.mergeDetails?.mergeTargetSource === "branch-group-integration"
&& task.mergeDetails?.mergeTargetBranch === group.branchName;
}
async function serializeGroup(store: TaskStore, group: BranchGroup) {
const members = await store.listTasksByBranchGroup(group.id);
const memberRows = members.map((task) => ({
taskId: task.id,
title: task.title ?? task.description,
column: task.column,
landed: isMemberLanded(task, group),
}));
const landedCount = memberRows.filter((member) => member.landed).length;
return {
...group,
members: memberRows,
completion: {
landed: landedCount,
total: memberRows.length,
complete: memberRows.length > 0 && landedCount === memberRows.length,
},
};
}
export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroupsRouterOptions): Router {
const router = Router();
router.get("/", async (req, res) => {
const statusRaw = req.query.status;
const status = typeof statusRaw === "string" && statusRaw.trim() ? statusRaw.trim() : undefined;
if (status && status !== "open" && status !== "finalized" && status !== "abandoned") {
throw badRequest("status must be one of: open, finalized, abandoned");
}
const groups = store.listBranchGroups(status ? { status: status as BranchGroup["status"] } : undefined);
const data = await Promise.all(groups.map((group) => serializeGroup(store, group)));
res.json({ groups: data });
});
router.get("/:id", async (req, res) => {
const id = String(req.params.id ?? "").trim();
if (!id) throw badRequest("id is required");
const group = store.getBranchGroup(id);
if (!group) throw notFound("Branch group not found");
res.json({ group: await serializeGroup(store, group) });
});
router.post("/assign", async (req, res) => {
const taskId = typeof req.body?.taskId === "string" ? req.body.taskId.trim() : "";
if (!taskId) throw badRequest("taskId is required");
const task = await store.getTask(taskId);
const groupIdBody = req.body?.groupId;
const branchNameRaw = req.body?.branchName;
const branchName = typeof branchNameRaw === "string" && branchNameRaw.trim() ? branchNameRaw.trim() : undefined;
if (groupIdBody === null) {
await store.setTaskBranchGroup(taskId, null);
res.json({ taskId, groupId: null });
return;
}
let groupId = typeof groupIdBody === "string" && groupIdBody.trim() ? groupIdBody.trim() : undefined;
if (!groupId) {
if (!branchName) throw badRequest("branchName is required when groupId is not provided");
const sourceType = task.branchContext?.source ?? "planning";
const sourceId = `task:${task.id}`;
const created = store.ensureBranchGroupForSource(sourceType, sourceId, {
branchName,
autoMerge: task.autoMerge ?? false,
});
groupId = created.id;
} else if (!store.getBranchGroup(groupId)) {
throw notFound("Branch group not found");
}
await store.setTaskBranchGroup(taskId, groupId);
res.json({ taskId, groupId });
});
router.post("/:id/promote", async (req, res) => {
const id = String(req.params.id ?? "").trim();
if (!id) throw badRequest("id is required");
const group = store.getBranchGroup(id);
if (!group) throw notFound("Branch group not found");
const members = await store.listTasksByBranchGroup(group.id);
const landed = members.filter((member) => isMemberLanded(member, group)).length;
if (members.length === 0 || landed !== members.length) {
throw badRequest("Branch group completion gate not satisfied");
}
const promote = options?.promoteBranchGroup;
if (!promote) {
throw badRequest("Branch-group promotion is unavailable");
}
const result = await promote({ groupId: id, projectId: parseProjectId(req) });
res.json({ groupId: id, ...result });
});
return router;
}

View File

@@ -12,6 +12,7 @@ import { createRoadmapCompatibilityRouter } from "../roadmap-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
import { createBranchGroupsRouter } from "./register-branch-groups-routes.js";
interface IntegratedRoutersOptions {
router: Router;
@@ -44,6 +45,18 @@ export function registerIntegratedRouters({
router.use("/goals", createGoalsRouter(store));
router.use("/roadmaps", createRoadmapCompatibilityRouter(store));
router.use("/stash-recovery", createStashRecoveryRouter(store));
router.use("/branch-groups", createBranchGroupsRouter(store, {
promoteBranchGroup: async ({ groupId, projectId }) => {
const engine = projectId && options?.engineManager
? options.engineManager.getEngine(projectId)
: options?.engine;
const promote = (engine as { promoteBranchGroup?: (id: string) => Promise<Record<string, unknown>> } | undefined)?.promoteBranchGroup;
if (!promote) {
throw new Error("promoteBranchGroup is not available on engine");
}
return await promote(groupId);
},
}));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {