feat(FN-branch-group): surface group PR controls in dashboard + CLI (U7)

Extend BranchGroupCard/GroupTaskModal with an Abandon action (open PRs) and
terminal merged/closed badges; promote stays completion-gated. New
fn branch-group list|show|promote (alias fn bg) reaching the same coordinator
path with createGroupPrCallback wired — agent-native parity with the dashboard
promote flow, same completion-gate rejection.
This commit is contained in:
gsxdsm
2026-06-03 10:31:18 -07:00
parent 415470c7bd
commit 9512e98330
9 changed files with 536 additions and 6 deletions

View File

@@ -5,3 +5,5 @@
Branch-group promotion now creates a single real GitHub PR for the group integration branch when promoting a completed PR-mode group. The PR number/url/state are persisted on the branch group and promotion is idempotent — re-running never opens a second PR (an existing persisted or open PR is reused). The GitHub client is injected into the engine via the same option-callback seam as `processPullRequestMerge`, wired at the `fn daemon`, `fn dashboard`, and `fn serve` construction sites. PR creation only happens for eligible (completion-gated, auto-merge-allowed) groups, and a GitHub failure leaves the group recoverable rather than persisting a false PR state.
The single managed group PR is now kept in sync through its terminal lifecycle: as additional members land, the PR body is rewritten with the latest member checklist and x/N completion (idempotent body rewrite — sync failures are non-fatal and retry on the next landing). When the persisted PR is closed or merged out-of-band on GitHub, the stored `prState` is reconciled rather than re-opened. Abandoning a group best-effort closes its GitHub PR and marks `prState` `closed` (or preserves `merged`). New injected `syncGroupPr` callback and dashboard `updatePr`/`closePr` GitHub-client helpers back this flow.
The branch-group surface is completion-gated end-to-end: the dashboard branch-group card and Group Task modal show member progress before completion, reveal the promote/Open-PR control only when the group is complete, render the persisted PR link once promoted, expose an Abandon action while the PR is open, and display a terminal merged/closed state. A new agent-native CLI command (`fn branch-group list | show <id> | promote <id>`) reaches the same promotion coordinator path the dashboard uses — promoting a complete group opens/links the same single managed PR, and an incomplete group is rejected with the same completion-gate message.

View File

