// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:4730-5231. import path from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety"; import { errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, runnerDisposition } from "./client"; import { issueRead } from "./issue-read"; import { isRecord } from "./notify-claudeqq"; import { prState } from "./pr-commands"; import { ghText } from "./render"; import { PR_CLOSEOUT_JSON_FIELDS, UNIDESK_CLI_CONFIG_PATH } from "./types"; import type { GitHubCommandResult, GitHubErrorPayload, GitHubPullRequest, GitHubPullRequestFile, GitHubPullRequestGraphqlMetadata, GitHubPullRequestGraphqlStatusCheckRollup, GitHubPullRequestGraphqlStatusContext, PrMergeUnknownRetryConfig } from "./types"; export function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" { if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged"; return pr.state === "closed" ? "closed" : "open"; } export function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record { const stateDetail = prStateDetail(pr); const closedAt = pr.closed_at ?? pr.merged_at ?? null; const mergedAt = pr.merged_at ?? null; const mergeCommitSha = pr.merge_commit_sha ?? null; const body = pr.body ?? ""; const includeBody = options.includeBody ?? true; const includePreview = options.includePreview ?? true; const summary: Record = { id: pr.id, number: pr.number, title: pr.title, state: pr.state, stateDetail, draft: pr.draft ?? false, url: pr.html_url, author: pr.user?.login ?? null, head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, headRefName: pr.head?.ref ?? null, baseRefName: pr.base?.ref ?? null, createdAt: pr.created_at ?? null, updatedAt: pr.updated_at ?? null, closed: stateDetail !== "open", closedAt, merged: stateDetail === "merged", mergedAt, mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null, bodyChars: body.length, bodySha: bodySha(body), }; if (includeBody) { summary.body = body; } else { summary.bodyOmitted = true; summary.fullBodyIncluded = false; if (includePreview) { summary.bodyPreview = preview(body); summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); } } return summary; } export function numberOrNull(value: number | undefined): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export function prCompactSummary(pr: GitHubPullRequest): Record { return { id: pr.id, number: pr.number, title: pr.title, state: pr.state, draft: pr.draft ?? false, url: pr.html_url, author: pr.user?.login ?? null, head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, createdAt: pr.created_at ?? null, updatedAt: pr.updated_at ?? null, }; } export function prFileSummary(file: GitHubPullRequestFile): Record { return { filename: file.filename, status: file.status ?? null, additions: numberOrNull(file.additions), deletions: numberOrNull(file.deletions), changes: numberOrNull(file.changes), previousFilename: file.previous_filename ?? null, sha: file.sha ?? null, blobUrl: file.blob_url ?? null, contentsUrl: file.contents_url ?? null, }; } export function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } { return files.reduce((accumulator, file) => { accumulator.additions += file.additions ?? 0; accumulator.deletions += file.deletions ?? 0; accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0)); return accumulator; }, { additions: 0, deletions: 0, changes: 0 }); } export function selectedPrJson(summary: Record, fields: readonly string[]): Record { const selected: Record = {}; for (const field of fields) selected[field] = summary[field]; return selected; } export function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record { return { mergeable: metadata.mergeable ?? null, mergeStateStatus: metadata.mergeStateStatus ?? null, statusCheckRollup: metadata.statusCheckRollup ?? null, headRefName: metadata.headRefName ?? null, baseRefName: metadata.baseRefName ?? null, }; } export function prMergeBoundary(): Record { return { runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization"], ordinaryRunnerFinalActionAllowed: true, commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"], unideskCliMergeSupported: true, }; } export function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record { const summary = prMetadataSummary(metadata); const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => { const value = summary[field]; if (value === null || value === undefined) return true; return typeof value === "string" && value.toUpperCase() === "UNKNOWN"; }); return { ok: missingOrUnknownFields.length === 0, source: "github-graphql", missingOrUnknownFields, advice: missingOrUnknownFields.length === 0 ? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action." : "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.", mergeBoundary: prMergeBoundary(), }; } export function prCloseoutMetadataError(error: GitHubErrorPayload): Record { return { ok: false, source: "github-graphql", degradedReason: error.degradedReason, runnerDisposition: error.runnerDisposition, message: error.message, advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.", mergeBoundary: prMergeBoundary(), }; } export function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean { if (fields === undefined) return false; return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup"); } export async function prGraphqlMetadata(repo: string, token: string, number: number): Promise { const { owner, name } = repoParts(repo); const query = ` query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { mergeable mergeStateStatus headRefName baseRefName statusCheckRollup { state contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion } ... on StatusContext { context state targetUrl description } } } } } } } `; const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number }); if (isGitHubError(response)) return response; const pullRequest = response.repository?.pullRequest; if (pullRequest === null || pullRequest === undefined) { return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response }); } return pullRequest; } export function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string { return context.name ?? context.context ?? "(unnamed status check)"; } export function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string { const type = context.__typename ?? "StatusContext"; const status = (context.status ?? "").toUpperCase(); const conclusion = (context.conclusion ?? "").toUpperCase(); const state = (context.state ?? "").toUpperCase(); if (type === "CheckRun") { if (status.length > 0 && status !== "COMPLETED") return "pending"; if (conclusion === "SUCCESS") return "success"; if (conclusion === "NEUTRAL") return "neutral"; if (conclusion === "SKIPPED") return "skipped"; if (conclusion === "CANCELLED") return "cancelled"; if (conclusion === "TIMED_OUT") return "timed-out"; if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure"; return "unknown"; } if (state === "SUCCESS") return "success"; if (state === "PENDING" || state === "EXPECTED") return "pending"; if (state === "FAILURE" || state === "ERROR") return "failure"; return "unknown"; } export function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record { const bucket = statusContextBucket(context); return { name: statusContextName(context), type: context.__typename ?? "StatusContext", bucket, status: context.status ?? null, conclusion: context.conclusion ?? null, state: context.state ?? null, targetUrl: context.targetUrl ?? null, description: context.description === undefined || context.description === null ? null : preview(context.description), }; } export function statusRollupSummary( repo: string, number: number, rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined, includeRaw: boolean, ): Record { const contexts = rollup?.contexts?.nodes ?? []; const compactContexts = contexts.map(compactStatusContext); const counts: Record = { success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0, cancelled: 0, "timed-out": 0, unknown: 0, }; for (const context of compactContexts) { const bucket = String(context.bucket ?? "unknown"); counts[bucket] = (counts[bucket] ?? 0) + 1; } const nonSuccess = compactContexts.filter((context) => context.bucket !== "success"); const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out"); const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown"); const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped"); return { state: rollup?.state ?? null, totalContexts: compactContexts.length, counts, failingContexts: failing.slice(0, 10), pendingContexts: pending.slice(0, 10), neutralContexts: neutral.slice(0, 10), nonSuccessPreview: nonSuccess.slice(0, 12), omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12), rawOmitted: !includeRaw, ...(includeRaw ? { contexts: compactContexts, raw: rollup ?? null } : { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }), }; } export function compactProbeOk(value: unknown): boolean | null { if (value === "ok") return true; if (value === "skipped") return null; if (isRecord(value) && value.ok === true) return true; if (isRecord(value) && value.ok === false) return false; return null; } export function compactAuthCapability(auth: GitHubCommandResult): Record { const token = isRecord(auth.token) ? auth.token : {}; const gh = isRecord(auth.gh) ? auth.gh : {}; const probes = isRecord(auth.probes) ? auth.probes : {}; return { ok: auth.ok === true, degradedReason: auth.ok === false ? auth.degradedReason ?? null : null, runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null, tokenPresent: token.present === true, tokenSource: typeof token.source === "string" ? token.source : null, ghFallbackAttempted: token.ghFallbackAttempted === true, ghBinaryFound: gh.binaryFound === true, restApi: compactProbeOk(probes.restApi), repoVisible: compactProbeOk(probes.repo), issueRead: compactProbeOk(probes.issueRead), valuesPrinted: false, }; } export function prPreflightPolicy(repo: string, number: number): Record { return { readOnly: true, writesRemote: false, createsPr: false, comments: false, mergesPr: false, mergeCommandSupported: true, mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`, unideskCliMergeSupported: true, ordinaryRunnerFinalActionAllowed: true, note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks for diagnosis; gh pr merge performs its own guarded readiness check and does not require this preflight step.", }; } export function preflightPullRequestSummary(summary: Record): Record { return { number: summary.number ?? null, title: summary.title ?? null, state: summary.state ?? null, stateDetail: summary.stateDetail ?? null, draft: summary.draft ?? null, url: summary.url ?? null, author: summary.author ?? null, head: summary.head ?? null, base: summary.base ?? null, headRefName: summary.headRefName ?? null, baseRefName: summary.baseRefName ?? null, closed: summary.closed ?? null, merged: summary.merged ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? null, bodyOmitted: true, }; } export function prCloseoutSummary( pr: Record, metadata: Record, statusChecks: Record, ): Record { const blockers: string[] = []; const pending: string[] = []; const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : ""; if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`); if (pr.draft === true) blockers.push("draft:true"); const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null; if (mergeable === "MERGEABLE") { // Ready signal, combined with mergeStateStatus and checks below. } else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") { blockers.push(`mergeable:${mergeable}`); } else { pending.push(`mergeable:${mergeable ?? "unknown"}`); } const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null; if (mergeStateStatus === "CLEAN") { // Ready signal. } else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) { pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`); } else { blockers.push(`mergeStateStatus:${mergeStateStatus}`); } const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null; const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0; const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0); const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0); if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`); if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`); if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) { // Ready signal. } else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) { pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`); } else if (rollupState !== null) { blockers.push(`statusCheckRollup:${rollupState}`); } const readyForCommanderMerge = blockers.length === 0 && pending.length === 0; return { readyForCommanderMerge, conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending", blockers, pending, commanderAction: readyForCommanderMerge ? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow" : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", }; } export function stripYamlComment(value: string): string { const index = value.indexOf("#"); return (index >= 0 ? value.slice(0, index) : value).trim(); } export function yamlScalarByPath(text: string, keyPath: string[]): string | null { const stack: Array<{ indent: number; key: string }> = []; for (const line of text.split(/\r?\n/u)) { if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/u.exec(line); if (match === null) continue; const indent = match[1].length; while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop(); const key = match[2]; const rawValue = stripYamlComment(match[3] ?? ""); const currentPath = [...stack.map((item) => item.key), key]; if (currentPath.length === keyPath.length && currentPath.every((item, index) => item === keyPath[index])) { return rawValue.length === 0 ? null : rawValue; } stack.push({ indent, key }); } return null; } export function yamlNumberByPath(text: string, keyPath: string[], configPath: string): number { const value = yamlScalarByPath(text, keyPath); if (value === null) throw new Error(`${configPath} missing numeric config ${keyPath.join(".")}`); const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${configPath} config ${keyPath.join(".")} must be a positive number`); return parsed; } export function loadPrMergeUnknownRetryConfig(): PrMergeUnknownRetryConfig { const configPath = path.resolve(process.cwd(), UNIDESK_CLI_CONFIG_PATH); if (!existsSync(configPath)) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} is required for gh pr merge retry policy`); const text = readFileSync(configPath, "utf8"); const prefix = ["github", "prMerge", "unknownRetry"]; const config = { maxAttempts: Math.trunc(yamlNumberByPath(text, [...prefix, "maxAttempts"], UNIDESK_CLI_CONFIG_PATH)), initialDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "initialDelayMs"], UNIDESK_CLI_CONFIG_PATH)), maxDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "maxDelayMs"], UNIDESK_CLI_CONFIG_PATH)), factor: yamlNumberByPath(text, [...prefix, "factor"], UNIDESK_CLI_CONFIG_PATH), }; if (config.maxAttempts < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxAttempts must be >= 1`); if (config.maxDelayMs < config.initialDelayMs) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxDelayMs must be >= initialDelayMs`); if (config.factor < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.factor must be >= 1`); return config; } export function sleepMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export function mergeabilityHasUnknownPending(mergeability: Record): boolean { const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; if (blockers.length > 0) return false; const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map((item) => item.toLowerCase()) : []; return pending.some((item) => item.includes("unknown")); } export function nextPrMergeRetryDelayMs(config: PrMergeUnknownRetryConfig, previousDelayMs: number): number { return Math.min(config.maxDelayMs, Math.max(1, Math.ceil(previousDelayMs * config.factor))); } export function prMergeMethodFlag(method: unknown): string { if (method === "squash") return "--squash"; if (method === "rebase") return "--rebase"; return "--merge"; } export function prMergeRetryCommand(repo: string, number: unknown, method: unknown, deleteBranch: unknown): string { return [ "bun scripts/cli.ts gh pr merge", ghText(number), "--repo", repo, prMergeMethodFlag(method), deleteBranch === true ? "--delete-branch" : "", ].filter((item) => item.length > 0).join(" "); } export async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise> { const { owner, name } = repoParts(repo); const headRepo = pr.head?.repo?.full_name ?? null; const headRef = pr.head?.ref ?? null; if (headRepo !== repo || headRef === null || headRef.length === 0) { return { attempted: false, skippedReason: "head-repo-differs-or-ref-missing", headRepo, headRef, }; } const encodedRef = encodeURIComponent(`heads/${headRef}`); const deleted = await githubRequest(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`); if (isGitHubError(deleted)) { return { attempted: true, ok: false, headRepo, headRef, error: deleted, }; } return { attempted: true, ok: true, headRepo, headRef, }; }