refactor(FN-branch-group): remove dead cross-unit group-PR helpers

Simplicity pass over the 8-unit diff: delete caller-less closeGroupPrCallback
(CLI), dead dashboard createGroupPullRequest/syncGroupPullRequest (+ their
builders/types/tests — production uses the CLI callbacks), and merge the two
CLI PR-body builders into one parameterized function. ~140 LOC of
parallel-but-unused code from isolated unit implementation.
This commit is contained in:
gsxdsm
2026-06-03 11:00:16 -07:00
parent 3bea12f5d8
commit 928b14ae1b
9 changed files with 110 additions and 505 deletions

View File

@@ -642,7 +642,6 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
createGroupPrCallback: vi.fn(() => vi.fn()),
syncGroupPrCallback: vi.fn(() => vi.fn()),
closeGroupPrCallback: vi.fn(() => vi.fn()),
}));
vi.mock("../project-context.js", () => ({

View File

@@ -696,7 +696,6 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
createGroupPrCallback: vi.fn(() => vi.fn()),
syncGroupPrCallback: vi.fn(() => vi.fn()),
closeGroupPrCallback: vi.fn(() => vi.fn()),
}));
vi.mock("../project-context.js", () => ({

View File

@@ -37,7 +37,6 @@ import {
processPullRequestMergeTask,
getTaskBranchName,
syncGroupPrCallback,
closeGroupPrCallback,
} from "../task-lifecycle.js";
interface MockTask {
@@ -1374,28 +1373,3 @@ describe("syncGroupPrCallback (U6)", () => {
});
});
describe("closeGroupPrCallback (U6)", () => {
const group = { id: "BG-1", prNumber: 42 };
it("closes an open PR and returns closed state", async () => {
const github = {
getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "open", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })),
closePr: vi.fn(async () => ({ number: 42, url: "u", status: "closed", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })),
};
const close = closeGroupPrCallback(github as never);
const result = await close({ group: group as never });
expect(result.prState).toBe("closed");
expect(github.closePr).toHaveBeenCalledWith({ number: 42 });
});
it("reconciles (does not close) when already merged out-of-band", async () => {
const github = {
getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "merged", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })),
closePr: vi.fn(),
};
const close = closeGroupPrCallback(github as never);
const result = await close({ group: group as never });
expect(result.prState).toBe("merged");
expect(github.closePr).not.toHaveBeenCalled();
});
});

View File

