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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user