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:
118
packages/dashboard/src/routes/register-branch-groups-routes.ts
Normal file
118
packages/dashboard/src/routes/register-branch-groups-routes.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user