@@ -20,7 +20,7 @@ import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
import type { CreateGroupPrFn, SyncGroupPrFn, CloseGroupPrFn, WorktreePool } from "@fusion/engine";
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine";
/**
* Minimal interface for GitHub operations needed by the PR merge workflow.
@@ -144,15 +144,37 @@ function buildGroupPullRequestTitle(group: Pick<BranchGroup, "id" | "sourceType"
return `${group.id}: ${group.sourceType}/${group.sourceId} (${members.length} tasks)`;
}
/**
* Build the body for a single managed group PR. With `checklist: true` (sync
* path, U6/R6) each member line gets an [x]/[ ] landed marker and an x/N
* "Completion" summary line is added; without it (initial create path) members
* are listed as plain bullets. Both variants share the same header/skeleton.
*/
function buildGroupPullRequestBody(
group: Pick<BranchGroup, "id" | "branchName" | "sourceType" | "sourceId">,
members: Array<Pick<Task, "id" | "title"> & { branchName: string }>,
options?: { checklist?: boolean; landed?: (member: Pick<Task, "id" | "title"> & { branchName: string }) => boolean },
): string {
const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"} — \`${member.branchName}\``);
return [
const checklist = options?.checklist ?? false;
const isLanded = options?.landed ?? (() => false);
const lines = members.map((member) => {
const title = member.title || "(untitled)";
if (checklist) {
return `- [${isLanded(member) ? "x" : " "}] ${member.id}: ${title} — \`${member.branchName}\``;
}
return `- ${member.id}: ${title} — \`${member.branchName}\``;
});
const header = [
`Automated group PR for ${group.id}.`,
`Source: ${group.sourceType}/${group.sourceId}`,
`Integration branch: \`${group.branchName}\``,
];
if (checklist) {
const landedCount = members.filter((member) => isLanded(member)).length;
header.push(`Completion: ${landedCount}/${members.length} landed`);
}
return [
...header,
"",
"Included tasks:",
...(lines.length > 0 ? lines : ["- (none)"]),
@@ -208,20 +230,16 @@ export function createGroupPrCallback(
* every sync, so repeated pushes are idempotent and coalesce naturally.
*/
function buildGroupPrSyncBody(group: BranchGroup, members: Task[]): string {
const landedCount = members.filter((member) => isBranchGroupMemberLanded(member, group)).length;
const lines = members.map((member) => {
const landed = isBranchGroupMemberLanded(member, group);
return `- [${landed ? "x" : " "}] ${member.id}: ${member.title || "(untitled)"} — \`${getTaskBranchName(member.id)}\``;
const membersWithBranch = members.map((member) => ({
id: member.id,
title: member.title,
branchName: getTaskBranchName(member.id),
}));
const landedById = new Map(members.map((member) => [member.id, isBranchGroupMemberLanded(member, group)]));
return buildGroupPullRequestBody(group, membersWithBranch, {
checklist: true,
landed: (member) => landedById.get(member.id) ?? false,
});
return [
`Automated group PR for ${group.id}.`,
`Source: ${group.sourceType}/${group.sourceId}`,
`Integration branch: \`${group.branchName}\``,
`Completion: ${landedCount}/${members.length} landed`,
"",
"Included tasks:",
...(lines.length > 0 ? lines : ["- (none)"]),
].join("\n");
}
/**
@@ -259,33 +277,6 @@ export function syncGroupPrCallback(
};
}
/**
* Build the `closeGroupPr` engine callback (KTD7, U6). Best-effort closes the
* single managed group PR during terminal reconciliation when a group is
* abandoned. If the PR is already closed/merged out-of-band, returns the
* reconciled state rather than erroring.
*/
export function closeGroupPrCallback(
github: Pick<GitHubOperations, "getPrStatus" | "closePr">,
): CloseGroupPrFn {
return async ({ group }) => {
if (group.prNumber == null) {
throw new Error(`closeGroupPr: group ${group.id} has no persisted prNumber`);
}
const repo = getCurrentRepo();
if (!repo) {
throw new Error("closeGroupPr: could not determine repository");
}
const current = await github.getPrStatus(repo.owner, repo.repo, group.prNumber);
const currentState = toBranchGroupPrState(current);
if (currentState !== "open") {
return { prNumber: current.number, prUrl: current.url, prState: currentState };
}
const closed = await github.closePr({ number: group.prNumber });
return { prNumber: closed.number, prUrl: closed.url, prState: toBranchGroupPrState(closed) };
};
}
async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise<boolean> {
try {
const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 });

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(() => true),
isGhAuthenticated: vi.fn(() => true),
runGh: vi.fn(),
runGhAsync: vi.fn(),
runGhJson: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))),
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
};
});
import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { GitHubClient, closeGroupPullRequest } from "../github.js";
const mockRunGh = vi.mocked(runGh);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const group = {
id: "BG-1",
branchName: "fusion/groups/planning-x",
sourceType: "planning" as const,
sourceId: "PS-1",
prNumber: 42,
};
const ghPrViewOpen = {
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "T",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: group.branchName,
};
describe("closeGroupPullRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
});
it("closes an open PR via the gh-CLI backend", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any);
// First getPrStatus returns open, then close, then getPrStatus returns closed.
mockRunGhJsonAsync
.mockResolvedValueOnce(ghPrViewOpen as any)
.mockResolvedValueOnce({ ...ghPrViewOpen, state: "CLOSED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber });
expect(result.prState).toBe("closed");
const closeArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "close")?.[0];
expect(closeArgs).toEqual(expect.arrayContaining(["pr", "close", "42"]));
});
it("reconciles (no close) when the PR is already merged out-of-band", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber });
expect(result.prState).toBe("merged");
expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined();
});
});

View File