@@ -124,6 +124,7 @@ async function loadCommandHandlers() {
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote } = await import("./commands/branch-group.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice, runMissionLinkGoal, runMissionUnlinkGoal, runMissionGoals } = await import("./commands/mission.js");
@@ -184,6 +185,9 @@ async function loadCommandHandlers() {
runGitFetch,
runGitPull,
runGitPush,
runBranchGroupList,
runBranchGroupShow,
runBranchGroupPromote,
runBackupCreate,
runBackupList,
runBackupRestore,
@@ -365,6 +369,10 @@ PR:
fn git push Push current branch
fn git pull Pull current branch
fn git fetch [remote] Fetch from remote (default: origin)
fn branch-group list List branch groups with completion + PR state
fn branch-group show <id> Show a branch group's members and completion gate
fn branch-group promote <id>
Promote a complete group (opens/links the single managed PR)
fn agent stop <id> Stop a running agent (pause execution)
fn agent start <id> Start a stopped agent (resume execution)
fn agent import <path> [--dry-run] [--skip-existing]
@@ -623,6 +631,9 @@ async function main() {
runGitFetch,
runGitPull,
runGitPush,
runBranchGroupList,
runBranchGroupShow,
runBranchGroupPromote,
runBackupCreate,
runBackupList,
runBackupRestore,
@@ -1554,6 +1565,40 @@ async function main() {
break;
}
case "branch-group":
case "bg": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls":
await runBranchGroupList(projectName);
break;
case "show": {
const id = args[2];
if (!id) {
console.error("Usage: fn branch-group show <group-id>");
process.exit(1);
}
await runBranchGroupShow(id, projectName);
break;
}
case "promote": {
const id = args[2];
if (!id) {
console.error("Usage: fn branch-group promote <group-id>");
process.exit(1);
}
await runBranchGroupPromote(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: branch-group ${subcommand || ""}`);
console.log("Try: fn branch-group list | show <id> | promote <id>");
process.exit(1);
}
break;
}
case "backup": {
const create = args.includes("--create");
const list = args.includes("--list");

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---- Mocks ----------------------------------------------------------------
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
const promoteBranchGroupMock = vi.fn();
vi.mock("@fusion/engine", () => ({
promoteBranchGroup: (...args: unknown[]) => promoteBranchGroupMock(...args),
resolveIntegrationBranch: vi.fn(async () => "main"),
}));
// The canonical completion predicate lives in @fusion/core; keep its real
// behavior so the CLI gate matches the dashboard route gate (parity).
vi.mock("@fusion/dashboard", () => ({
GitHubClient: vi.fn(function GitHubClient() {}),
}));
const createGroupPrCallbackMock = vi.fn(() => async () => ({ prNumber: 1, prUrl: "x", prState: "open" as const }));
vi.mock("../task-lifecycle.js", () => ({
createGroupPrCallback: (...args: unknown[]) => createGroupPrCallbackMock(...args),
}));
import { resolveProject } from "../../project-context.js";
import { runBranchGroupPromote, runBranchGroupList } from "../branch-group.js";
const LANDED_TASK = {
id: "FN-1",
title: "one",
description: "one",
column: "in-review",
mergeDetails: {
mergeConfirmed: true,
mergeTargetSource: "branch-group-integration",
mergeTargetBranch: "feature/shared",
},
branchContext: { source: "planning", assignmentMode: "shared", groupId: "BG-1" },
};
const UNLANDED_TASK = {
...LANDED_TASK,
id: "FN-2",
column: "in-progress",
mergeDetails: undefined,
};
function makeStore(group: Record<string, unknown>, members: unknown[]) {
return {
getBranchGroup: vi.fn(() => group),
listBranchGroups: vi.fn(() => [group]),
listTasksByBranchGroup: vi.fn(async () => members),
getSettings: vi.fn(async () => ({
autoMerge: false,
globalPause: false,
enginePaused: false,
mergeStrategy: "merge",
baseBranch: "main",
})),
recordRunAuditEvent: vi.fn(),
};
}
const BASE_GROUP = {
id: "BG-1",
sourceType: "planning",
sourceId: "PS-1",
branchName: "feature/shared",
status: "open" as const,
prState: "none" as const,
autoMerge: false,
};
describe("branch-group CLI promote (agent-native parity)", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
promoteBranchGroupMock.mockReset();
createGroupPrCallbackMock.mockClear();
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
exitSpy.mockRestore();
logSpy.mockRestore();
errSpy.mockRestore();
vi.mocked(resolveProject).mockReset();
});
it("promotes a complete group via the same coordinator path and prints the PR url", async () => {
const store = makeStore(BASE_GROUP, [LANDED_TASK]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "p",
projectPath: "/tmp/p",
projectName: "p",
isRegistered: true,
store: store as never,
});
promoteBranchGroupMock.mockResolvedValue({
groupId: "BG-1",
promoted: true,
alreadyFinalized: false,
reason: "promoted",
status: "open",
prState: "open",
prNumber: 42,
prUrl: "https://example/pr/42",
});
await runBranchGroupPromote("BG-1");
// Reaches the SAME standalone coordinator the engine bridge method delegates to,
// with the createGroupPr callback wired (the dashboard route ends here too).
expect(createGroupPrCallbackMock).toHaveBeenCalledTimes(1);
expect(promoteBranchGroupMock).toHaveBeenCalledTimes(1);
const callArg = promoteBranchGroupMock.mock.calls[0][0] as Record<string, unknown>;
expect(callArg.groupId).toBe("BG-1");
expect(callArg.createGroupPr).toBeTypeOf("function");
expect(logSpy.mock.calls.flat().join("\n")).toContain("https://example/pr/42");
});
it("returns the same prUrl shape the promote route returns (parity)", async () => {
const store = makeStore(BASE_GROUP, [LANDED_TASK]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never,
});
const routeShape = {
groupId: "BG-1",
promoted: true,
alreadyFinalized: false,
reason: "promoted",
status: "open",
prState: "open",
prNumber: 7,
prUrl: "https://example/pr/7",
};
promoteBranchGroupMock.mockResolvedValue(routeShape);
await runBranchGroupPromote("BG-1");
const result = await promoteBranchGroupMock.mock.results[0].value;
expect(result).toMatchObject({ prNumber: 7, prUrl: "https://example/pr/7", prState: "open" });
});
it("rejects an incomplete group with the same completion gate message", async () => {
const store = makeStore(BASE_GROUP, [LANDED_TASK, UNLANDED_TASK]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never,
});
await expect(runBranchGroupPromote("BG-1")).rejects.toThrow(/process.exit/);
expect(promoteBranchGroupMock).not.toHaveBeenCalled();
expect(errSpy.mock.calls.flat().join("\n")).toContain("Branch group completion gate not satisfied");
});
it("lists groups with completion + PR state", async () => {
const store = makeStore({ ...BASE_GROUP, prState: "open", prNumber: 3 }, [LANDED_TASK]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never,
});
await runBranchGroupList();
const out = logSpy.mock.calls.flat().join("\n");
expect(out).toContain("BG-1");
expect(out).toContain("feature/shared");
expect(out).toContain("PR open");
});
});

View File

@@ -0,0 +1,169 @@
import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, type BranchGroup, type Settings } from "@fusion/core";
import { promoteBranchGroup, resolveIntegrationBranch } from "@fusion/engine";
import { GitHubClient } from "@fusion/dashboard";
import { resolveProject } from "../project-context.js";
import { createGroupPrCallback } from "./task-lifecycle.js";
/**
* Agent-native parity (R10): expose the same branch-group surfacing/controls a
* dashboard user gets (`GET /api/branch-groups`, `GET /:id`, `POST /:id/promote`)
* from the CLI.
*
* Pattern chosen: store-direct + the standalone `promoteBranchGroup` coordinator
* (the same function the engine bridge method delegates to), with the
* `createGroupPr` callback wired exactly as the dashboard/daemon construction
* sites wire it (`createGroupPrCallback(githubClient)`). The dashboard route's
* `promoteBranchGroup` option ultimately reaches this same coordinator function,
* so the CLI promote produces the SAME single managed PR — parity of outcome.
*
* This matches the established CLI convention (`task merge`, `task pr-create`,
* `git pull`) of operating against the resolved `TaskStore` and engine helpers
* directly rather than calling the dashboard HTTP API.
*/
interface BranchGroupCommandContext {
store: TaskStore;
projectPath: string;
}
async function getBranchGroupContext(projectName?: string): Promise<BranchGroupCommandContext> {
try {
const context = await resolveProject(projectName);
if (context) {
return { store: context.store, projectPath: context.projectPath };
}
} catch {
// fall through to a local store rooted at cwd
}
if (projectName) {
throw new Error(`Project ${projectName} not found`);
}
const store = new TaskStore(process.cwd());
await store.init();
return { store, projectPath: process.cwd() };
}
async function serializeCompletion(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: isBranchGroupMemberLanded(task, group),
}));
const landed = memberRows.filter((member) => member.landed).length;
return {
members: memberRows,
landed,
total: memberRows.length,
complete: isBranchGroupComplete(members, group),
};
}
export async function runBranchGroupList(projectName?: string) {
const { store } = await getBranchGroupContext(projectName);
const groups = store.listBranchGroups();
if (groups.length === 0) {
console.log("\n No branch groups yet.\n");
return;
}
console.log();
for (const group of groups) {
const completion = await serializeCompletion(store, group);
const prState = group.prState === "none" ? "no PR" : `PR ${group.prState}`;
const gate = completion.complete ? "complete" : `${completion.landed}/${completion.total}`;
console.log(` ${group.id} ${group.branchName} [${group.status}] (${gate}) ${prState}`);
}
console.log();
}
export async function runBranchGroupShow(id: string, projectName?: string) {
const { store } = await getBranchGroupContext(projectName);
const group = store.getBranchGroup(id);
if (!group) {
console.error(`\n ✗ Branch group ${id} not found\n`);
process.exit(1);
}
const completion = await serializeCompletion(store, group);
console.log();
console.log(` Branch group ${group.id}`);
console.log(` Branch: ${group.branchName}`);
console.log(` Source: ${group.sourceType}/${group.sourceId}`);
console.log(` Status: ${group.status}`);
console.log(` PR state: ${group.prState}${group.prNumber != null ? ` (#${group.prNumber})` : ""}`);
if (group.prUrl) {
console.log(` PR URL: ${group.prUrl}`);
}
console.log(` Progress: ${completion.landed} of ${completion.total} members finished${completion.complete ? " (complete)" : ""}`);
console.log();
console.log(" Members:");
for (const member of completion.members) {
const mark = member.landed ? "✓" : "○";
console.log(` ${mark} ${member.taskId} ${member.title} [${member.column}]`);
}
console.log();
}
export async function runBranchGroupPromote(id: string, projectName?: string) {
const { store, projectPath } = await getBranchGroupContext(projectName);
const group = store.getBranchGroup(id);
if (!group) {
console.error(`\n ✗ Branch group ${id} not found\n`);
process.exit(1);
}
// Completion gate — mirror the dashboard `POST /:id/promote` gate (R8) so the
// CLI rejects an incomplete group with the same message a dashboard user sees.
const members = await store.listTasksByBranchGroup(group.id);
if (!isBranchGroupComplete(members, group)) {
console.error("\n ✗ Branch group completion gate not satisfied\n");
process.exit(1);
}
const settings = (await store.getSettings()) as Settings;
const resolvedIntegrationBranch = await resolveIntegrationBranch(projectPath, settings);
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
console.log(`\n Promoting branch group ${group.id}…\n`);
try {
const result = await promoteBranchGroup({
store,
rootDir: projectPath,
groupId: group.id,
settings: {
autoMerge: settings.autoMerge,
globalPause: settings.globalPause,
enginePaused: settings.enginePaused,
mergeStrategy: settings.mergeStrategy,
integrationBranch: resolvedIntegrationBranch,
baseBranch: settings.baseBranch,
},
createGroupPr: createGroupPrCallback(githubClient),
recordAudit: (event) => {
store.recordRunAuditEvent({
agentId: "cli:branch-group-promote",
runId: `cli-promote-${group.id}`,
domain: event.domain as Parameters<TaskStore["recordRunAuditEvent"]>[0]["domain"],
mutationType: event.mutationType as Parameters<TaskStore["recordRunAuditEvent"]>[0]["mutationType"],
target: event.target,
metadata: event.metadata,
});
},
});
if (result.prUrl) {
console.log(` ✓ Group ${result.groupId} — PR ${result.prState}: ${result.prUrl}`);
} else {
console.log(` ✓ Group ${result.groupId} — ${result.reason} (status: ${result.status}, prState: ${result.prState})`);
}
console.log();
} catch (err) {
console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}

View File

@@ -618,6 +618,13 @@ export function apiPromoteBranchGroup(id: string, projectId?: string): Promise<P
});
}
export function apiAbandonBranchGroup(id: string, projectId?: string): Promise<{ groupId: string; group: BranchGroupSummary }> {
return api<{ groupId: string; group: BranchGroupSummary }>(withProjectId(`/branch-groups/${id}/abandon`, 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

@@ -2,7 +2,7 @@ import "./BranchGroupCard.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
import type { BranchGroupSummary } from "../api";
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
import { subscribeSse } from "../sse-bus";
interface BranchGroupCardProps {
@@ -15,6 +15,7 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [promoting, setPromoting] = useState(false);
const [abandoning, setAbandoning] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const loadGroup = useCallback(async () => {
@@ -87,6 +88,16 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
}
}, [groupId, loadGroup, projectId]);
const onAbandon = useCallback(async () => {
setAbandoning(true);
try {
await apiAbandonBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setAbandoning(false);
}
}, [groupId, loadGroup, projectId]);
if (loading) {
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> Loading branch group…</div>;
}
@@ -137,7 +148,19 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
</ul>
)}
{!collapsed && complete && (
{!collapsed && (group.prState === "merged" || group.prState === "closed") && (
<div className="branch-group-card-actions">
<span className="badge">{group.prState === "merged" ? "Group PR merged" : "Group PR closed"}</span>
{group.prUrl && (
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
<GitPullRequest size={14} /> PR #{group.prNumber ?? "—"}
<ExternalLink size={12} />
</a>
)}
</div>
)}
{!collapsed && complete && group.prState !== "merged" && group.prState !== "closed" && (
<div className="branch-group-card-actions">
{group.prUrl && (
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
@@ -147,10 +170,21 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
)}
{group.autoMerge ? (
<span className="badge">Auto-merge enabled</span>
) : group.prState === "none" ? (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
Open PR
</button>
) : (
<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"}
Merge group into main
</button>
)}
{group.prState === "open" && (
<button type="button" className="btn btn-danger" onClick={() => void onAbandon()} disabled={abandoning}>
{abandoning ? <Loader2 size={14} className="spin" /> : null}
Abandon group
</button>
)}
</div>

View File

@@ -1,7 +1,7 @@
import "./GroupTaskModal.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react";
import { apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
import { subscribeSse } from "../sse-bus";
interface GroupTaskModalProps {
@@ -16,6 +16,7 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(false);
const [promoting, setPromoting] = useState(false);
const [abandoning, setAbandoning] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadGroup = useCallback(async () => {
@@ -81,6 +82,17 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
}
}, [groupId, loadGroup, projectId]);
const onAbandon = useCallback(async () => {
if (!groupId) return;
setAbandoning(true);
try {
await apiAbandonBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setAbandoning(false);
}
}, [groupId, loadGroup, projectId]);
if (!isOpen || !groupId) return null;
return (
@@ -138,7 +150,13 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
</section>
)}
{group.completion.complete && (
{(group.prState === "merged" || group.prState === "closed") && (
<section className="card group-task-modal-actions">
<span className="badge">{group.prState === "merged" ? "Group PR merged" : "Group PR closed"}</span>
</section>
)}
{group.completion.complete && group.prState !== "merged" && group.prState !== "closed" && (
<section className="card group-task-modal-actions">
{group.autoMerge ? (
<span className="badge">Auto-merge enabled</span>
@@ -148,6 +166,12 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
{group.prState === "none" ? "Open PR" : "Merge group into main"}
</button>
)}
{group.prState === "open" && (
<button type="button" className="btn btn-danger" onClick={() => void onAbandon()} disabled={abandoning}>
{abandoning ? <Loader2 className="spin" /> : null}
Abandon group
</button>
)}
</section>
)}
</>

View File

@@ -5,10 +5,12 @@ import { BranchGroupCard } from "../BranchGroupCard";
const apiGetBranchGroup = vi.fn();
const apiPromoteBranchGroup = vi.fn();
const apiAbandonBranchGroup = vi.fn();
vi.mock("../../api", () => ({
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
apiAbandonBranchGroup: (...args: unknown[]) => apiAbandonBranchGroup(...args),
}));
vi.mock("../../sse-bus", () => ({
@@ -50,6 +52,7 @@ describe("BranchGroupCard", () => {
beforeEach(() => {
apiGetBranchGroup.mockReset();
apiPromoteBranchGroup.mockReset();
apiAbandonBranchGroup.mockReset();
});
it("hides promote control while incomplete", async () => {
@@ -96,6 +99,44 @@ describe("BranchGroupCard", () => {
expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument();
});
const completeMembers = [
{ taskId: "FN-1", title: "one", column: "done", landed: true },
{ taskId: "FN-2", title: "two", column: "done", landed: true },
];
it("shows Abandon control while group PR is open", async () => {
apiGetBranchGroup.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "open", prNumber: 11, prUrl: "https://example/pr/11" }),
});
apiAbandonBranchGroup.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) });
render(<BranchGroupCard groupId="BG-1" />);
const abandon = await screen.findByRole("button", { name: /abandon group/i });
fireEvent.click(abandon);
await waitFor(() => {
expect(apiAbandonBranchGroup).toHaveBeenCalledWith("BG-1", undefined);
});
});
it("shows terminal merged state and hides promote/abandon", async () => {
apiGetBranchGroup.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 5, prUrl: "https://example/pr/5" }),
});
render(<BranchGroupCard groupId="BG-1" />);
expect(await screen.findByText("Group PR merged")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
});
it("shows terminal closed state", async () => {
apiGetBranchGroup.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "closed", prNumber: 6, prUrl: "https://example/pr/6" }),
});
render(<BranchGroupCard groupId="BG-1" />);
expect(await screen.findByText("Group PR closed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
});
it("shows members by default and collapses via toggle", async () => {
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
render(<BranchGroupCard groupId="BG-1" />);

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GroupTaskModal } from "../GroupTaskModal";
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../../api";
import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api";
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
@@ -9,6 +9,7 @@ vi.mock("../../api", async () => {
...actual,
apiGetBranchGroup: vi.fn(),
apiPromoteBranchGroup: vi.fn(),
apiAbandonBranchGroup: vi.fn(),
};
});
@@ -19,6 +20,12 @@ vi.mock("../../hooks/useNavigationHistory", () => ({
const mockedGet = vi.mocked(apiGetBranchGroup);
const mockedPromote = vi.mocked(apiPromoteBranchGroup);
const mockedAbandon = vi.mocked(apiAbandonBranchGroup);
const completeMembers = [
{ taskId: "FN-1", title: "First", column: "done", landed: true },
{ taskId: "FN-2", title: "Second", column: "done", landed: true },
];
function makeGroup(overrides: Record<string, unknown> = {}) {
return {
@@ -42,6 +49,7 @@ describe("GroupTaskModal", () => {
beforeEach(() => {
mockedPromote.mockReset();
mockedGet.mockReset();
mockedAbandon.mockReset();
});
it("renders group summary and member open action", async () => {
@@ -102,4 +110,28 @@ describe("GroupTaskModal", () => {
expect(link.getAttribute("href")).toContain("/pull/1");
expect(link.textContent).toContain("open");
});
it("abandons an open group PR", async () => {
mockedGet.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "open", prNumber: 2, prUrl: "https://github.com/org/repo/pull/2" }),
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
mockedAbandon.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) } as Awaited<ReturnType<typeof apiAbandonBranchGroup>>);
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
const action = await screen.findByRole("button", { name: /abandon group/i });
await userEvent.click(action);
await waitFor(() => expect(mockedAbandon).toHaveBeenCalledWith("BG-1", undefined));
});
it("shows terminal state and hides controls when merged", async () => {
mockedGet.mockResolvedValue({
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 3, prUrl: "https://github.com/org/repo/pull/3" }),
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
expect(await screen.findByText("Group PR merged")).toBeDefined();
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
});
});