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:
@@ -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 }> };
|
||||
|
||||
83
packages/dashboard/app/components/BranchGroupCard.css
Normal file
83
packages/dashboard/app/components/BranchGroupCard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
123
packages/dashboard/app/components/BranchGroupCard.tsx
Normal file
123
packages/dashboard/app/components/BranchGroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 && (() => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
119
packages/dashboard/src/__tests__/routes-branch-groups.test.ts
Normal file
119
packages/dashboard/src/__tests__/routes-branch-groups.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user