import { spawn } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import path from "node:path"; import { cleanGitEnvironment, gitSucceeds, gitText, isProtectedPath, } from "./git.js"; import { findExecutable } from "./src/pi-ext-executable.ts"; import type { ForgeComment, ForgeReview } from "./types.js"; const MAX_GH_OUTPUT_BYTES = 2 * 1024 * 1024; const MAX_METADATA_BYTES = 256 * 1024; const MAX_GIT_OUTPUT_BYTES = 1024 * 1024; const MAX_COMMENTS = 1_000; const PAGE_SIZE = 100; const MAX_PAGES = 10; const MAX_BODY_LENGTH = 256 * 1024; const MAX_COMMENT_BODY_LENGTH = 64 * 1024; const MAX_REF_LENGTH = 1_024; const OBJECT_ID = /^[0-9a-f]{40,64}$/u; const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; type PullMetadata = { host: string; repository: string; number: number; url: string; title: string; body: string; author: string; baseRef: string; baseSha: string; headRef: string; headSha: string; headRepository: string; }; type Checkout = { branch: string; repoRoot: string }; type ConfirmCheckout = ( title: string, message: string, signal: AbortSignal, ) => Promise; export async function openGitHubReview( cwd: string, identifier: string | undefined, confirm: ConfirmCheckout | undefined, signal: AbortSignal, ): Promise { signal.throwIfAborted(); await requireGh(); await assertSafeFetchConfig(cwd, signal); const remotes = await githubRepositories(cwd, signal); const explicit = identifier === undefined ? undefined : parseIdentifier(identifier); if ( explicit?.repository && explicit.host && !hasRepository(remotes, explicit.host, explicit.repository) ) throw new Error( `GitHub PR repository ${explicit.repository} does not belong to this Git checkout`, ); const metadata = await resolvePull(cwd, identifier, signal); if (explicit && !identifierMatchesMetadata(explicit, metadata)) throw new Error("GitHub returned a different PR than the one requested"); if (!hasRepository(remotes, metadata.host, metadata.repository)) throw new Error( `GitHub PR repository ${metadata.repository} does not belong to this Git checkout`, ); const comments = await loadComments(cwd, metadata, signal); const checkout = await checkoutPull( cwd, metadata, identifier === undefined, confirm, signal, ); return forgeReview(metadata, checkout.branch, comments); } export async function isGitHubReviewCurrent( cwd: string, forge: ForgeReview, signal: AbortSignal, ): Promise { signal.throwIfAborted(); if (!(await checkoutMatches(cwd, forge, signal))) return false; const metadata = await resolvePull(cwd, forge.url, signal); if (!metadataMatchesForge(metadata, forge)) return false; return checkoutMatches(cwd, forge, signal); } export async function refreshGitHubReview( cwd: string, forge: ForgeReview, signal: AbortSignal, ): Promise { signal.throwIfAborted(); if (!(await checkoutMatches(cwd, forge, signal))) throw new Error( "The PR checkout branch, commit, or working tree changed; reopen /strata --pr", ); const metadata = await resolvePull(cwd, forge.url, signal); if (!samePullIdentity(metadata, forge)) throw new Error("GitHub PR identity changed; reopen /strata --pr"); if (metadata.headSha !== forge.headSha) throw new Error( "The GitHub PR has a new head commit; reopen /strata --pr for checkout confirmation", ); await ensureCommit( cwd, metadata.repository, metadata.host, metadata.baseSha, signal, ); const comments = await loadComments(cwd, metadata, signal); await assertGitHubCheckout(cwd, forge, signal); return forgeReview(metadata, forge.checkoutBranch, comments); } export async function assertGitHubCheckout( cwd: string, forge: ForgeReview, signal: AbortSignal, ): Promise { if (!(await checkoutMatches(cwd, forge, signal))) throw new Error( "The PR checkout branch, commit, or working tree changed; reopen /strata --pr", ); } async function checkoutPull( cwd: string, metadata: PullMetadata, resolveCurrentBranch: boolean, confirm: ConfirmCheckout | undefined, signal: AbortSignal, ): Promise { const repoRoot = await repositoryRoot(cwd, signal); await assertClean(repoRoot, signal); const initial = await checkoutState(repoRoot, signal); if (resolveCurrentBranch && initial.head !== metadata.headSha) throw new Error( `Current branch ${initial.branch || "(detached HEAD)"} has unpushed commits or does not match GitHub PR #${metadata.number}`, ); await ensureCommit( repoRoot, metadata.repository, metadata.host, metadata.baseSha, signal, ); await ensureCommit( repoRoot, metadata.headRepository, metadata.host, metadata.headSha, signal, ); if (resolveCurrentBranch) { await assertClean(repoRoot, signal); const afterFetch = await checkoutState(repoRoot, signal); if ( afterFetch.branch !== initial.branch || afterFetch.head !== initial.head || !afterFetch.branch ) throw new Error( "Git checkout changed while resolving the current branch PR", ); return { branch: afterFetch.branch, repoRoot }; } const branches = await localBranches(repoRoot, signal); const sameRepository = metadata.repository.toLowerCase() === metadata.headRepository.toLowerCase(); const namedHead = branches.get(metadata.headRef); if (sameRepository && namedHead && namedHead !== metadata.headSha) throw new Error( `Local branch ${metadata.headRef} diverges from GitHub PR #${metadata.number}; refusing to overwrite it`, ); const prefix = prBranchPrefix(metadata.number, metadata.headRef); if (initial.branch.startsWith(prefix) && initial.head !== metadata.headSha) throw new Error( `Current PR checkout branch ${initial.branch} has commits outside GitHub PR #${metadata.number}; refusing to switch it`, ); const target = sameRepository && namedHead === metadata.headSha ? { branch: metadata.headRef, create: false } : choosePrBranch(prefix, branches, metadata.headSha); if (initial.branch === target.branch && initial.head === metadata.headSha) return { branch: target.branch, repoRoot }; if (!confirm) throw new Error( "PR checkout requires confirmation, but no interactive confirmation is available", ); const approved = await confirm( "Switch to GitHub PR checkout?", [ `Repository: ${metadata.repository}`, `PR: #${metadata.number}`, `Current branch: ${initial.branch || "(detached HEAD)"}`, `Target branch: ${target.branch}`, `Target commit: ${metadata.headSha}`, "", "Switch this clean checkout without stashing, resetting, or running hooks?", ].join("\n"), signal, ); if (!approved) throw new Error("GitHub PR checkout was not confirmed"); signal.throwIfAborted(); const confirmedMetadata = await resolvePull(repoRoot, metadata.url, signal); if (!sameMetadata(confirmedMetadata, metadata)) throw new Error( "GitHub PR metadata changed after confirmation; reopen /strata --pr", ); await assertClean(repoRoot, signal); const before = await checkoutState(repoRoot, signal); if (before.branch !== initial.branch || before.head !== initial.head) throw new Error("Git checkout changed before the confirmed PR switch"); const confirmedBranches = await localBranches(repoRoot, signal); if (confirmedBranches.get(target.branch) !== branches.get(target.branch)) throw new Error( `Local target branch ${target.branch} changed after confirmation; refusing checkout`, ); const changed = await changedPaths( repoRoot, before.head, confirmedMetadata.headSha, signal, ); const protectedPath = changed.find((candidate) => isProtectedPath(candidate)); if (protectedPath) throw new Error( `PR checkout would change protected path ${protectedPath}; refusing checkout`, ); const hooks = await mkdtemp(path.join(tmpdir(), "strata-no-hooks-")); try { await assertSafeCheckoutConfig(repoRoot, signal); const config = [ "-c", `core.hooksPath=${hooks}`, "-c", "submodule.recurse=false", "-c", "maintenance.auto=false", "-c", "gc.auto=0", ]; const args = target.create ? [ ...config, "checkout", "--no-track", "-b", target.branch, metadata.headSha, ] : [...config, "checkout", "--no-guess", target.branch]; try { await gitText(repoRoot, args, MAX_GIT_OUTPUT_BYTES, signal); } catch { signal.throwIfAborted(); throw new Error("Git refused the confirmed PR checkout"); } } finally { await rm(hooks, { recursive: true, force: true }); } const after = await checkoutState(repoRoot, signal); if (after.branch !== target.branch || after.head !== metadata.headSha) throw new Error( "Git checkout did not land on the pinned PR branch and commit", ); await assertClean(repoRoot, signal); return { branch: target.branch, repoRoot }; } async function checkoutMatches( cwd: string, forge: ForgeReview, signal: AbortSignal, ): Promise { await assertSafeCheckoutConfig(cwd, signal); const state = await checkoutState(cwd, signal); if (state.branch !== forge.checkoutBranch || state.head !== forge.headSha) return false; return ( (await gitText( cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], MAX_GIT_OUTPUT_BYTES, signal, )) === "" ); } async function assertClean(cwd: string, signal: AbortSignal): Promise { await assertSafeCheckoutConfig(cwd, signal); const status = await gitText( cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], MAX_GIT_OUTPUT_BYTES, signal, ); if (status !== "") throw new Error( "GitHub PR checkout requires a clean worktree with no staged, unstaged, or untracked changes", ); } async function assertSafeCheckoutConfig( cwd: string, signal: AbortSignal, ): Promise { const names = await repositoryConfigNames(cwd, signal); if (names.some((name) => /^include(?:if\..+)?\.path$/u.test(name))) throw new Error( "Git repository include config is not allowed during PR checkout", ); if ( names.some((name) => /^filter\..+\.(?:clean|smudge|process)$/u.test(name)) ) throw new Error( "Git repository external clean, smudge, or process filters are not allowed during PR checkout", ); } async function assertSafeFetchConfig( cwd: string, signal: AbortSignal, ): Promise { const names = await repositoryConfigNames(cwd, signal); if (names.some((name) => /^include(?:if\..+)?\.path$/u.test(name))) throw new Error( "Git repository include config is not allowed during PR fetch", ); if (names.some((name) => /^credential(?:\..+)?\.helper$/u.test(name))) throw new Error( "Git repository credential helpers are not allowed during PR fetch", ); if ( names.some((name) => /^url\..+\.(?:insteadof|pushinsteadof)$/u.test(name)) ) throw new Error( "Git repository URL rewrites are not allowed during PR fetch", ); } async function repositoryConfigNames( cwd: string, signal: AbortSignal, ): Promise { const text = await gitText( cwd, ["config", "--no-includes", "--name-only", "-z", "--list"], MAX_METADATA_BYTES, signal, ); if (text === "") return []; if (!text.endsWith("\0")) throw new Error("Git returned malformed repository config metadata"); return text .slice(0, -1) .split("\0") .map((name) => name.toLowerCase()); } async function repositoryRoot( cwd: string, signal: AbortSignal, ): Promise { const root = stripNewline( await gitText( cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel"], 16 * 1024, signal, ), ); if (!root) throw new Error("Git returned an empty repository root"); return root; } async function checkoutState( cwd: string, signal: AbortSignal, ): Promise<{ branch: string; head: string }> { const results = await Promise.allSettled([ gitText(cwd, ["branch", "--show-current"], 16 * 1024, signal).then( stripNewline, ), gitText( cwd, ["rev-parse", "--verify", "HEAD^{commit}"], 4 * 1024, signal, ).then(stripNewline), ]); const [branch, head] = results.map((result) => { if (result.status === "rejected") throw result.reason; return result.value; }); if (!OBJECT_ID.test(head)) throw new Error("Git returned an invalid HEAD commit"); return { branch, head }; } async function localBranches( cwd: string, signal: AbortSignal, ): Promise> { const text = await gitText( cwd, [ "for-each-ref", "--format=%(refname:strip=2)%09%(objectname)", "refs/heads", ], MAX_GIT_OUTPUT_BYTES, signal, ); const branches = new Map(); for (const line of text.split(/\r?\n/u)) { if (!line) continue; const separator = line.lastIndexOf("\t"); const branch = line.slice(0, separator); const commit = line.slice(separator + 1); if (separator <= 0 || !OBJECT_ID.test(commit)) throw new Error("Git returned malformed local branch metadata"); branches.set(branch, commit); } return branches; } function choosePrBranch( prefix: string, branches: Map, headSha: string, ): { branch: string; create: boolean } { for (let suffix = 0; suffix < 10_000; suffix++) { const branch = suffix === 0 ? prefix : `${prefix}-${suffix + 1}`; const commit = branches.get(branch); if (commit === headSha) return { branch, create: false }; if (commit === undefined) return { branch, create: true }; } throw new Error("Could not allocate a non-colliding local PR branch"); } function prBranchPrefix(number: number, headRef: string): string { const label = headRef .toLowerCase() .replaceAll(/[^a-z0-9._-]+/gu, "-") .replaceAll(/^-+|-+$/gu, "") .slice(0, 48); return `strata/pr-${number}-${label || "head"}`; } async function changedPaths( cwd: string, from: string, to: string, signal: AbortSignal, ): Promise { const text = await gitText( cwd, [ "-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--no-renames", "-z", from, to, "--", ], MAX_GIT_OUTPUT_BYTES, signal, ); if (text === "") return []; if (!text.endsWith("\0")) throw new Error("Git returned malformed changed paths"); return text.slice(0, -1).split("\0"); } async function ensureCommit( cwd: string, repository: string, host: string, commit: string, signal: AbortSignal, ): Promise { if (await gitSucceeds(cwd, ["cat-file", "-e", `${commit}^{commit}`], signal)) return; const url = `https://${host}/${repository}.git`; await assertSafeFetchConfig(cwd, signal); try { await gitText( cwd, [ "-c", `core.hooksPath=${process.platform === "win32" ? "NUL" : devNull}`, "-c", "submodule.recurse=false", "-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "fetch", "--quiet", "--no-tags", "--no-recurse-submodules", "--no-auto-maintenance", "--no-write-fetch-head", url, commit, ], MAX_GIT_OUTPUT_BYTES, signal, ); } catch { signal.throwIfAborted(); throw new Error( `Git could not fetch pinned commit ${commit} from ${repository}`, ); } if ( !(await gitSucceeds(cwd, ["cat-file", "-e", `${commit}^{commit}`], signal)) ) throw new Error(`Git did not acquire pinned commit ${commit}`); } async function githubRepositories( cwd: string, signal: AbortSignal, ): Promise> { const names = (await gitText(cwd, ["remote"], 64 * 1024, signal)) .split(/\r?\n/u) .filter(Boolean); const repositories: Array<{ host: string; repository: string }> = []; for (const name of names) { const urls = await gitText( cwd, ["remote", "get-url", "--all", name], MAX_GIT_OUTPUT_BYTES, signal, ); for (const value of urls.split(/\r?\n/u)) { const parsed = parseRemote(value); if (parsed) repositories.push(parsed); } } return repositories; } function parseRemote( value: string, ): { host: string; repository: string } | undefined { const scp = /^(?:[^@/:]+@)?([^/:]+):([^/]+\/[^/]+?)(?:\.git)?$/u.exec(value); if (scp) return repositoryIdentity(scp[1] as string, scp[2] as string); try { const url = new URL(value); if (!["https:", "ssh:", "git:"].includes(url.protocol)) return undefined; return repositoryIdentity( url.hostname, url.pathname.replace(/^\/+|\/+$/gu, ""), ); } catch { return undefined; } } function repositoryIdentity( host: string, repository: string, ): { host: string; repository: string } | undefined { const normalized = repository.endsWith(".git") ? repository.slice(0, -4) : repository; if (!host || !REPOSITORY.test(normalized)) return undefined; return { host: host.toLowerCase(), repository: normalized }; } function hasRepository( remotes: Array<{ host: string; repository: string }>, host: string, repository: string, ): boolean { return remotes.some( (remote) => remote.host === host.toLowerCase() && remote.repository.toLowerCase() === repository.toLowerCase(), ); } function identifierMatchesMetadata( identifier: ReturnType, metadata: PullMetadata, ): boolean { return ( identifier.number === metadata.number && (identifier.host === undefined || identifier.host === metadata.host) && (identifier.repository === undefined || identifier.repository.toLowerCase() === metadata.repository.toLowerCase()) ); } function parseIdentifier(identifier: string): { host?: string; repository?: string; number: number; } { if (/^[1-9]\d*$/u.test(identifier)) { const number = Number(identifier); if (!Number.isSafeInteger(number)) throw new Error("GitHub PR number is too large"); return { number }; } const pull = parsePullUrl(identifier); if (!pull) throw new Error("--pr accepts only a positive PR number or HTTPS PR URL"); return pull; } function parsePullUrl(value: string): | { host: string; repository: string; number: number; } | undefined { try { const url = new URL(value); if ( url.protocol !== "https:" || url.username || url.password || url.search || url.hash ) return undefined; const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)\/?$/u.exec( url.pathname, ); if (!match) return undefined; const number = Number(match[3]); const repository = `${match[1]}/${match[2]}`; if (!Number.isSafeInteger(number) || !REPOSITORY.test(repository)) return undefined; return { host: url.hostname.toLowerCase(), repository, number }; } catch { return undefined; } } async function resolvePull( cwd: string, identifier: string | undefined, signal: AbortSignal, ): Promise { const fields = [ "number", "url", "title", "body", "author", "baseRefName", "baseRefOid", "headRefName", "headRefOid", "headRepository", "headRepositoryOwner", ].join(","); const args = [ "pr", "view", ...(identifier ? [identifier] : []), "--json", fields, ]; const value = await ghJson( cwd, args, MAX_METADATA_BYTES, signal, "PR metadata", ); const object = record(value, "GitHub PR metadata"); const url = requiredString(object.url, "PR url", 4_096); const identity = parsePullUrl(url); if (!identity) throw new Error("GitHub returned an unsafe or malformed PR URL"); const number = positiveInteger(object.number, "PR number"); if (number !== identity.number) throw new Error("GitHub PR number and URL disagree"); const headRepositoryObject = record( object.headRepository, "PR headRepository", ); const headOwnerObject = record( object.headRepositoryOwner, "PR headRepositoryOwner", ); const headRepository = optionalString( headRepositoryObject.nameWithOwner, "PR head repository", 512, ) ?? `${requiredString(headOwnerObject.login, "PR head owner", 256)}/${requiredString(headRepositoryObject.name, "PR head name", 256)}`; if (!REPOSITORY.test(headRepository)) throw new Error("GitHub returned an invalid PR head repository"); return { host: identity.host, repository: identity.repository, number, url, title: requiredString(object.title, "PR title", 4_096), body: nullableString(object.body, "PR body", MAX_BODY_LENGTH), author: author(object.author, "PR author"), baseRef: ref(object.baseRefName, "PR base ref"), baseSha: objectId(object.baseRefOid, "PR base commit"), headRef: ref(object.headRefName, "PR head ref"), headSha: objectId(object.headRefOid, "PR head commit"), headRepository, }; } async function loadComments( cwd: string, metadata: PullMetadata, signal: AbortSignal, ): Promise { const prefix = `repos/${metadata.repository}`; const discussion = await ghPages( cwd, `${prefix}/issues/${metadata.number}/comments`, metadata.host, signal, ); const reviews = await ghPages( cwd, `${prefix}/pulls/${metadata.number}/reviews`, metadata.host, signal, ); const inline = await ghPages( cwd, `${prefix}/pulls/${metadata.number}/comments`, metadata.host, signal, ); if (discussion.length + reviews.length + inline.length > MAX_COMMENTS) throw new Error(`GitHub PR discussion exceeds ${MAX_COMMENTS} comments`); return [ ...discussion.map((value, index) => generalComment(value, "discussion", index, metadata), ), ...reviews.map((value, index) => generalComment(value, "review", index, metadata), ), ...inline.map((value, index) => inlineComment(value, index, metadata)), ]; } async function ghPages( cwd: string, endpoint: string, host: string, signal: AbortSignal, ): Promise { const values: unknown[] = []; for (let page = 1; page <= MAX_PAGES; page++) { const separator = endpoint.includes("?") ? "&" : "?"; const value = await ghJson( cwd, [ "api", "--method", "GET", "--hostname", host, `${endpoint}${separator}per_page=${PAGE_SIZE}&page=${page}`, ], MAX_GH_OUTPUT_BYTES, signal, "PR discussion", ); if (!Array.isArray(value)) throw new Error("GitHub PR discussion page must be an array"); values.push(...value); if (values.length > MAX_COMMENTS) throw new Error(`GitHub PR discussion exceeds ${MAX_COMMENTS} comments`); if (value.length < PAGE_SIZE) return values; } throw new Error(`GitHub PR discussion exceeds ${MAX_PAGES} pages`); } function generalComment( value: unknown, kind: "discussion" | "review", index: number, metadata: PullMetadata, ): ForgeComment { const object = record(value, `${kind} comment ${index}`); return { id: `${kind}:${sourceId(object.id, `${kind} comment id`)}`, url: safeSourceUrl(object.html_url, `${kind} comment url`, metadata), author: author(object.user, `${kind} comment author`), body: nullableString( object.body, `${kind} comment body`, MAX_COMMENT_BODY_LENGTH, ), kind, outdated: false, }; } function inlineComment( value: unknown, index: number, metadata: PullMetadata, ): ForgeComment { const object = record(value, `inline comment ${index}`); const currentSide = object.side === "RIGHT" ? "new" : object.side === "LEFT" ? "old" : undefined; const originalSide = object.original_side === "RIGHT" ? "new" : object.original_side === "LEFT" ? "old" : undefined; const side = currentSide ?? originalSide; const currentLine = Number.isSafeInteger(object.line) && (object.line as number) > 0 ? (object.line as number) : undefined; const originalLine = Number.isSafeInteger(object.original_line) && (object.original_line as number) > 0 ? (object.original_line as number) : undefined; const line = currentLine ?? originalLine; const commit = optionalObjectId( object.commit_id ?? object.original_commit_id, "inline comment commit", ); const outdated = object.position === null || !currentSide || currentLine === undefined; const reply = object.in_reply_to_id; return { id: `inline:${sourceId(object.id, "inline comment id")}`, url: safeSourceUrl(object.html_url, "inline comment url", metadata), author: author(object.user, "inline comment author"), body: nullableString( object.body, "inline comment body", MAX_COMMENT_BODY_LENGTH, ), kind: "inline", ...(optionalString(object.path, "inline comment path", 4_096) ? { path: object.path as string } : {}), ...(side ? { side } : {}), ...(line ? { line } : {}), ...(commit ? { commit } : {}), ...(reply === undefined || reply === null ? {} : { replyTo: `inline:${sourceId(reply, "inline reply id")}` }), outdated, }; } function forgeReview( metadata: PullMetadata, checkoutBranch: string, comments: ForgeComment[], ): ForgeReview { return { provider: "github", providerLabel: "GitHub", repository: metadata.repository, number: metadata.number, url: metadata.url, title: metadata.title, body: metadata.body, author: metadata.author, baseRef: metadata.baseRef, baseSha: metadata.baseSha, headRef: metadata.headRef, headSha: metadata.headSha, checkoutBranch, comments, }; } function sameMetadata(left: PullMetadata, right: PullMetadata): boolean { return ( left.host === right.host && left.repository.toLowerCase() === right.repository.toLowerCase() && left.number === right.number && left.url === right.url && left.title === right.title && left.body === right.body && left.author === right.author && left.baseRef === right.baseRef && left.baseSha === right.baseSha && left.headRef === right.headRef && left.headSha === right.headSha && left.headRepository.toLowerCase() === right.headRepository.toLowerCase() ); } function metadataMatchesForge( metadata: PullMetadata, forge: ForgeReview, ): boolean { return ( samePullIdentity(metadata, forge) && metadata.baseRef === forge.baseRef && metadata.baseSha === forge.baseSha && metadata.headRef === forge.headRef && metadata.headSha === forge.headSha ); } function samePullIdentity(metadata: PullMetadata, forge: ForgeReview): boolean { return ( forge.provider === "github" && metadata.repository.toLowerCase() === forge.repository.toLowerCase() && metadata.number === forge.number && metadata.url === forge.url ); } async function requireGh(): Promise { if (findExecutable(process.platform === "win32" ? ["gh.exe"] : ["gh"])) return; throw new Error("GitHub PR context requires the optional gh CLI on PATH"); } async function ghJson( cwd: string, args: string[], maxBytes: number, signal: AbortSignal, label: string, ): Promise { const text = await runGh(cwd, args, maxBytes, signal, label); try { return JSON.parse(text); } catch { throw new Error(`GitHub returned malformed JSON for ${label}`); } } function runGh( cwd: string, args: string[], maxBytes: number, signal: AbortSignal, label: string, ): Promise { return new Promise((resolve, reject) => { signal.throwIfAborted(); const child = spawn("gh", args, { cwd, env: { ...cleanGitEnvironment(), GH_PAGER: "cat", PAGER: "cat", NO_COLOR: "1", GH_PROMPT_DISABLED: "1", }, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let bytes = 0; let errorBytes = 0; let failure: Error | undefined; const finish = (error?: Error, output = "") => { signal.removeEventListener("abort", abort); if (error) reject(error); else resolve(output); }; const abort = () => { failure ??= signal.reason instanceof Error ? signal.reason : new Error("GitHub request cancelled"); child.kill(); }; signal.addEventListener("abort", abort, { once: true }); child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) { failure ??= new Error( `GitHub ${label} exceeds the ${maxBytes} byte limit`, ); child.kill(); return; } stdout.push(chunk); }); child.stderr.on("data", (chunk: Buffer) => { if (errorBytes >= 64 * 1024) return; const remaining = 64 * 1024 - errorBytes; stderr.push(chunk.subarray(0, remaining)); errorBytes += Math.min(chunk.length, remaining); }); child.once("error", (error: NodeJS.ErrnoException) => { failure ??= error.code === "ENOENT" ? new Error("GitHub PR context requires the optional gh CLI on PATH") : new Error(`Could not start gh for GitHub ${label}`); }); child.once("close", (code) => { if (failure) return finish(failure); if (code === 0) { finish(undefined, Buffer.concat(stdout).toString("utf8")); return; } const detail = Buffer.concat(stderr).toString("utf8"); const auth = /auth|login|token|credential|HTTP 40[13]/iu.test(detail); finish( new Error( auth ? "GitHub authentication failed; verify gh auth status" : `GitHub could not load ${label}; verify the PR exists and is accessible`, ), ); }); }); } function record(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record; } function requiredString( value: unknown, label: string, maximum: number, ): string { if ( typeof value !== "string" || value.trim() === "" || value.length > maximum ) throw new Error(`GitHub returned an invalid ${label}`); return value; } function optionalString( value: unknown, label: string, maximum: number, ): string | undefined { if (value === undefined || value === null || value === "") return undefined; return requiredString(value, label, maximum); } function nullableString( value: unknown, label: string, maximum: number, ): string { if (value === undefined || value === null) return ""; if (typeof value !== "string" || value.length > maximum) throw new Error(`GitHub returned an invalid ${label}`); return value; } function positiveInteger(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) <= 0) throw new Error(`GitHub returned an invalid ${label}`); return value as number; } function sourceId(value: unknown, label: string): string { if (typeof value === "string" && value.length > 0 && value.length <= 256) return value; if (Number.isSafeInteger(value) && (value as number) > 0) return String(value); throw new Error(`GitHub returned an invalid ${label}`); } function objectId(value: unknown, label: string): string { if (typeof value !== "string" || !OBJECT_ID.test(value)) throw new Error(`GitHub returned an invalid ${label}`); return value; } function optionalObjectId(value: unknown, label: string): string | undefined { if (value === undefined || value === null) return undefined; return objectId(value, label); } function ref(value: unknown, label: string): string { const result = requiredString(value, label, MAX_REF_LENGTH); if ( result.startsWith("-") || /[\u0000-\u001f\u007f~^:?*[\\]/u.test(result) || result.includes("..") ) throw new Error(`GitHub returned an invalid ${label}`); return result; } function author(value: unknown, label: string): string { const object = record(value, label); return requiredString(object.login, label, 256); } function safeSourceUrl( value: unknown, label: string, metadata: PullMetadata, ): string { const text = requiredString(value, label, 4_096); try { const url = new URL(text); if ( url.protocol !== "https:" || url.username || url.password || url.hostname.toLowerCase() !== metadata.host || !url.pathname.startsWith(`/${metadata.repository}/`) ) throw new Error("unsafe"); return text; } catch { throw new Error(`GitHub returned an unsafe ${label}`); } } function stripNewline(value: string): string { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; }