Files
pikasTech-unidesk/scripts/src/gh/body-input.ts
T
2026-06-26 00:16:53 +08:00

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",
},
};
}