import type { Exec } from "@henryqw/pi-process"; import { inspectWorktree, isRecord, readHead, requiredOid, requiredText, runChecked, } from "./pr-execution.ts"; import type { LocalMergeSafety } from "./pr-routing.ts"; const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}"; export type InspectLocalMergeSafetyInput = { exec: Exec; cwd: string; expectedHead: string; headFetchSource: string; }; export type InspectedLocalMergeSafety = LocalMergeSafety & { headOid: string; }; export type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & { pullRequestId: string; hostname: string; expectedBase: { repository: string; ref: string; oid: string; }; revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise; }; function validateInspectionInput(input: InspectLocalMergeSafetyInput): void { requiredText(input.cwd, "cwd"); requiredOid(input.expectedHead, "expected PR head"); requiredText(input.headFetchSource, "PR head fetch source"); if (typeof input.exec !== "function") throw new TypeError("exec must be a function"); } /** Inspect local state without changing branches, the index, or the worktree. */ export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise { validateInspectionInput(input); const worktree = await inspectWorktree(input.exec, { cwd: input.cwd }); await runChecked(input.exec, "git", [ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", input.headFetchSource, input.expectedHead, ], { cwd: input.cwd }); await runChecked(input.exec, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`], { cwd: input.cwd }); const headOid = await readHead(input.exec, { cwd: input.cwd }); if (headOid === input.expectedHead) return { worktree, head: "equal", headOid }; const localAncestor = await runChecked( input.exec, "git", ["merge-base", "--is-ancestor", headOid, input.expectedHead], { cwd: input.cwd }, [0, 1], ); if (localAncestor.code === 0) return { worktree, head: "behind", headOid }; const expectedAncestor = await runChecked( input.exec, "git", ["merge-base", "--is-ancestor", input.expectedHead, headOid], { cwd: input.cwd }, [0, 1], ); return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid }; } function validateExecuteInput(input: ExecuteGitHubMergeInput): void { validateInspectionInput(input); requiredText(input.pullRequestId, "pullRequestId"); requiredText(input.hostname, "hostname"); if (!isRecord(input.expectedBase)) throw new TypeError("expectedBase must be an object"); requiredText(input.expectedBase.repository, "expected base repository"); requiredText(input.expectedBase.ref, "expected base ref"); requiredText(input.expectedBase.oid, "expected base OID"); if (typeof input.revalidateReadiness !== "function") throw new TypeError("revalidateReadiness must be a function"); } function parseGraphQLResponse(output: string, action: string): Record { let value: unknown; try { value = JSON.parse(output); } catch { throw new Error(`${action}: invalid GraphQL output`); } if (!isRecord(value)) throw new Error(`${action}: invalid GraphQL output`); const errors = value.errors; if (errors !== undefined) { if (!Array.isArray(errors)) throw new Error(`${action}: invalid GraphQL output`); if (errors.length > 0) { const messages = errors.map((error) => isRecord(error) && typeof error.message === "string" && error.message ? error.message : undefined); if (messages.some((message) => message === undefined)) throw new Error(`${action}: invalid GraphQL errors`); throw new Error(`${action}: ${messages.join("; ")}`); } } return value; } function parseMergeResponse(output: string, expectedId: string): void { const value = parseGraphQLResponse(output, "GitHub merge failed"); const data = value.data; const mutation = isRecord(data) ? data.mergePullRequest : undefined; const pullRequest = isRecord(mutation) ? mutation.pullRequest : undefined; if (!isRecord(pullRequest)) throw new Error("GitHub merge failed: invalid GraphQL output"); const id = pullRequest.id; const state = pullRequest.state; if (typeof id !== "string" || !id || typeof state !== "string" || !state) { throw new Error("GitHub merge failed: invalid GraphQL output"); } if (id !== expectedId) throw new Error(`GitHub merge returned unexpected pull request id ${id}`); if (state !== "MERGED") throw new Error(`GitHub merge returned pull request ${id} in state ${state}`); } export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise { validateExecuteInput(input); const local = await inspectLocalMergeSafety(input); if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) { throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`); } await input.revalidateReadiness(local); const finalWorktree = await inspectWorktree(input.exec, { cwd: input.cwd }); if (finalWorktree !== "clean") { throw new Error("Final local merge safety check failed: worktree is dirty"); } const finalHead = await readHead(input.exec, { cwd: input.cwd }); if (finalHead !== local.headOid) { throw new Error(`Final local merge safety check failed: HEAD changed from ${local.headOid} to ${finalHead}`); } const merged = await runChecked(input.exec, "gh", [ "api", "graphql", "--hostname", input.hostname, "-f", `query=${MERGE_PULL_REQUEST_MUTATION}`, "-F", `pullRequestId=${input.pullRequestId}`, "-F", `expectedHeadOid=${input.expectedHead}`, "-F", "mergeMethod=SQUASH", ], { cwd: input.cwd }); const output = merged.stdout.trim(); if (!output) throw new Error("GitHub merge returned no output"); parseMergeResponse(output, input.pullRequestId); }