diff --git a/.changeset/fn-branch-group-single-pr.md b/.changeset/fn-branch-group-single-pr.md index 648a2c6dcc..41c03337fa 100644 --- a/.changeset/fn-branch-group-single-pr.md +++ b/.changeset/fn-branch-group-single-pr.md @@ -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 | promote `) 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. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 4a34970f45..f4c6edd21b 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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 Show a branch group's members and completion gate + fn branch-group promote + Promote a complete group (opens/links the single managed PR) fn agent stop Stop a running agent (pause execution) fn agent start Start a stopped agent (resume execution) fn agent import [--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 "); + process.exit(1); + } + await runBranchGroupShow(id, projectName); + break; + } + case "promote": { + const id = args[2]; + if (!id) { + console.error("Usage: fn branch-group promote "); + process.exit(1); + } + await runBranchGroupPromote(id, projectName); + break; + } + default: + console.error(`Unknown subcommand: branch-group ${subcommand || ""}`); + console.log("Try: fn branch-group list | show | promote "); + process.exit(1); + } + break; + } + case "backup": { const create = args.includes("--create"); const list = args.includes("--list"); diff --git a/packages/cli/src/commands/__tests__/branch-group.test.ts b/packages/cli/src/commands/__tests__/branch-group.test.ts new file mode 100644 index 0000000000..580a5fcb20 --- /dev/null +++ b/packages/cli/src/commands/__tests__/branch-group.test.ts @@ -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, 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; + let logSpy: ReturnType; + let errSpy: ReturnType; + + 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; + 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"); + }); +}); diff --git a/packages/cli/src/commands/branch-group.ts b/packages/cli/src/commands/branch-group.ts new file mode 100644 index 0000000000..dc940a8eb6 --- /dev/null +++ b/packages/cli/src/commands/branch-group.ts @@ -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 { + 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[0]["domain"], + mutationType: event.mutationType as Parameters[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); + } +} diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0a987a4900..3b702ee8c3 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -618,6 +618,13 @@ export function apiPromoteBranchGroup(id: string, projectId?: string): Promise

{ + 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 }> }; diff --git a/packages/dashboard/app/components/BranchGroupCard.tsx b/packages/dashboard/app/components/BranchGroupCard.tsx index c4073e757c..81d618ba8f 100644 --- a/packages/dashboard/app/components/BranchGroupCard.tsx +++ b/packages/dashboard/app/components/BranchGroupCard.tsx @@ -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(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

Loading branch group…
; } @@ -137,7 +148,19 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { )} - {!collapsed && complete && ( + {!collapsed && (group.prState === "merged" || group.prState === "closed") && ( +
+ {group.prState === "merged" ? "Group PR merged" : "Group PR closed"} + {group.prUrl && ( + + PR #{group.prNumber ?? "—"} + + + )} +
+ )} + + {!collapsed && complete && group.prState !== "merged" && group.prState !== "closed" && ( diff --git a/packages/dashboard/app/components/GroupTaskModal.tsx b/packages/dashboard/app/components/GroupTaskModal.tsx index 2c66efa8fb..c09e6700f1 100644 --- a/packages/dashboard/app/components/GroupTaskModal.tsx +++ b/packages/dashboard/app/components/GroupTaskModal.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [promoting, setPromoting] = useState(false); + const [abandoning, setAbandoning] = useState(false); const [error, setError] = useState(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 )} - {group.completion.complete && ( + {(group.prState === "merged" || group.prState === "closed") && ( +
+ {group.prState === "merged" ? "Group PR merged" : "Group PR closed"} +
+ )} + + {group.completion.complete && group.prState !== "merged" && group.prState !== "closed" && (
{group.autoMerge ? ( Auto-merge enabled @@ -148,6 +166,12 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb {group.prState === "none" ? "Open PR" : "Merge group into main"} )} + {group.prState === "open" && ( + + )}
)} diff --git a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx index 161817e9cc..e29793d4a9 100644 --- a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx @@ -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(); + 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(); + 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(); + 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(); diff --git a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx index a5b93f7d2e..19570e00b6 100644 --- a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx @@ -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("../../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 = {}) { 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>); + mockedAbandon.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) } as Awaited>); + + render(); + + 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>); + + render(); + + expect(await screen.findByText("Group PR merged")).toBeDefined(); + expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull(); + }); });