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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
176
packages/cli/src/commands/__tests__/branch-group.test.ts
Normal file
176
packages/cli/src/commands/__tests__/branch-group.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
169
packages/cli/src/commands/branch-group.ts
Normal file
169
packages/cli/src/commands/branch-group.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 }> };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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" />);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user