@@ -1,131 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(() => true),
isGhAuthenticated: vi.fn(() => true),
runGh: vi.fn(),
runGhAsync: vi.fn(),
runGhJson: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))),
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
};
});
import { runGh, runGhJsonAsync } from "@fusion/core";
import { GitHubClient, createGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody } from "../github.js";
const mockRunGh = vi.mocked(runGh);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const group = {
id: "BG-1",
branchName: "fusion/groups/planning-x",
sourceType: "planning" as const,
sourceId: "PS-1",
};
const members = [
{ id: "FN-A", title: "Alpha" },
{ id: "FN-B", title: "Beta" },
];
describe("createGroupPullRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a PR via the gh-CLI backend and returns persisted shape", async () => {
// findPrForBranch (gh): no existing PR.
mockRunGhJsonAsync.mockResolvedValueOnce([] as any);
// createPr (gh): returns the PR url on stdout.
mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/55\n");
const client = new GitHubClient({ forceMode: "gh-cli" });
const result = await createGroupPullRequest(client, {
group,
members,
headBranch: group.branchName,
baseBranch: "main",
});
expect(result).toEqual({
prNumber: 55,
prUrl: "https://github.com/owner/repo/pull/55",
prState: "open",
});
const createArgs = mockRunGh.mock.calls[0][0];
expect(createArgs).toEqual(expect.arrayContaining(["pr", "create", "--head", group.branchName, "--base", "main"]));
});
it("creates a PR via the REST API backend and returns persisted shape", async () => {
const client = new GitHubClient({ token: "ghp_token", forceMode: "token" });
const fetchSpy = vi.spyOn(global, "fetch" as any)
// findPrForBranch (API): empty list.
.mockResolvedValueOnce({ ok: true, json: async () => [] } as any)
// createPr (API).
.mockResolvedValueOnce({
ok: true,
json: async () => ({
number: 77,
html_url: "https://github.com/owner/repo/pull/77",
title: "T",
state: "open",
head: { ref: group.branchName },
base: { ref: "main" },
comments: 0,
}),
} as any);
const result = await createGroupPullRequest(client, {
group,
members,
headBranch: group.branchName,
baseBranch: "main",
});
expect(result).toEqual({
prNumber: 77,
prUrl: "https://github.com/owner/repo/pull/77",
prState: "open",
});
fetchSpy.mockRestore();
});
it("reuses an existing open PR instead of creating a second one (idempotent)", async () => {
mockRunGhJsonAsync.mockResolvedValueOnce([
{ number: 12, url: "https://github.com/owner/repo/pull/12", title: "T", state: "OPEN", baseRefName: "main", headRefName: group.branchName, mergedAt: null },
] as any);
const client = new GitHubClient({ forceMode: "gh-cli" });
const result = await createGroupPullRequest(client, {
group,
members,
headBranch: group.branchName,
baseBranch: "main",
});
expect(result).toEqual({
prNumber: 12,
prUrl: "https://github.com/owner/repo/pull/12",
prState: "open",
});
// createPr must NOT have been called.
expect(mockRunGh).not.toHaveBeenCalled();
});
});
describe("group PR title/body builders", () => {
it("title includes the group id, source, and member count", () => {
expect(buildGroupPullRequestTitle(group, members)).toBe("BG-1: planning/PS-1 (2 tasks)");
});
it("body lists every member task", () => {
const body = buildGroupPullRequestBody(group, members);
expect(body).toContain("Automated group PR for BG-1.");
expect(body).toContain("- FN-A: Alpha");
expect(body).toContain("- FN-B: Beta");
});
});

View File

