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,5 @@
---
"@runfusion/fusion": minor
---
Add dashboard shared branch-group visibility and controls: branch-group list/show/assign/promote API routes, grouped task surfacing, and a completion-gated branch-group card that only reveals PR/merge actions once all members are landed.

View File

@@ -1112,3 +1112,34 @@ The merge-advance notice includes an explicit **Push to origin** action for the
- Non-fast-forward and lease-stale failures surface actionable messaging with Smart Pull.
- Every attempt records `mutationType: "push:origin"` run-audit metadata: `integrationBranch`, `remote`, `localSha`, `remoteSha`, `aheadCount`, `behindCount`, `forceWithLease`, `outcome`, optional `stderrPreview`, and `durationMs`.
- Push remains explicit user authorization only through dashboard HTTP routes (no scheduler/heartbeat auto-push).
## Shared branch groups
The dashboard now exposes branch-group visibility and controls for shared planning/mission branches.
- `GET /api/branch-groups` lists groups with completion (`landed`/`total`) and tracked PR metadata.
- `GET /api/branch-groups/:id` returns group details (shared branch, members, per-member landed state, completion, PR state).
- `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`).
- `POST /api/branch-groups/:id/promote` triggers the engine promotion flow (`promoteBranchGroup`) and returns promotion/PR status.
UI surfaces:
- Subtask planning interview shows a grouped indicator when `assignmentMode=shared`.
- Task cards show grouped/shared branch metadata for grouped tasks.
- Task detail renders a branch-group card with member landed progress.
The branch-group card is completion-gated: while members are still pending, it shows progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior).
### CLI-onboarding backfill runbook
Use the assign endpoint to place paused CLI-onboarding tasks into a single shared group rooted on `feature/cli-onboarding`:
```bash
for id in FN-5805 FN-5806 FN-5807 FN-5808 FN-5809 FN-5810 FN-5811 FN-5812 FN-5813 FN-5814 FN-5815 FN-5816; do
curl -sS -X POST "http://127.0.0.1:4040/api/branch-groups/assign" \
-H 'content-type: application/json' \
--data "{\"taskId\":\"$id\",\"branchName\":\"feature/cli-onboarding\"}"
done
```
If the endpoint is unavailable on the running dashboard build, the response will be `{"error":"Not found"}` until a build containing the branch-group router is deployed.

View File

@@ -77,6 +77,8 @@ import type {
ProjectNodePathMapping,
ApprovalRequestStatus,
TaskIdIntegrityReport,
BranchGroup,
BranchGroupPrState,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -564,6 +566,58 @@ export function mergeTask(id: string, projectId?: string): Promise<MergeResult>
return api<MergeResult>(withProjectId(`/tasks/${id}/merge`, projectId), { method: "POST" });
}
export interface BranchGroupMemberSummary {
taskId: string;
title: string;
column: Task["column"];
landed: boolean;
}
export interface BranchGroupSummary extends BranchGroup {
members: BranchGroupMemberSummary[];
completion: {
landed: number;
total: number;
complete: boolean;
};
}
export interface PromoteBranchGroupResult {
groupId: string;
status?: BranchGroup["status"];
prState?: BranchGroupPrState;
prNumber?: number;
prUrl?: string;
}
export function apiListBranchGroups(projectId?: string, status?: BranchGroup["status"]): Promise<{ groups: BranchGroupSummary[] }> {
const search = new URLSearchParams();
if (status) search.set("status", status);
const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<{ groups: BranchGroupSummary[] }>(withProjectId(`/branch-groups${suffix}`, projectId));
}
export function apiGetBranchGroup(id: string, projectId?: string): Promise<{ group: BranchGroupSummary }> {
return api<{ group: BranchGroupSummary }>(withProjectId(`/branch-groups/${id}`, projectId));
}
export function apiAssignTaskBranchGroup(
payload: { taskId: string; groupId?: string | null; branchName?: string },
projectId?: string,
): Promise<{ taskId: string; groupId: string | null }> {
return api<{ taskId: string; groupId: string | null }>(withProjectId("/branch-groups/assign", projectId), {
method: "POST",
body: JSON.stringify(payload),
});
}
export function apiPromoteBranchGroup(id: string, projectId?: string): Promise<PromoteBranchGroupResult> {
return api<PromoteBranchGroupResult>(withProjectId(`/branch-groups/${id}/promote`, projectId), {
method: "POST",
body: JSON.stringify({}),
});
}
export type RecoverBranchBindingOutcome =
| { taskId: string; result: "applied"; branch: string; aheadCount: number; integrationBase: string; previousBranch: string | null }
| { taskId: string; result: "skipped"; reason: "binding-intact" | "no-live-branch" | "ambiguous-candidates" | "no-unique-work"; candidates?: Array<{ branch: string; aheadCount: number }> };

