// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:7964-8156. import path from "node:path"; import { ghBinaryPath, repoParts, resolveToken } from "./auth-and-safety"; import { commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition } from "./client"; import { issueRead } from "./issue-read"; import { needsPrGraphqlMetadata, numberOrNull, prCloseoutMetadata, prCloseoutMetadataError, prCompactSummary, prFileSummary, prGraphqlMetadata, prMetadataSummary, prSummary, selectedPrJson, sumPrFileStats } from "./pr-summary"; import { MAX_PR_FILES_LIMIT, PR_LIST_JSON_FIELDS } from "./types"; import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, PrListJsonField, PrListState, PrReadJsonField } from "./types"; export async function authStatus(repo: string): Promise { const ghPath = ghBinaryPath(); const { token, probe } = resolveToken(ghPath !== null); const degraded: GitHubDegradedReason[] = []; if (ghPath === null) degraded.push("missing-binary"); if (!probe.present || token === null) { if (ghPath === null) { return { ok: false, command: "auth status", repo, degradedReason: "missing-binary", degraded, runnerDisposition: runnerDisposition("missing-binary"), gh: { binaryFound: false, path: null }, token: probe, probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, }; } degraded.push("missing-token"); return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: probe }), { degraded, gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, }); } const { owner, name } = repoParts(repo); const api = await githubRequest>(token, "GET", "/rate_limit"); if (isGitHubError(api)) { return commandError("auth status", repo, api, { degraded: [...degraded, api.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: api, repo: "skipped", issueRead: "skipped" }, }); } const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); if (isGitHubError(repoProbe)) { return commandError("auth status", repo, repoProbe, { degraded: [...degraded, repoProbe.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" }, }); } const issueProbe = await githubRequest(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`); if (isGitHubError(issueProbe)) { return commandError("auth status", repo, issueProbe, { degraded: [...degraded, issueProbe.degradedReason], gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe }, }); } return { ok: true, command: "auth status", repo, degraded, gh: { binaryFound: ghPath !== null, path: ghPath }, token: probe, probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, }, restFallback: true, }; } export async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined, noDump = false): Promise { const { owner, name } = repoParts(repo); const prs = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`); if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit }); const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice(); return { ok: true, command: "pr list", repo, ...(noDump ? { noDump: true } : {}), state, limit, count: prs.length, jsonFields: fields, pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)), }; } export async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise { const { owner, name } = repoParts(repo); const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT); const pr = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); const perPage = Math.max(1, Math.min(100, boundedLimit)); const files: GitHubPullRequestFile[] = []; let page = 1; while (files.length < boundedLimit) { const remaining = boundedLimit - files.length; const pageSize = Math.min(perPage, remaining); const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`; const pageFiles = await githubRequest(token, "GET", path); if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length }); files.push(...pageFiles); if (pageFiles.length < pageSize || pageFiles.length === 0) break; page += 1; } const totalFiles = numberOrNull(pr.changed_files); const listedStats = sumPrFileStats(files); const totalAdditions = numberOrNull(pr.additions); const totalDeletions = numberOrNull(pr.deletions); const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null; const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes; const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit; const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT); const nextCommand = truncated ? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}` : `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`; return { ok: true, command: commandName, repo, readOnly: true, rawDiffIncluded: false, pullRequest: prCompactSummary(pr), summary: { files: totalFiles ?? files.length, additions: totalAdditions ?? listedStats.additions, deletions: totalDeletions ?? listedStats.deletions, changes: totalChanges, commits: numberOrNull(pr.commits), source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest", }, files: files.map(prFileSummary), filesReturned: files.length, limit: boundedLimit, truncation: { truncated, requestedLimit: limit, appliedLimit: boundedLimit, returned: files.length, totalFiles, maxLimit: MAX_PR_FILES_LIMIT, }, next: { command: nextCommand, purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.", }, request: { method: "GET", paths: [ `/repos/${owner}/${name}/pulls/${number}`, `/repos/${owner}/${name}/pulls/${number}/files`, ], }, }; } export async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record | null = null): Promise { 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 }); const fullSummary = prSummary(pr); const includeBody = jsonFields === undefined || jsonFields.includes("body"); const summary = includeBody ? fullSummary : prSummary(pr, { includeBody: false, includePreview: false }); const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null; if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) }); const selectionSummary = metadata === null ? fullSummary : { ...fullSummary, ...prMetadataSummary(metadata) }; const selectedJson = jsonFields === undefined ? null : selectedPrJson(selectionSummary, jsonFields); if (includeBody && selectedJson !== null) delete selectedJson.body; return { ok: true, command: commandName, repo, ...(disclosure === null ? {} : { disclosure }), pullRequest: summary, ...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }), ...(jsonFields === undefined ? {} : { jsonFields, json: selectedJson, compatibility: { legacyJsonBodyPath: includeBody ? ".data.pullRequest.body" : null, bodyPath: includeBody ? ".data.pullRequest.body" : null, omittedDuplicateBodyPath: includeBody ? ".data.json.body" : null, duplicateBodyOmitted: includeBody, bodyOmitted: !includeBody, bodyMigrationHint: includeBody ? "Read the full body from .data.pullRequest.body; .data.json.body is intentionally omitted so large bodies are emitted only once." : null, }, }), }; } export async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record | null = null): Promise { return prRead(repo, token, number, jsonFields, "pr view", disclosure); }