Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/strata/github.ts

Raw
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<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;
}