View File

@@ -0,0 +1,83 @@
.branch-group-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.branch-group-card-error {
color: var(--color-error);
}
.branch-group-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.branch-group-card-title {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.branch-group-card-badge {
background: var(--surface-elevated);
}
.branch-group-card-progress-text {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.branch-group-card-progress {
width: 100%;
height: var(--space-xs);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
overflow: hidden;
}
.branch-group-card-progress-fill {
display: block;
height: 100%;
background: var(--color-info);
}
.branch-group-card-members {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
}
.branch-group-card-member {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.branch-group-card-member-title {
flex: 1;
}
.branch-group-card-member-status {
color: var(--text-muted);
display: inline-flex;
}
.branch-group-card-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
@media (max-width: 768px) {
.branch-group-card-header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,123 @@
import "./BranchGroupCard.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
import type { BranchGroupSummary } from "../api";
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
import { subscribeSse } from "../sse-bus";
interface BranchGroupCardProps {
groupId: string;
projectId?: string;
}
export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [promoting, setPromoting] = useState(false);
const loadGroup = useCallback(async () => {
try {
const response = await apiGetBranchGroup(groupId, projectId);
setGroup(response.group);
setError(null);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load branch group";
setError(message);
} finally {
setLoading(false);
}
}, [groupId, projectId]);
useEffect(() => {
setLoading(true);
void loadGroup();
}, [loadGroup]);
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
return subscribeSse(`/api/events${query}`, {
events: {
"task:updated": () => {
void loadGroup();
},
},
onReconnect: () => {
void loadGroup();
},
});
}, [loadGroup, projectId]);
const completionText = useMemo(() => {
if (!group) return "";
return `${group.completion.landed} of ${group.completion.total} members finished`;
}, [group]);
const onPromote = useCallback(async () => {
setPromoting(true);
try {
await apiPromoteBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setPromoting(false);
}
}, [groupId, loadGroup, projectId]);
if (loading) {
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> Loading branch group</div>;
}
if (error || !group) {
return <div className="card branch-group-card branch-group-card-error">{error ?? "Branch group unavailable"}</div>;
}
const completionPercent = group.completion.total > 0
? (group.completion.landed / group.completion.total) * 100
: 0;
const complete = group.completion.complete;
return (
<section className="card branch-group-card">
<header className="branch-group-card-header">
<div className="branch-group-card-title">
<GitBranch size={14} />
<strong>{group.branchName}</strong>
</div>
<span className="badge branch-group-card-badge">Group {group.id}</span>
</header>
<div className="branch-group-card-progress-text">{completionText}</div>
<div className="branch-group-card-progress" role="progressbar" aria-valuenow={group.completion.landed} aria-valuemin={0} aria-valuemax={group.completion.total}>
<span className="branch-group-card-progress-fill" style={{ width: `${completionPercent}%` }} />
</div>
<ul className="branch-group-card-members">
{group.members.map((member) => (
<li key={member.taskId} className="branch-group-card-member">
<span className={`status-dot ${member.landed ? "status-dot--online" : "status-dot--pending"}`} />
<span className="branch-group-card-member-title">{member.taskId} · {member.title}</span>
<span className="branch-group-card-member-status">{member.landed ? <CheckCircle2 size={14} /> : <CircleDashed size={14} />}</span>
</li>
))}
</ul>
{complete && (
<div className="branch-group-card-actions">
{group.prUrl && (
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
<GitPullRequest size={14} /> PR #{group.prNumber ?? "—"}
<ExternalLink size={12} />
</a>
)}
{group.autoMerge ? (
<span className="badge">Auto-merge enabled</span>
) : (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
{group.prState === "none" ? "Open PR" : "Merge group into main"}
</button>
)}
</div>
)}
</section>
);
}

View File

@@ -734,6 +734,9 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
<option value="shared">Shared merge target subtasks run on their own branches</option>
<option value="per-task-derived">Per-task branches derived from planning branch</option>
</select>
{branchAssignmentMode === "shared" && branchName.trim() && (
<p className="text-muted">Grouped on shared branch <strong>{branchName.trim()}</strong></p>
)}
</div>
</div>
{subtasks.map((subtask, index) => {

View File

@@ -1891,6 +1891,25 @@ function TaskCardComponent({
<span className="card-branch-value">{branchMetadata.baseBranch}</span>
</span>
)}
{task.branchContext?.groupId && (
<span
className="card-branch-chip"
title={
task.branchContext.assignmentMode === "shared" && branchMetadata.branch
? `${task.branchContext.groupId} · ${branchMetadata.branch}`
: task.branchContext.groupId
}
>
<span className="card-branch-label">
{task.branchContext.assignmentMode === "shared" ? "Shared" : "Group"}
</span>
<span className="card-branch-value">
{task.branchContext.assignmentMode === "shared" && branchMetadata.branch
? branchMetadata.branch
: task.branchContext.groupId}
</span>
</span>
)}
</div>
)}
{showProgressSection && (() => {

View File

@@ -38,6 +38,7 @@ import { WorkflowResultsTab } from "./WorkflowResultsTab";
import { RoutingTab } from "./RoutingTab";
import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
@@ -2624,6 +2625,9 @@ export function TaskDetailContent({
</span>
</div>
</div>
{task.branchContext?.groupId && (
<BranchGroupCard groupId={task.branchContext.groupId} projectId={projectId} />
)}
</>
)}
{task.status === "failed" && task.error && (

View File

@@ -0,0 +1,96 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { BranchGroupCard } from "../BranchGroupCard";
const apiGetBranchGroup = vi.fn();
const apiPromoteBranchGroup = vi.fn();
vi.mock("../../api", () => ({
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: () => () => {},
}));
vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
CircleDashed: () => null,
ExternalLink: () => null,
GitBranch: () => null,
GitPullRequest: () => null,
Loader2: () => null,
}));
function makeGroup(overrides: Record<string, unknown> = {}) {
return {
id: "BG-1",
sourceType: "planning",
sourceId: "PS-1",
branchName: "feature/shared",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
members: [
{ taskId: "FN-1", title: "one", column: "done", landed: true },
{ taskId: "FN-2", title: "two", column: "in-review", landed: false },
],
completion: { landed: 1, total: 2, complete: false },
...overrides,
};
}
describe("BranchGroupCard", () => {
beforeEach(() => {
apiGetBranchGroup.mockReset();
apiPromoteBranchGroup.mockReset();
});
it("hides promote control while incomplete", async () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
render(<BranchGroupCard groupId="BG-1" />);
await screen.findByText("1 of 2 members finished");
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
it("shows promote and calls API when complete + autoMerge off", async () => {
apiGetBranchGroup
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }] }) })
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }], prState: "open", prNumber: 22, prUrl: "https://example/pr/22" }) });
apiPromoteBranchGroup.mockResolvedValue({ groupId: "BG-1", prState: "open" });
render(<BranchGroupCard groupId="BG-1" />);
const button = await screen.findByRole("button", { name: /open pr/i });
fireEvent.click(button);
await waitFor(() => {
expect(apiPromoteBranchGroup).toHaveBeenCalledWith("BG-1", undefined);
});
});
it("shows auto-merge badge when complete and autoMerge is on", async () => {
apiGetBranchGroup.mockResolvedValue({
group: makeGroup({
autoMerge: true,
completion: { landed: 2, total: 2, complete: true },
members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }],
}),
});
render(<BranchGroupCard groupId="BG-1" />);
expect(await screen.findByText("Auto-merge enabled")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
});
it("renders tracked group PR link when present", async () => {
apiGetBranchGroup.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }], prState: "open", prNumber: 9, prUrl: "https://example/pr/9" }),
});
render(<BranchGroupCard groupId="BG-1" />);
expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument();
});
});

