repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/strata/github.ts
Rawimport { 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<boolean>;
export async function openGitHubReview(
cwd: string,
identifier: string | undefined,
confirm: ConfirmCheckout | undefined,
signal: AbortSignal,
): Promise<ForgeReview> {
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<boolean> {
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<ForgeReview> {
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<void> {
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<Checkout> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<string[]> {
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<string> {
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<Map<string, string>> {
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<string, string>();
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<string, string>,
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<string[]> {
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<void> {
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<Array<{ host: string; repository: string }>> {
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<typeof parseIdentifier>,
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<PullMetadata> {
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<ForgeComment[]> {
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<unknown[]> {
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<void> {
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<unknown> {
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<string> {
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
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;
}