// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:1557-1667. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readIssueCommentBody } from "./body-input"; import { PREVIEW_CHARS } from "./types"; import type { GitHubOptions, GitHubTokenProbe } from "./types"; export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { if (options.comment === undefined && options.commentFile === undefined) return null; if (options.comment !== undefined && options.commentFile !== undefined) { throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`); } if (options.body !== undefined || options.bodyFile !== undefined) { throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`); } if (options.commentFile !== undefined) { return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile }); } return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined }); } export function tokenFromEnvironment(): GitHubTokenProbe { if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; } if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) { return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false }; } return { present: false, source: null, ghFallbackAttempted: false }; } export function ghBinaryPath(): string | null { try { const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); return output.length > 0 ? output : null; } catch { return null; } } export function ghAuthToken(): string | null { try { const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); return output.length > 0 ? output : null; } catch { return null; } } export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } { const envProbe = tokenFromEnvironment(); if (envProbe.present) { const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null; return { token, probe: envProbe }; } if (!allowGhFallback) return { token: null, probe: envProbe }; const ghPath = ghBinaryPath(); if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } }; const token = ghAuthToken(); if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } }; return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } }; } export function repoParts(repo: string): { owner: string; name: string } { const [owner, name, extra] = repo.split("/"); if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format"); return { owner, name }; } export function preview(text: string): string { return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text; } export function previewLines(text: string, maxLines = 12): string[] { return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line)); } export function bodySha(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } export function binarySha(data: Buffer): string { return createHash("sha256").update(data).digest("hex"); } export function normalizeExpectedSha(raw: string): string { const value = raw.replace(/^sha256:/iu, "").toLowerCase(); if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:"); return value; } export function shellPollutionEvidence(body: string): string[] { const evidence: string[] = []; if (body.includes("\\n")) evidence.push("literal-backslash-n"); if (body.includes("\\t")) evidence.push("literal-backslash-t"); if (body.includes("\\`")) evidence.push("escaped-backtick"); if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape"); if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return"); return evidence; } export function bodySafetySignals(body: string): Record { const evidence = shellPollutionEvidence(body); return { bodyChars: body.length, bodyTrimmedChars: body.trim().length, bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length, bodySha: bodySha(body), preservesRawNewlines: body.includes("\n"), containsLiteralBackslashN: body.includes("\\n"), containsBackticks: body.includes("`"), containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body), shellPollution: { suspected: evidence.length > 0, evidence, }, }; }