From 928b14ae1b65369f4b763cb4f1b478bcdd0b8eeb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 11:00:16 -0700 Subject: [PATCH] refactor(FN-branch-group): remove dead cross-unit group-PR helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cli/src/commands/__tests__/daemon.test.ts | 1 - .../cli/src/commands/__tests__/serve.test.ts | 1 - .../commands/__tests__/task-lifecycle.test.ts | 26 --- packages/cli/src/commands/task-lifecycle.ts | 77 ++++---- .../__tests__/github-close-group-pr.test.ts | 74 +++++++ .../__tests__/github-create-group-pr.test.ts | 131 ------------- .../__tests__/github-sync-group-pr.test.ts | 180 ------------------ packages/dashboard/src/github.ts | 123 +----------- packages/dashboard/src/index.ts | 2 +- 9 files changed, 110 insertions(+), 505 deletions(-) create mode 100644 packages/dashboard/src/__tests__/github-close-group-pr.test.ts delete mode 100644 packages/dashboard/src/__tests__/github-create-group-pr.test.ts delete mode 100644 packages/dashboard/src/__tests__/github-sync-group-pr.test.ts diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 3ed047db2f..1d5aeb5e2f 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -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", () => ({ diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index b2898816e3..a490c51324 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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", () => ({ diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index b812ef9908..685a1449f2 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -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(); - }); -}); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 609d62df8d..4f9fccf619 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -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, members: Array & { branchName: string }>, + options?: { checklist?: boolean; landed?: (member: Pick & { 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, -): 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 { try { const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 }); diff --git a/packages/dashboard/src/__tests__/github-close-group-pr.test.ts b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts new file mode 100644 index 0000000000..5920708646 --- /dev/null +++ b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@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(); + }); +}); diff --git a/packages/dashboard/src/__tests__/github-create-group-pr.test.ts b/packages/dashboard/src/__tests__/github-create-group-pr.test.ts deleted file mode 100644 index fdc1c0f11f..0000000000 --- a/packages/dashboard/src/__tests__/github-create-group-pr.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@fusion/core", async () => { - const actual = await vi.importActual("@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"); - }); -}); diff --git a/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts b/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts deleted file mode 100644 index 2ec6403a7c..0000000000 --- a/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@fusion/core", async () => { - const actual = await vi.importActual("@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(); - }); -}); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 451926b3a4..f18af7865d 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -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, - members: Pick[], -): 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, - members: Pick[], -): 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; - members: Pick[]; - /** 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, - input: CreateGroupPrInput, -): Promise { - 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; - members: Pick[]; -} - -/** - * 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, - input: SyncGroupPrInput, -): Promise { - 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 diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 14754a1292..91098aad06 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -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 {