View File

@@ -304,6 +304,7 @@ describe("SubtaskBreakdownModal", () => {
fireEvent.change(branchNameInput, { target: { value: "feature/planning-shared" } });
fireEvent.change(mergeTargetInput, { target: { value: "develop" } });
fireEvent.change(branchModeSelect, { target: { value: "shared" } });
expect(await screen.findByText("Grouped on shared branch")).toBeInTheDocument();
fireEvent.click(screen.getByText("Create Tasks"));

View File

@@ -1393,6 +1393,22 @@ describe("TaskCard", () => {
expect(screen.getByText("develop")).toBeDefined();
});
it("shows shared group chip with shared branch label for grouped tasks", () => {
render(
<TaskCard
task={makeTask({
branch: "feature/shared-branch",
branchContext: { groupId: "BG-22", source: "planning", assignmentMode: "shared" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Shared")).toBeDefined();
expect(screen.getAllByText("feature/shared-branch").length).toBeGreaterThan(0);
});
it("keeps long non-default branch names readable via text and title semantics", () => {
const longBranch = "feature/fn-3423-display-very-long-working-branch-name-for-card-metadata";
const { container } = render(

View File

@@ -13,6 +13,10 @@ import {
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
vi.mock("../BranchGroupCard", () => ({
BranchGroupCard: ({ groupId }: { groupId: string }) => <div>Mock Branch Group {groupId}</div>,
}));
setupTaskDetailModalHooks();
describe("TaskDetailModal GitHub tracking CTA", () => {
@@ -88,6 +92,24 @@ describe("TaskDetailModal GitHub tracking CTA", () => {
});
});
describe("TaskDetailModal branch group surfacing", () => {
it("renders branch group card when task has group context", () => {
render(
<TaskDetailModal
task={makeTask({ branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "shared" } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
});
});
describe("TaskDetailModal delete affordance", () => {
it("archives done task when Archive Instead is chosen", async () => {
const user = userEvent.setup();

View File

@@ -0,0 +1,119 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { BranchGroup, Task, TaskStore } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { request as REQUEST } from "../test-request.js";
function buildTask(id: string, groupId: string, landed: boolean): Task {
return {
id,
description: id,
column: landed ? "done" : "in-progress",
dependencies: [],
steps: [],
currentStep: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
branchContext: { groupId, source: "planning", assignmentMode: "shared" },
mergeDetails: landed
? { mergeConfirmed: true, mergeTargetSource: "branch-group-integration", mergeTargetBranch: "feature/shared" }
: undefined,
} as Task;
}
function createStore(group: BranchGroup, tasks: Task[]): TaskStore {
return {
getRootDir: vi.fn(() => "/tmp/project"),
listBranchGroups: vi.fn(() => [group]),
getBranchGroup: vi.fn((id: string) => (id === group.id ? group : null)),
listTasksByBranchGroup: vi.fn(async () => tasks),
setTaskBranchGroup: vi.fn(async () => {}),
ensureBranchGroupForSource: vi.fn(() => group),
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? buildTask(id, group.id, false)),
} as unknown as TaskStore;
}
function buildApp(store: TaskStore, promoteBranchGroup?: ReturnType<typeof vi.fn>) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { engine: { promoteBranchGroup } as any }));
return app;
}
describe("branch group routes", () => {
let group: BranchGroup;
let tasks: Task[];
beforeEach(() => {
group = {
id: "BG-1",
sourceType: "planning",
sourceId: "PS-1",
branchName: "feature/shared",
autoMerge: false,
prState: "open",
prNumber: 101,
prUrl: "https://example/pr/101",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
};
tasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, false)];
});
it("lists and shows groups with completion + PR fields", async () => {
const app = buildApp(createStore(group, tasks));
const listRes = await REQUEST(app, "GET", "/api/branch-groups");
expect(listRes.status).toBe(200);
expect(listRes.body.groups[0].completion).toEqual({ landed: 1, total: 2, complete: false });
expect(listRes.body.groups[0].prNumber).toBe(101);
const showRes = await REQUEST(app, "GET", "/api/branch-groups/BG-1");
expect(showRes.status).toBe(200);
expect(showRes.body.group.members).toHaveLength(2);
expect(showRes.body.group.members[0]).toHaveProperty("landed");
});
it("returns 404 for unknown group", async () => {
const app = buildApp(createStore(group, tasks));
const res = await REQUEST(app, "GET", "/api/branch-groups/BG-404");
expect(res.status).toBe(404);
});
it("assigns and detaches grouped task", async () => {
const store = createStore(group, tasks);
const app = buildApp(store);
let res = await REQUEST(app, "POST", "/api/branch-groups/assign", JSON.stringify({ taskId: "FN-1", groupId: "BG-1" }), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect((store.setTaskBranchGroup as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("FN-1", "BG-1");
res = await REQUEST(app, "POST", "/api/branch-groups/assign", JSON.stringify({ taskId: "FN-1", groupId: null }), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect((store.setTaskBranchGroup as unknown as ReturnType<typeof vi.fn>)).toHaveBeenLastCalledWith("FN-1", null);
});
it("promotes completed groups and rejects incomplete groups", async () => {
const promoteBranchGroup = vi.fn(async () => ({ prNumber: 202, prUrl: "https://example/pr/202", prState: "open", status: "open" }));
const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)];
let app = buildApp(createStore(group, completeTasks), promoteBranchGroup);
let res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect(promoteBranchGroup).toHaveBeenCalledWith("BG-1");
expect(res.body.prNumber).toBe(202);
app = buildApp(createStore(group, tasks), promoteBranchGroup);
res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" });
expect(res.status).toBe(400);
});
it("creates group on assign when groupId absent", async () => {
const store = createStore(group, tasks);
const app = buildApp(store);
const res = await REQUEST(app, "POST", "/api/branch-groups/assign", JSON.stringify({ taskId: "FN-99", branchName: "feature/cli-onboarding" }), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect((store.ensureBranchGroupForSource as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
});
});

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 {

View File

@@ -12,7 +12,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.allow-resurrection,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,BranchGroupCard,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.allow-resurrection,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
@@ -22,7 +22,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,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,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-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.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,register-git-github.pr-options-preflight-metadata,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-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
];