@@ -1,180 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(() => true),
isGhAuthenticated: vi.fn(() => true),
runGh: vi.fn(),
runGhAsync: vi.fn(),
runGhJson: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))),
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
};
});
import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { GitHubClient, syncGroupPullRequest, closeGroupPullRequest } from "../github.js";
const mockRunGh = vi.mocked(runGh);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const group = {
id: "BG-1",
branchName: "fusion/groups/planning-x",
sourceType: "planning" as const,
sourceId: "PS-1",
prNumber: 42,
};
const members = [
{ id: "FN-A", title: "Alpha" },
{ id: "FN-B", title: "Beta" },
];
const ghPrViewOpen = {
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "T",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: group.branchName,
};
describe("syncGroupPullRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
});
it("edits the PR body via the gh-CLI backend when the PR is open", async () => {
// getPrStatus (gh view): open. updatePr→getPrStatus (gh view): open again.
mockRunGhJsonAsync.mockResolvedValue(ghPrViewOpen as any);
const client = new GitHubClient({ forceMode: undefined as never });
// Force gh-auth path by relying on mocked isGhAvailable/isGhAuthenticated.
const result = await syncGroupPullRequest(client, { group, members });
expect(result).toEqual({
prNumber: 42,
prUrl: "https://github.com/owner/repo/pull/42",
prState: "open",
});
// pr edit was invoked with the group's PR number and a body.
const editArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "edit")?.[0];
expect(editArgs).toBeDefined();
expect(editArgs).toEqual(expect.arrayContaining(["pr", "edit", "42", "--body"]));
});
it("edits the PR body via the REST API backend when the PR is open", async () => {
// Force the API path: gh CLI unavailable so getPrStatus/updatePr use REST.
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
const client = new GitHubClient({ token: "ghp_token", forceMode: "token" });
const fetchSpy = vi.spyOn(global, "fetch" as any)
// getPrStatus (API): open.
.mockResolvedValueOnce({
ok: true,
json: async () => ({
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "T",
state: "open",
merged: false,
head: { ref: group.branchName },
base: { ref: "main" },
comments: 0,
updated_at: "2026-06-03T00:00:00Z",
}),
} as any)
// updatePr (API PATCH).
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any)
// updatePr→getPrStatus (API): open.
.mockResolvedValueOnce({
ok: true,
json: async () => ({
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "T2",
state: "open",
merged: false,
head: { ref: group.branchName },
base: { ref: "main" },
comments: 0,
updated_at: "2026-06-03T00:00:01Z",
}),
} as any);
const result = await syncGroupPullRequest(client, { group, members });
expect(result.prState).toBe("open");
expect(result.prNumber).toBe(42);
// PATCH was sent with a body containing the completion checklist.
const patchCall = fetchSpy.mock.calls.find((c) => (c[1] as any)?.method === "PATCH");
expect(patchCall).toBeDefined();
fetchSpy.mockRestore();
});
it("reconciles (no edit) when the PR is closed out-of-band on GitHub", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await syncGroupPullRequest(client, { group, members });
expect(result.prState).toBe("closed");
// pr edit must NOT be invoked when the PR is already terminal.
expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined();
});
it("reconciles to merged (no edit) when the PR is merged out-of-band", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await syncGroupPullRequest(client, { group, members });
expect(result.prState).toBe("merged");
expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined();
});
it("throws when the group has no persisted prNumber", async () => {
const client = new GitHubClient({ forceMode: undefined as never });
await expect(
syncGroupPullRequest(client, { group: { ...group, prNumber: null as never }, members }),
).rejects.toThrow(/no persisted prNumber/);
});
});
describe("closeGroupPullRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
});
it("closes an open PR via the gh-CLI backend", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any);
// First getPrStatus returns open, then close, then getPrStatus returns closed.
mockRunGhJsonAsync
.mockResolvedValueOnce(ghPrViewOpen as any)
.mockResolvedValueOnce({ ...ghPrViewOpen, state: "CLOSED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber });
expect(result.prState).toBe("closed");
const closeArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "close")?.[0];
expect(closeArgs).toEqual(expect.arrayContaining(["pr", "close", "42"]));
});
it("reconciles (no close) when the PR is already merged out-of-band", async () => {
mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any);
const client = new GitHubClient({ forceMode: undefined as never });
const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber });
expect(result.prState).toBe("merged");
expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined();
});
});

View File

