edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
// 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<string, unknown> } {
|
|
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<string, unknown> } {
|
|
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<string, unknown> } {
|
|
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 <file|->`);
|
|
}
|
|
if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-file <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<string, unknown> } {
|
|
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<string, unknown> | null {
|
|
if (!isRecord(value)) return null;
|
|
const diagnostics: Record<string, unknown> = {};
|
|
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<string, unknown> | null {
|
|
if (!isRecord(value)) return null;
|
|
const result: Record<string, unknown> = {};
|
|
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<string, unknown> = {};
|
|
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<string, unknown> {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const summary: Record<string, unknown> = {
|
|
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<string, unknown> } | 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<string, unknown> } {
|
|
const body = readOptionalMarkdownBody(options, command);
|
|
if (body !== null) return body;
|
|
throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`);
|
|
}
|
|
|
|
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<string, unknown> } {
|
|
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 <file|->, or --body <text>`);
|
|
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",
|
|
},
|
|
};
|
|
}
|