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

@@ -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();