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