// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:2087-2461. import path from "node:path"; import { bodySafetySignals, preview, previewLines } from "./auth-and-safety"; import { isRecord } from "./notify-claudeqq"; import { GITHUB_API, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types"; import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubOptions, GitHubTokenProbe, RunnerDisposition } from "./types"; export function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record, extra: Record = {}): Record { const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update"; const isCommentUpdate = command === "issue comment update" || command === "pr comment update"; const source = String(bodySource.kind ?? "unknown"); const requestBody: Record = { bodyChars: body.length, bodySource, }; if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title; if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels; return { repo, bodySource, source, bodyPreview: preview(body), bodyPreviewLines: previewLines(body), ...bodySafetySignals(body), request: { method: isCommentUpdate ? "PATCH" : "POST", path: isIssueWrite ? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : isCommentUpdate ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" : "/repos/{owner}/{repo}/issues/{issue_number}/comments") : (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : isCommentUpdate ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"), body: requestBody, }, validation: { source, rawText: source === "body-file" ? "read from file bytes without shell interpolation" : source === "stdin" ? "read from stdin bytes" : "short inline argument accepted only by command-specific safety rules", }, ...extra, }; } export function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition { if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed"; return "infra-blocked"; } export function sanitizedErrorDetails(parsed: unknown): unknown { if (typeof parsed !== "object" || parsed === null) return parsed; const source = parsed as Record; const details: Record = {}; if ("message" in source) details.message = source.message; if ("documentation_url" in source) details.documentationUrl = source.documentation_url; if ("status" in source) details.status = source.status; if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5); if ("raw" in source) details.raw = source.raw; return details; } export function errorProperty(value: unknown, key: string): string | null { if (!isRecord(value)) return null; const item = value[key]; return typeof item === "string" && item.length > 0 ? item : null; } export function errorChainText(error: unknown): string { const parts: string[] = []; let current: unknown = error; const seen = new Set(); while (current !== null && current !== undefined && !seen.has(current)) { seen.add(current); if (current instanceof Error) { parts.push(current.name, current.message); current = (current as Error & { cause?: unknown }).cause; continue; } if (isRecord(current)) { for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) { const value = errorProperty(current, key); if (value !== null) parts.push(value); } current = current.cause; continue; } parts.push(String(current)); break; } return parts.join(" ").replace(/\s+/gu, " ").trim(); } export function firstErrorProperty(error: unknown, key: string): string | null { let current: unknown = error; const seen = new Set(); while (current !== null && current !== undefined && !seen.has(current)) { seen.add(current); const value = errorProperty(current, key); if (value !== null) return value; current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null; } return null; } export function classifyGitHubFetchFailure(error: unknown): { reason: GitHubDegradedReason; message: string; retryable?: boolean; commanderAction?: string; details: Record; } { const text = errorChainText(error); const lower = text.toLowerCase(); const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text); const isGitHubTransient = !isProxyFailure && ( /temporary failure in name resolution/iu.test(text) || /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text) || /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text) ); if (!isGitHubTransient) { return { reason: "network-proxy-failed", message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response", details: { scope: "github-api", transient: false, errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), errorCode: firstErrorProperty(error, "code"), host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), }, }; } return { reason: "github-transient", message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response", retryable: true, commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh", details: { scope: "github-api", transient: true, retryable: true, authFailure: false, semanticFailure: false, errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), errorCode: firstErrorProperty(error, "code"), host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work", valuesPrinted: false, }, }; } export function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit = {}): GitHubErrorPayload { return { ok: false, degradedReason: reason, runnerDisposition: runnerDisposition(reason), message, ...extra, }; } export function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record = {}): GitHubCommandResult { return { ok: false, command, repo, degradedReason: error.degradedReason, runnerDisposition: error.runnerDisposition, details: error, ...extra, }; } export function validationError(command: string, repo: string, message: string, extra: Record = {}): GitHubCommandResult { const error = errorPayload("validation-failed", message, { details: extra }); return commandError(command, repo, error, extra); } export function unsupportedCommand(command: string, repo: string, message: string, extra: Record = {}): GitHubCommandResult { const error = errorPayload("unsupported-command", message, { details: extra }); return commandError(command, repo, error, { message, ...extra }); } export async function parseGitHubResponse(response: Response): Promise { const text = await response.text(); if (text.length === 0) return null; try { return JSON.parse(text) as unknown; } catch { return { raw: preview(text) }; } } export function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason { const lower = message.toLowerCase(); const acceptedScopes = response.headers.get("x-accepted-oauth-scopes"); if (status === 401) return "auth-failed"; if (status === 403) { if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient"; if (path.startsWith("/repos/")) return "repo-forbidden"; return "permission-denied"; } if (status === 404) { if (/\/pulls\/\d+/u.test(path)) return "pr-not-found"; if (/\/issues\/\d+/u.test(path)) return "issue-not-found"; if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed"; return "repo-not-found"; } if (status === 422) return "validation-failed"; return "invalid-response"; } export async function githubRequest( token: string, method: string, path: string, body?: Record, ): Promise { let response: Response; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { response = await fetch(`${GITHUB_API}${path}`, { method, signal: controller.signal, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": USER_AGENT, "X-GitHub-Api-Version": "2022-11-28", }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch (error) { const classified = classifyGitHubFetchFailure(error); return errorPayload(classified.reason, classified.message, { request: { method, path }, details: classified.details, retryable: classified.retryable, commanderAction: classified.commanderAction, }); } finally { clearTimeout(timeout); } const parsed = await parseGitHubResponse(response); if (!response.ok) { const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; return errorPayload(classifyHttpStatus(response.status, message, path, response), message, { status: response.status, details: sanitizedErrorDetails(parsed), scopes: { accepted: response.headers.get("x-accepted-oauth-scopes"), token: response.headers.get("x-oauth-scopes"), }, request: { method, path }, }); } return parsed as T; } export async function githubGraphqlRequest( token: string, query: string, variables: Record, ): Promise { let response: Response; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { response = await fetch(`${GITHUB_API}/graphql`, { method: "POST", signal: controller.signal, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": USER_AGENT, "X-GitHub-Api-Version": "2022-11-28", }, body: JSON.stringify({ query, variables }), }); } catch (error) { const classified = classifyGitHubFetchFailure(error); return errorPayload(classified.reason, classified.message, { request: { method: "POST", path: "/graphql" }, details: classified.details, retryable: classified.retryable, commanderAction: classified.commanderAction, }); } finally { clearTimeout(timeout); } const parsed = await parseGitHubResponse(response); if (!response.ok) { const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, { status: response.status, details: sanitizedErrorDetails(parsed), scopes: { accepted: response.headers.get("x-accepted-oauth-scopes"), token: response.headers.get("x-oauth-scopes"), }, request: { method: "POST", path: "/graphql" }, }); } if (!isRecord(parsed)) { return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed }); } if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { const firstError = parsed.errors[0]; const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed"; return errorPayload("validation-failed", message, { details: sanitizedErrorDetails(parsed), request: { method: "POST", path: "/graphql" }, }); } const data = parsed.data; if (!isRecord(data)) { return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) }); } return data as T; } export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null { if (!tokenProbe.present) { if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) { return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); } if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) { return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); } return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), { degraded: ["missing-token"], token: tokenProbe, }); } return null; } export function isGitHubError(value: unknown): value is GitHubErrorPayload { return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value; } export function issueBodyReadCommands(repo: string, issueNumber: number): Record { return { body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`, full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, }; } export function issueCommentReadCommands(repo: string, issueNumber: number): Record { return { comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`, comment: `bun scripts/cli.ts gh issue comment view --repo ${repo} --full`, full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, }; } export function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record { const explicitFullDisclosure = options.raw || options.full; return { defaultCompact: dryRun || !explicitFullDisclosure, explicitFullDisclosure, fullBodyIncluded: explicitFullDisclosure && !dryRun, bodyOmitted: dryRun || !explicitFullDisclosure, dryRunBoundedPreview: dryRun, note: explicitFullDisclosure && !dryRun ? "The returned issue object includes the full body because --full or --raw was explicitly requested." : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.", readCommands: issueBodyReadCommands(repo, issueNumber), }; }