// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:5233-5528. import { repoParts, resolveToken } from "./auth-and-safety"; import { authStatus } from "./auth-pr-read"; import { authRequired, commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition, validationError } from "./client"; import { isRecord } from "./notify-claudeqq"; import { runPrMergeCloseout } from "./pr-merge-closeout"; import { compactAuthCapability, deleteHeadBranchAfterMerge, loadPrMergeUnknownRetryConfig, mergeabilityHasUnknownPending, nextPrMergeRetryDelayMs, prCloseoutMetadata, prCloseoutSummary, preflightPullRequestSummary, prGraphqlMetadata, prMergeRetryCommand, prMetadataSummary, prPreflightPolicy, prSummary, sleepMs, statusRollupSummary } from "./pr-summary"; import { ghShort, ghTable, ghText } from "./render"; import { UNIDESK_CLI_CONFIG_PATH } from "./types"; import type { GitHubCommandResult, GitHubErrorPayload, GitHubOptions, GitHubPullRequest, GitHubPullRequestGraphqlMetadata, PrMergeUnknownRetryConfig } from "./types"; export async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise { const { owner, name } = repoParts(repo); let retryConfig: PrMergeUnknownRetryConfig; try { retryConfig = loadPrMergeUnknownRetryConfig(); } catch (error) { return validationError("pr merge", repo, error instanceof Error ? error.message : String(error), { number, configPath: UNIDESK_CLI_CONFIG_PATH }); } const pr = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" }); const summary = prSummary(pr); if (summary.merged === true) { const branchDeletion = options.deleteBranch && !options.dryRun ? await deleteHeadBranchAfterMerge(repo, token, pr) : { attempted: false, skippedReason: options.deleteBranch ? "dry-run" : "keep-branch-requested" }; const closeout = runPrMergeCloseout(repo, pr, options); return withPrMergeRendered({ ok: true, command: "pr merge", repo, number, method: options.mergeMethod, deleteBranch: options.deleteBranch, alreadyMerged: true, pullRequest: summary, branchDeletion, closeout, rest: true, }); } let metadata: GitHubPullRequestGraphqlMetadata | GitHubErrorPayload | null = null; let statusChecks: Record = {}; let mergeability: Record = {}; let delayMs = retryConfig.initialDelayMs; const retryAttempts: Array> = []; for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt += 1) { metadata = await prGraphqlMetadata(repo, token, number); if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, retry: { attempt, maxAttempts: retryConfig.maxAttempts } }); statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false); mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks); const shouldRetryUnknown = mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability); const waitMs = shouldRetryUnknown && attempt < retryConfig.maxAttempts ? delayMs : 0; retryAttempts.push({ attempt, maxAttempts: retryConfig.maxAttempts, mergeable: metadata.mergeable ?? null, mergeStateStatus: metadata.mergeStateStatus ?? null, statusCheckRollup: metadata.statusCheckRollup?.state ?? null, conclusion: mergeability.conclusion ?? null, blockers: Array.isArray(mergeability.blockers) ? mergeability.blockers : [], pending: Array.isArray(mergeability.pending) ? mergeability.pending : [], waitMs, }); if (mergeability.readyForCommanderMerge === true || !shouldRetryUnknown || attempt >= retryConfig.maxAttempts) break; await sleepMs(delayMs); delayMs = nextPrMergeRetryDelayMs(retryConfig, delayMs); } if (metadata === null || isGitHubError(metadata)) return commandError("pr merge", repo, metadata ?? errorPayload("invalid-response", "PR merge metadata was not collected"), { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary }); const retry = { reason: "github-mergeability-unknown", maxAttempts: retryConfig.maxAttempts, attempts: retryAttempts.length, exhausted: mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability) && retryAttempts.length >= retryConfig.maxAttempts, config: retryConfig, attemptsLog: retryAttempts, }; if (mergeability.readyForCommanderMerge !== true) { const details = { number, method: options.mergeMethod, deleteBranch: options.deleteBranch, pullRequest: preflightPullRequestSummary(summary), mergeability, statusChecks, closeoutMetadata: prCloseoutMetadata(metadata), retry, }; return withPrMergeRendered(validationError("pr merge", repo, retry.exhausted ? "PR mergeability is still UNKNOWN after automatic retry; no merge performed" : "PR is not ready for merge; blockers or pending states remain", details), details); } if (options.dryRun) { return withPrMergeRendered({ ok: true, command: "pr merge", repo, number, dryRun: true, wouldMerge: true, method: options.mergeMethod, deleteBranch: options.deleteBranch, pullRequest: preflightPullRequestSummary(summary), mergeability, statusChecks, retry, closeout: runPrMergeCloseout(repo, pr, options), }); } const merged = await githubRequest>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, { merge_method: options.mergeMethod, }); if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary }); const after = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged }); const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "keep-branch-requested" }; const closeout = runPrMergeCloseout(repo, after, options); return withPrMergeRendered({ ok: true, command: "pr merge", repo, number, method: options.mergeMethod, deleteBranch: options.deleteBranch, mergeResult: merged, pullRequest: prSummary(after), mergeability, statusChecks, retry, branchDeletion, closeout, rest: true, }); } export function withPrMergeRendered(result: GitHubCommandResult, fallbackDetails?: Record): GitHubCommandResult { return { ...result, contentType: "text/plain", renderedText: renderPrMergeTable(result, fallbackDetails), }; } export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?: Record): string { const details = fallbackDetails ?? {}; const pullRequest = isRecord(result.pullRequest) ? result.pullRequest : isRecord(details.pullRequest) ? details.pullRequest : {}; const mergeability = isRecord(result.mergeability) ? result.mergeability : isRecord(details.mergeability) ? details.mergeability : {}; const statusChecks = isRecord(result.statusChecks) ? result.statusChecks : isRecord(details.statusChecks) ? details.statusChecks : {}; const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {}; const closeout = isRecord(result.closeout) ? result.closeout : {}; const localWorktree = isRecord(closeout.localWorktree) ? closeout.localWorktree : {}; const mainWorktree = isRecord(closeout.mainWorktree) ? closeout.mainWorktree : {}; const nodeSyncs = Array.isArray(closeout.nodeSyncs) ? closeout.nodeSyncs.filter(isRecord) : []; const retry = isRecord(result.retry) ? result.retry : isRecord(details.retry) ? details.retry : {}; const mergeMethod = result.method ?? result.mergeMethod ?? details.method; const deleteBranch = result.deleteBranch ?? details.deleteBranch; const mergeResult = isRecord(result.mergeResult) ? result.mergeResult : {}; const mergeCommitValue = pullRequest.mergeCommit; const mergeCommit = isRecord(mergeCommitValue) ? mergeCommitValue.oid : mergeCommitValue ?? mergeResult.sha; const mergedAt = pullRequest.mergedAt; const status = result.ok === true ? result.alreadyMerged === true ? "already-merged" : result.dryRun === true ? "dry-run" : "merged" : ghText(mergeability.conclusion) === "pending" ? "pending" : "blocked"; const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; const rows = [[ `#${ghText(result.number ?? details.number)}`, status, ghText(mergeMethod), ghText(mergeability.mergeable), ghText(mergeability.mergeStateStatus), retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)}${retry.exhausted === true ? " exhausted" : ""}` : "-", `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, ]]; const lines = [ `gh pr merge (${status})`, "", ghTable(["PR", "STATUS", "METHOD", "MERGEABLE", "MERGE_STATE", "RETRY", "CHECKS"], rows), "", "Summary:", ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, ` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`, ` mergeCommit=${ghText(mergeCommit)} mergedAt=${ghText(mergedAt)}`, ` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`, ` closeout localWorktree=${closeoutCell(localWorktree)} mainWorktree=${closeoutCell(mainWorktree)} nodeSyncs=${nodeSyncs.length === 0 ? "-" : nodeSyncs.map(closeoutCell).join(",")}`, "", "Next:", ]; if (result.ok === true && result.dryRun !== true) { lines.push(" no follow-up required; merge facts are included in Summary."); } else { lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`); lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`); } lines.push("", "Disclosure:"); lines.push(" default view is a bounded table; use --full or --raw on read/preflight commands for structured metadata."); return lines.join("\n"); } function closeoutCell(value: Record): string { if (value.planned === true) return "planned"; if (value.skipped === true) return `skipped:${ghText(value.skippedReason)}`; if (value.ok === true) return "ok"; if (value.ok === false) return `failed:${ghText(value.degradedReason)}`; return "-"; } export async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise { const auth = await authStatus(repo); const authCapability = compactAuthCapability(auth); const policy = prPreflightPolicy(repo, number); if (auth.ok === false) { return { ok: false, command: commandName, repo, number, degradedReason: auth.degradedReason, runnerDisposition: auth.runnerDisposition, authCapability, policy, details: auth, }; } const { token, probe } = resolveToken(repo, true); const missing = authRequired(repo, commandName, probe); if (missing !== null || token === null) { const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); return { ...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))), command: commandName, number, authCapability, policy, } as GitHubCommandResult; } const { owner, name } = repoParts(repo); const pr = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, authCapability, policy }); const metadata = await prGraphqlMetadata(repo, token, number); const summary = prSummary(pr); const pullRequest = preflightPullRequestSummary(summary); if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy }); const metadataSummary = prMetadataSummary(metadata); const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw); const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks); const result: GitHubCommandResult = { ok: true, command: commandName, canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`, aliases: ["bun scripts/cli.ts gh preflight ", "bun scripts/cli.ts gh pr closeout "], repo, number, readOnly: true, writesRemote: false, valuesPrinted: false, authCapability, pullRequest, mergeability: { mergeable: metadataSummary.mergeable, mergeStateStatus: metadataSummary.mergeStateStatus, ...closeout, }, statusChecks, policy, ...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}), }; if (includeRaw) return result; return { ...result, contentType: "text/plain", renderedText: renderPrPreflightTable(result), }; } export function renderPrPreflightTable(result: GitHubCommandResult): string { const pullRequest = isRecord(result.pullRequest) ? result.pullRequest : {}; const mergeability = isRecord(result.mergeability) ? result.mergeability : {}; const statusChecks = isRecord(result.statusChecks) ? result.statusChecks : {}; const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; const policy = isRecord(result.policy) ? result.policy : {}; const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; const conclusion = ghText(mergeability.conclusion); const rows = [[ `#${ghText(result.number)}`, ghText(pullRequest.stateDetail ?? pullRequest.state), ghText(mergeability.mergeable), ghText(mergeability.mergeStateStatus), `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, conclusion, ]]; const lines = [ `gh pr preflight (${conclusion})`, "", ghTable(["PR", "STATE", "MERGEABLE", "MERGE_STATE", "CHECKS", "CONCLUSION"], rows), "", "Summary:", ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, ` head=${ghText(isRecord(pullRequest.head) ? pullRequest.head.ref : null)} base=${ghText(isRecord(pullRequest.base) ? pullRequest.base.ref : null)}`, ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, "", "Next:", ]; const mergeCommand = ghText(policy.mergeCommand); if (mergeability.readyForCommanderMerge === true && mergeCommand !== "-") { lines.push(` ${mergeCommand}`); } lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number)} --repo ${ghText(result.repo)} --full`); lines.push("", "Disclosure:"); lines.push(" default view is a bounded table; use --full or --raw for structured PR/check metadata."); return lines.join("\n"); }