// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:1346-1555. import path from "node:path"; import { ApplyPatchV2Error, deriveUpdatedContent, parseApplyPatchV2 } from "../apply-patch-v2"; import { existsSync, readFileSync } from "node:fs"; import type { PatchHunk } from "../apply-patch-v2"; import { shellPollutionEvidence } from "./auth-and-safety"; import { isRecord } from "./notify-claudeqq"; import { MAX_INLINE_ISSUE_COMMENT_BODY_CHARS } from "./types"; import type { GitHubOptions } from "./types"; export function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record } { if (path === "-") { return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } }; } if (!existsSync(path)) throw new Error(`body file not found: ${path}`); return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } }; } export function readBodyPatchFileOrStdin(path: string): { patch: string; patchSource: Record } { if (path === "-") { return { patch: readFileSync(0, "utf8"), patchSource: { kind: "body-patch-stdin", path: "-" } }; } if (!existsSync(path)) throw new Error(`body patch file not found: ${path}`); return { patch: readFileSync(path, "utf8"), patchSource: { kind: "body-patch-file", path } }; } export function readBodyPatch(options: GitHubOptions, command: string): { patch: string; patchSource: Record } { const conflictingSources: string[] = []; if (options.body !== undefined) conflictingSources.push("--body"); if (options.bodyFile !== undefined) conflictingSources.push(options.bodyFile === "-" ? "--body-stdin" : "--body-file"); if (options.comment !== undefined) conflictingSources.push("--comment"); if (options.commentFile !== undefined) conflictingSources.push(options.commentFile === "-" ? "--comment-stdin" : "--comment-file"); if (conflictingSources.length > 0) { throw new Error(`${command} accepts only a patch source; remove ${conflictingSources.join(",")} and use --body-patch-stdin or --body-patch-file `); } if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-file `); return readBodyPatchFileOrStdin(options.bodyPatchFile); } export function patchPathList(hunks: PatchHunk[]): string[] { return Array.from(new Set(hunks.map((hunk) => hunk.path))); } export function applyVirtualBodyPatch(virtualPath: "issue.md" | "comment.md", oldBody: string, patchText: string): { newBody: string; patchSummary: Record } { const parsed = parseApplyPatchV2(patchText); let newBody = oldBody; let updateChunkCount = 0; for (let index = 0; index < parsed.hunks.length; index += 1) { const hunk = parsed.hunks[index] as PatchHunk; if (hunk.kind !== "update") { throw new ApplyPatchV2Error("GitHub body patch supports only Update File hunks", { hunk: index + 1, action: hunk.kind, path: hunk.path, expectedPath: virtualPath, }); } if (hunk.path !== virtualPath) { throw new ApplyPatchV2Error("GitHub body patch target path mismatch", { hunk: index + 1, path: hunk.path, expectedPath: virtualPath, }); } if (hunk.movePath !== null && hunk.movePath !== hunk.path) { throw new ApplyPatchV2Error("GitHub body patch does not support Move to", { hunk: index + 1, path: hunk.path, targetPath: hunk.movePath, expectedPath: virtualPath, }); } updateChunkCount += hunk.chunks.length; newBody = deriveUpdatedContent(virtualPath, newBody, hunk.chunks).newContent; } if (newBody === oldBody) { throw new ApplyPatchV2Error("GitHub body patch did not change the body; no GitHub write will be performed", { path: virtualPath, hunkCount: parsed.hunks.length, }); } return { newBody, patchSummary: { engine: "apply-patch-v2", virtualFile: virtualPath, patchBytes: Buffer.byteLength(patchText, "utf8"), hunkCount: parsed.hunks.length, updateChunkCount, paths: patchPathList(parsed.hunks), hints: parsed.hints, }, }; } export function sanitizedPatchDiagnostics(value: unknown): Record | null { if (!isRecord(value)) return null; const diagnostics: Record = {}; const candidateLines = value.firstExpectedLineCandidates; if (Array.isArray(candidateLines)) diagnostics.firstExpectedLineCandidates = candidateLines.filter((item): item is number => typeof item === "number"); for (const key of ["firstExpectedLineCandidatesTruncated", "bestPrefixMatchedLines", "bestPrefixStartLine", "likelyMissingAddedPrefixes", "likelyStaleOrOversizedContext"]) { const item = value[key]; if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) diagnostics[key] = item; } return Object.keys(diagnostics).length > 0 ? diagnostics : null; } export function sanitizedPatchDetails(value: unknown): Record | null { if (!isRecord(value)) return null; const result: Record = {}; for (const key of ["path", "targetPath", "expectedPath", "hunk", "chunk", "action", "status", "line", "hunkCount"]) { const item = value[key]; if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) result[key] = item; } if (typeof value.expected === "string") { result.expectedLineCount = value.expected.length === 0 ? 0 : value.expected.split(/\r?\n/u).length; result.expectedChars = value.expected.length; } const diagnostics = sanitizedPatchDiagnostics(value.diagnostics); if (diagnostics !== null) result.diagnostics = diagnostics; if (Array.isArray(value.partialChanges)) result.partialChanges = value.partialChanges.filter((item): item is string => typeof item === "string"); if (Array.isArray(value.outcomes)) { result.outcomes = value.outcomes .filter(isRecord) .map((outcome) => { const summary: Record = {}; for (const key of ["hunk", "action", "status", "path", "targetPath", "change"]) { const item = outcome[key]; if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) summary[key] = item; } if (isRecord(outcome.error) && typeof outcome.error.message === "string") summary.errorMessage = outcome.error.message; return summary; }); } if (isRecord(value.failed)) result.failed = sanitizedPatchDetails(value.failed); if (isRecord(value.cause)) result.cause = sanitizedPatchDetails(value.cause); return Object.keys(result).length > 0 ? result : null; } export function bodyPatchErrorSummary(error: unknown): Record { const message = error instanceof Error ? error.message : String(error); const summary: Record = { name: error instanceof Error ? error.name : "Error", message, bodyTextRedacted: true, note: "Patch diagnostics omit expected/context text so GitHub body contents and credentials are not echoed.", }; const details = error instanceof ApplyPatchV2Error ? sanitizedPatchDetails(error.details) : null; if (details !== null) summary.details = details; return summary; } export function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); if (options.bodyFile !== undefined) { return readMarkdownBodyFileOrStdin(options.bodyFile); } if (options.body !== undefined) { return { body: options.body, bodySource: { kind: "inline", warning: options.body.includes("\n") ? "inline body contains real newlines; --body-stdin with a quoted heredoc is safer for generated Markdown" : "inline body is intended only for short single-line text", }, }; } return null; } export function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } { const body = readOptionalMarkdownBody(options, command); if (body !== null) return body; throw new Error(`${command} requires --body-stdin, --body-file , or --body `); } export function secretLikeInlineFindings(body: string): string[] { const findings: string[] = []; if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value"); if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value"); if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value"); if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value"); return findings; } export function readIssueCommentBody(options: GitHubOptions, command = "issue comment create"): { body: string; bodySource: Record } { if (options.bodyFile !== undefined && options.body !== undefined) { throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); } if (options.bodyFile !== undefined) { return readMarkdownBodyFileOrStdin(options.bodyFile); } if (options.body === undefined) throw new Error(`${command} requires --body-stdin, --body-file , or --body `); const body = options.body; const trimmed = body.trim(); const shellPollution = shellPollutionEvidence(body); const secretLike = secretLikeInlineFindings(body); if (trimmed.length === 0) { throw new Error(`${command} --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown`); } if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) { throw new Error(`${command} --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`); } if (body.includes("\n") || body.includes("\r")) { throw new Error(`${command} --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown`); } if (shellPollution.length > 0) { throw new Error(`${command} --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`); } if (secretLike.length > 0) { throw new Error(`${command} --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`); } return { body, bodySource: { kind: "inline", maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS, warning: "inline issue comments are intended only for short single-line text; use --body-stdin with a quoted heredoc for Markdown or generated content", }, }; }