@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { BranchGroup, BranchGroupPrState, DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, Task, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import type { BranchGroup, BranchGroupPrState, DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import {
isGhAvailable,
isGhAuthenticated,
@@ -3841,133 +3841,12 @@ function prInfoToBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState {
return "open";
}
/** Build the title for a single managed group PR. */
export function buildGroupPullRequestTitle(
group: Pick<BranchGroup, "id" | "sourceType" | "sourceId">,
members: Pick<Task, "id">[],
): string {
return `${group.id}: ${group.sourceType}/${group.sourceId} (${members.length} tasks)`;
}
/** Build the body for a single managed group PR (member checklist + completion). */
export function buildGroupPullRequestBody(
group: Pick<BranchGroup, "id" | "branchName" | "sourceType" | "sourceId">,
members: Pick<Task, "id" | "title">[],
): string {
const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"}`);
return [
`Automated group PR for ${group.id}.`,
`Source: ${group.sourceType}/${group.sourceId}`,
`Integration branch: \`${group.branchName}\``,
"",
"Included tasks:",
...(lines.length > 0 ? lines : ["- (none)"]),
].join("\n");
}
export interface CreateGroupPrInput {
group: Pick<BranchGroup, "id" | "branchName" | "sourceType" | "sourceId">;
members: Pick<Task, "id" | "title">[];
/** Head branch — the group integration branch. */
headBranch: string;
/** Base branch — the project default / integration target. */
baseBranch: string;
}
export interface CreateGroupPrResult {
prNumber: number;
prUrl: string;
prState: BranchGroupPrState;
}
/**
* Create (or reuse) the single managed GitHub PR for a branch group.
*
* Idempotency: if an existing PR is already open for the group head branch on
* GitHub, it is reused rather than opening a second one. This is the GitHub-side
* idempotency guard; the coordinator additionally checks the persisted
* `prNumber` before ever calling this helper.
*
* Backend parity: dispatches through `GitHubClient.findPrForBranch` /
* `GitHubClient.createPr`, which transparently use the `gh` CLI when available
* and fall back to the REST API, so both paths produce the same result shape.
*/
export async function createGroupPullRequest(
github: Pick<GitHubClient, "findPrForBranch" | "createPr">,
input: CreateGroupPrInput,
): Promise<CreateGroupPrResult> {
const existing = await github.findPrForBranch({ head: input.headBranch, state: "all" });
if (existing) {
return {
prNumber: existing.number,
prUrl: existing.url,
prState: prInfoToBranchGroupPrState(existing),
};
}
const created = await github.createPr({
title: buildGroupPullRequestTitle(input.group, input.members),
body: buildGroupPullRequestBody(input.group, input.members),
head: input.headBranch,
base: input.baseBranch,
});
return {
prNumber: created.number,
prUrl: created.url,
prState: prInfoToBranchGroupPrState(created),
};
}
export interface SyncGroupPrInput {
group: Pick<BranchGroup, "id" | "branchName" | "sourceType" | "sourceId" | "prNumber">;
members: Pick<Task, "id" | "title">[];
}
/**
* Push an updated title/body onto the single managed group PR (U6, R6).
*
* The body always reflects the *full* current member state (checklist +
* completion summary), so repeated calls are idempotent body rewrites — each
* landing pushes the latest state and naturally coalesces with the previous one;
* no queue is needed (KTD4: idempotency anchors on the persisted `prNumber`).
*
* Out-of-band reconciliation: if the persisted PR is no longer open on GitHub
* (closed/merged out-of-band), this does NOT re-open or edit it — it returns the
* reconciled `prState` so the caller can persist it instead of erroring.
*
* Backend parity: dispatches through `GitHubClient.getPrStatus` / `updatePr`,
* which use the `gh` CLI when available and fall back to the REST API.
*/
export async function syncGroupPullRequest(
github: Pick<GitHubClient, "getPrStatus" | "updatePr">,
input: SyncGroupPrInput,
): Promise<CreateGroupPrResult> {
const prNumber = input.group.prNumber;
if (prNumber == null) {
throw new Error(`syncGroupPullRequest: group ${input.group.id} has no persisted prNumber`);
}
const { owner, repo } = getCurrentRepoOrThrow();
const current = await github.getPrStatus(owner, repo, prNumber);
const currentState = prInfoToBranchGroupPrState(current);
// Out-of-band terminal state: do not re-open or edit a closed/merged PR.
if (currentState !== "open") {
return { prNumber: current.number, prUrl: current.url, prState: currentState };
}
const updated = await github.updatePr({
number: prNumber,
title: buildGroupPullRequestTitle(input.group, input.members),
body: buildGroupPullRequestBody(input.group, input.members),
});
return {
prNumber: updated.number,
prUrl: updated.url,
prState: prInfoToBranchGroupPrState(updated),
};
}
/**
* Close the single managed group PR (U6, R7) — best-effort terminal
* reconciliation when a branch group is abandoned. If the PR is already

View File

@@ -11,7 +11,7 @@ export {
type RuntimeLogSink,
} from "./runtime-logger.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, createGroupPullRequest, syncGroupPullRequest, closeGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrInput, type CreateGroupPrResult, type SyncGroupPrInput } from "./github.js";
export { GitHubClient, isPrMergeReady, closeGroupPullRequest, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js";
export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js";
export {