edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
121 lines
5.1 KiB
TypeScript
121 lines
5.1 KiB
TypeScript
// 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<string, unknown> } | 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<string, unknown> {
|
|
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,
|
|
},
|
|
};
|
|
}
|