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

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