Files
oh-my-pi/packages/coding-agent/src/tools/gh-pr-checkout.ts
T
can1357 cafe52cd9f refactor(coding-agent): split github tool into domain modules
- gh.ts held wire types, search, Actions run-watch, PR checkout/push/create,
  PR diff parsing and view fetch/format in 3958 lines.
- Split into gh-types, gh-search, gh-run-watch, gh-pr-checkout, gh-pr-diff,
  gh-view and a gh-common module holding the shared primitives and the single
  process-lifetime default-repo memo pair; gh.ts is now 246 lines.
- All 22 exports stay on gh.ts because tools/index.ts star-exports ./gh, so
  the issue:// and pr:// protocol handlers needed no edits.
2026-08-08 06:32:01 +02:00

680 lines
23 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { getWorktreeDir, hashPath, isEnoent } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
import type { ToolSession } from ".";
import type { GhPrCheckoutSummary, GhToolDetails } from "./gh";
import {
appendRepoFlag,
buildTextResult,
formatAuthor,
formatLabels,
normalizeOptionalString,
normalizePrIdentifierList,
normalizeText,
parsePullRequestUrl,
pushLine,
requireCurrentGitBranch,
requireNonEmpty,
} from "./gh-common";
import { formatShortSha } from "./gh-format";
import type { GhPrViewData, GhRepoViewData, GithubInput } from "./gh-types";
import { GH_PR_FIELDS_NO_COMMENTS } from "./gh-view";
import { invalidateAllForNumber } from "./github-cache";
import { ToolError, throwIfAborted } from "./tool-errors";
export const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
export const GH_PR_CHECKOUT_FIELDS = [
"baseRefName",
"headRefName",
"headRefOid",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
"maintainerCanModify",
"number",
"title",
"url",
];
export function sanitizeRemoteName(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/g, "")
.replace(/-+$/g, "");
return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
}
/** Maximum disambiguation suffixes we try before giving up on a worktree path. */
export const WORKTREE_PATH_MAX_SUFFIX = 100;
export function toLocalBranchRef(value: string): string {
return `refs/heads/${value}`;
}
export async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
const repoRoot = await git.repo.root(cwd, signal);
if (!repoRoot) {
throw new ToolError("Current git repository is unavailable.");
}
return repoRoot;
}
export async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
const primaryRepoRoot = await git.repo.primaryRoot(cwd, signal);
if (!primaryRepoRoot) {
throw new ToolError("Current git repository is unavailable.");
}
return primaryRepoRoot;
}
/**
* Resolve a worktree path that is free of conflicts.
*
* Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
* `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
* first variant that is **not** registered with git as another worktree and
* **not** present on disk. The numeric tail salvages two rare cases that
* would otherwise abort a checkout: stale leftover dirs from an interrupted
* `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
* between two repos that happen to produce the same 7-hex digest.
*/
export async function resolveAvailableWorktreePath(
basePath: string,
existingWorktrees: git.GitWorktreeEntry[],
): Promise<string> {
const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
const normalized = path.resolve(candidate);
if (registered.has(normalized)) continue;
try {
await fs.stat(normalized);
} catch (error) {
if (isEnoent(error)) {
return candidate;
}
throw error;
}
}
throw new ToolError(
`could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
);
}
export function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
if (originUrl?.startsWith("http://") || originUrl?.startsWith("https://")) {
return normalizeOptionalString(repo.url) ?? normalizeOptionalString(repo.sshUrl) ?? "";
}
return normalizeOptionalString(repo.sshUrl) ?? normalizeOptionalString(repo.url) ?? "";
}
export async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
const remotes = await git.remote.list(repoRoot, signal);
const urls = new Map<string, string>();
for (const remoteName of remotes) {
const remoteUrl = await git.remote.url(repoRoot, remoteName, signal);
if (remoteUrl) {
urls.set(remoteName, remoteUrl);
}
}
return urls;
}
export async function ensurePrRemote(
repoRoot: string,
data: GhPrViewData,
signal?: AbortSignal,
): Promise<{ name: string; url: string }> {
if (!data.isCrossRepository) {
const originUrl = await git.remote.url(repoRoot, "origin", signal);
if (!originUrl) {
throw new ToolError("origin remote is unavailable for this repository.");
}
return {
name: "origin",
url: originUrl,
};
}
const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository");
const repoSummary = await git.github.json<GhRepoViewData>(
repoRoot,
["repo", "view", headRepository, "--json", GH_REPO_CLONE_FIELDS.join(",")],
signal,
{ repoProvided: true },
);
const originUrl = await git.remote.url(repoRoot, "origin", signal);
const remoteUrl = selectPrCloneUrl(originUrl, repoSummary);
if (!remoteUrl) {
throw new ToolError(`Could not determine a clone URL for ${headRepository}.`);
}
const remotes = await getRemoteUrls(repoRoot, signal);
for (const [remoteName, url] of remotes) {
if (url === remoteUrl) {
return { name: remoteName, url };
}
}
const preferredRemoteName = sanitizeRemoteName(
data.headRepositoryOwner?.login ?? headRepository.split("/")[0] ?? "fork",
);
let remoteName = preferredRemoteName;
let suffix = 2;
while (remotes.has(remoteName)) {
remoteName = `${preferredRemoteName}-${suffix}`;
suffix += 1;
}
await git.remote.add(repoRoot, remoteName, remoteUrl, signal);
return {
name: remoteName,
url: remoteUrl,
};
}
export async function resolvePrBranchPushTarget(
repoRoot: string,
localBranch: string,
signal?: AbortSignal,
): Promise<{
remoteName: string;
remoteBranch: string;
remoteUrl?: string;
prUrl?: string;
maintainerCanModify?: boolean;
isCrossRepository: boolean;
}> {
const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
if (!headRef) {
throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via op: pr_checkout first`);
}
const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
const maintainerCanModifyValue = await git.config.getBranch(
repoRoot,
localBranch,
"ompPrMaintainerCanModify",
signal,
);
const isCrossRepositoryValue = await git.config.getBranch(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
const remoteName = pushRemote ?? remote;
if (!remoteName) {
throw new ToolError(`branch ${localBranch} has no configured push remote`);
}
return {
remoteName,
remoteBranch: headRef,
remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
prUrl,
maintainerCanModify:
maintainerCanModifyValue === undefined
? undefined
: ["1", "true", "yes", "on"].includes(maintainerCanModifyValue.toLowerCase()),
isCrossRepository: ["1", "true", "yes", "on"].includes((isCrossRepositoryValue ?? "").toLowerCase()),
};
}
export function formatPrCheckoutResult(options: {
data: GhPrViewData;
localBranch: string;
worktreePath: string;
remoteName: string;
remoteUrl: string;
reused: boolean;
}): string {
const { data, localBranch, worktreePath, remoteName, remoteUrl, reused } = options;
const lines: string[] = [
reused ? `# Pull Request #${data.number ?? "?"} Worktree` : `# Checked Out Pull Request #${data.number ?? "?"}`,
"",
];
pushLine(lines, "Title", data.title ?? undefined);
pushLine(lines, "URL", data.url);
pushLine(lines, "Base", data.baseRefName);
pushLine(lines, "Head", data.headRefName);
pushLine(lines, "Local branch", localBranch);
pushLine(lines, "Worktree", worktreePath);
pushLine(lines, "Remote", remoteName);
pushLine(lines, "Remote URL", remoteUrl);
pushLine(lines, "Cross repository", data.isCrossRepository);
pushLine(lines, "Maintainer can modify", data.maintainerCanModify);
lines.push("");
lines.push(
reused
? "Reused the existing PR worktree."
: "Created a dedicated worktree for this PR and configured the local branch to push back to the PR head branch.",
);
return lines.join("\n").trim();
}
export function formatPrPushResult(options: {
localBranch: string;
remoteName: string;
remoteBranch: string;
remoteUrl?: string;
prUrl?: string;
forceWithLease: boolean;
}): string {
const lines: string[] = ["# Pushed Pull Request Branch", ""];
pushLine(lines, "Local branch", options.localBranch);
pushLine(lines, "Remote", options.remoteName);
pushLine(lines, "Remote branch", options.remoteBranch);
pushLine(lines, "Remote URL", options.remoteUrl);
pushLine(lines, "PR", options.prUrl);
pushLine(lines, "Force with lease", options.forceWithLease);
lines.push("");
lines.push(`Pushed ${options.localBranch} to ${options.remoteName}:${options.remoteBranch}.`);
return lines.join("\n").trim();
}
export function joinSections(sections: string[]): string[] {
return sections.flatMap((section, idx) => (idx === 0 ? [section] : ["", "---", "", section]));
}
export async function executePrCheckout(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const force = params.force ?? false;
const prList = normalizePrIdentifierList(params.pr);
const prRefs = prList.length > 0 ? prList : [undefined];
const isMulti = prRefs.length > 1;
const settled = await Promise.allSettled(
prRefs.map(prRef => checkoutPullRequest(session, signal, { prRef, repo, force })),
);
const outcomes: PrCheckoutOutcome[] = [];
const failures: Array<{ prRef: string | undefined; reason: unknown }> = [];
for (let i = 0; i < settled.length; i++) {
const entry = settled[i];
if (entry.status === "fulfilled") outcomes.push(entry.value);
else failures.push({ prRef: prRefs[i], reason: entry.reason });
}
if (failures.length > 0) {
throwIfAborted(signal);
const failureLines = failures.map(
f => `- ${f.prRef ?? "(current branch)"}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`,
);
if (outcomes.length === 0) {
if (failures.length === 1) throw failures[0].reason;
throw new ToolError(`all ${failures.length} PR checkouts failed:\n${failureLines.join("\n")}`);
}
// Partial success: report the worktrees that did get created alongside
// the failures so the agent does not lose track of them.
const sections = outcomes.map(formatPrCheckoutResult);
const header = `# ${outcomes.length}/${settled.length} Pull Request Worktrees checked out (${failures.length} failed)`;
const text = [header, "", ...joinSections(sections), "", "## Failed", ...failureLines].join("\n").trim();
return buildTextResult(text, undefined, {
repo,
checkouts: outcomes.map(outcomeToSummary),
});
}
if (!isMulti) {
const [outcome] = outcomes;
return buildTextResult(formatPrCheckoutResult(outcome), outcome.data.url, {
repo: repo ?? outcome.data.headRepository?.nameWithOwner,
branch: outcome.localBranch,
worktreePath: outcome.worktreePath,
remote: outcome.remoteName,
remoteBranch: outcome.headRefName,
checkouts: [outcomeToSummary(outcome)],
});
}
const sections = outcomes.map(formatPrCheckoutResult);
const reusedCount = outcomes.reduce((acc, o) => acc + (o.reused ? 1 : 0), 0);
const newCount = outcomes.length - reusedCount;
const headerParts: string[] = [];
if (newCount > 0) headerParts.push(`${newCount} checked out`);
if (reusedCount > 0) headerParts.push(`${reusedCount} reused`);
const header = `# ${outcomes.length} Pull Request Worktrees (${headerParts.join(", ")})`;
const text = [header, "", ...joinSections(sections)].join("\n").trim();
return buildTextResult(text, undefined, {
repo,
checkouts: outcomes.map(outcomeToSummary),
});
}
export interface PrCheckoutOptions {
prRef: string | undefined;
repo: string | undefined;
force: boolean;
}
export interface PrCheckoutOutcome {
data: GhPrViewData;
localBranch: string;
worktreePath: string;
remoteName: string;
remoteUrl: string;
headRefName: string;
reused: boolean;
}
export async function checkoutPullRequest(
session: ToolSession,
signal: AbortSignal | undefined,
options: PrCheckoutOptions,
): Promise<PrCheckoutOutcome> {
const { prRef, repo, force } = options;
if (prRef?.startsWith("-")) {
throw new ToolError(`invalid PR identifier: ${prRef}. Pass a PR number, URL, or branch name.`);
}
const args = ["pr", "view"];
if (prRef) args.push(prRef);
appendRepoFlag(args, repo, prRef);
args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
const data = await git.github.json<GhPrViewData>(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
const prNumber = data.number;
if (typeof prNumber !== "number") {
throw new ToolError("GitHub CLI did not return a pull request number.");
}
const headRefName = requireNonEmpty(data.headRefName, "head branch");
const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
const repoRoot = await requireGitRepoRoot(session.cwd, signal);
const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
const localBranch = `pr-${prNumber}`;
const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
// Every git mutation against `repoRoot` from here on must run under the
// per-repo lock. Worktrees of the same primary repo share `.git/config`,
// `commit-graph` chain, `packed-refs`, and worktree metadata files — git
// uses O_EXCL lock files for each, with no waiter. Concurrent in-process
// callers (e.g. parallel `pr_checkout` calls) would otherwise lose lock
// races and surface "could not lock config file" / "Another git process
// seems to be running" errors. The gh API call above stays outside the
// lock so multiple checkouts can fetch PR metadata in parallel.
return git.withRepoLock(
repoRoot,
async () => {
const existingWorktrees = await git.worktree.list(repoRoot, signal);
const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch));
const remote = await ensurePrRemote(repoRoot, data, signal);
await git.fetch(
repoRoot,
remote.name,
`refs/heads/${headRefName}`,
`refs/remotes/${remote.name}/${headRefName}`,
{ signal },
);
if (!existingWorktree) {
const localBranchRef = toLocalBranchRef(localBranch);
const localBranchExists = await git.ref.exists(repoRoot, localBranchRef, signal);
if (localBranchExists) {
const existingOid = await git.ref.resolve(repoRoot, localBranchRef, signal);
if (existingOid !== headRefOid) {
if (!force) {
throw new ToolError(
`local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`,
);
}
await git.branch.force(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
}
} else {
await git.branch.create(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
}
}
await git.config.setBranch(repoRoot, localBranch, "remote", remote.name, signal);
await git.config.setBranch(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
await git.config.setBranch(repoRoot, localBranch, "pushRemote", remote.name, signal);
await git.config.setBranch(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
await git.config.setBranch(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
await git.config.setBranch(
repoRoot,
localBranch,
"ompPrIsCrossRepository",
String(Boolean(data.isCrossRepository)),
signal,
);
await git.config.setBranch(
repoRoot,
localBranch,
"ompPrMaintainerCanModify",
String(Boolean(data.maintainerCanModify)),
signal,
);
let finalWorktreePath = existingWorktree?.path ?? worktreePath;
if (!existingWorktree) {
finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
}
const resolvedWorktreePath = await fs.realpath(finalWorktreePath);
return {
data,
localBranch,
worktreePath: resolvedWorktreePath,
remoteName: remote.name,
remoteUrl: remote.url,
headRefName,
reused: Boolean(existingWorktree),
};
},
signal,
);
}
export function outcomeToSummary(outcome: PrCheckoutOutcome): GhPrCheckoutSummary {
return {
prNumber: typeof outcome.data.number === "number" ? outcome.data.number : undefined,
url: outcome.data.url ?? undefined,
branch: outcome.localBranch,
worktreePath: outcome.worktreePath,
remote: outcome.remoteName,
remoteBranch: outcome.headRefName,
reused: outcome.reused,
};
}
export async function executePrPush(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repoRoot = await requireGitRepoRoot(session.cwd, signal);
const localBranch = normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal));
const refExists = await git.ref.exists(repoRoot, toLocalBranchRef(localBranch), signal);
if (!refExists) {
throw new ToolError(`local branch ${localBranch} does not exist`);
}
const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal);
const currentBranch = await git.branch.current(repoRoot, signal);
const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch);
const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`;
await git.push(repoRoot, {
forceWithLease: params.forceWithLease,
refspec,
remote: target.remoteName,
signal,
});
// A successful push changes what `pr://N` and `pr://N/diff` should show;
// drop the cached rows so the canonical "push → re-read diff" flow sees
// fresh data instead of a soft-TTL stale snapshot.
const pushedPr = parsePullRequestUrl(target.prUrl);
if (pushedPr.prNumber !== undefined) {
invalidateAllForNumber(pushedPr.prNumber, pushedPr.repo);
}
return buildTextResult(
formatPrPushResult({
localBranch,
remoteName: target.remoteName,
remoteBranch: target.remoteBranch,
remoteUrl: target.remoteUrl,
prUrl: target.prUrl,
forceWithLease: params.forceWithLease ?? false,
}),
target.prUrl,
{
branch: localBranch,
remote: target.remoteName,
remoteBranch: target.remoteBranch,
},
);
}
export async function executePrCreate(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = normalizeOptionalString(params.repo);
const title = normalizeOptionalString(params.title);
const body = params.body;
const base = normalizeOptionalString(params.base);
const head = normalizeOptionalString(params.head);
const draft = params.draft ?? false;
const fill = params.fill ?? false;
const reviewers = normalizePrIdentifierList(params.reviewer);
const assignees = normalizePrIdentifierList(params.assignee);
const labels = normalizePrIdentifierList(params.label);
if (!fill && !title) {
throw new ToolError("title is required unless fill is true");
}
if (fill && (title || body !== undefined)) {
throw new ToolError("fill is mutually exclusive with title and body");
}
const args = ["pr", "create"];
appendRepoFlag(args, repo);
if (title) args.push("--title", title);
if (base) args.push("--base", base);
if (head) args.push("--head", head);
if (draft) args.push("--draft");
if (fill) args.push("--fill");
for (const reviewer of reviewers) args.push("--reviewer", reviewer);
for (const assignee of assignees) args.push("--assignee", assignee);
for (const label of labels) args.push("--label", label);
let bodyDir: string | undefined;
try {
if (!fill) {
if (body !== undefined && body.length > 0) {
// Route through a temp file so multi-KB bodies stay clear of any
// argv-length limits and shell-quoting hazards on uncommon platforms.
bodyDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-pr-body-"));
const bodyFile = path.join(bodyDir, "body.md");
await Bun.write(bodyFile, body);
args.push("--body-file", bodyFile);
} else {
// Avoid gh dropping into an interactive editor when no body is given.
args.push("--body", "");
}
}
const output = await git.github.text(session.cwd, args, signal, {
repoProvided: Boolean(repo),
});
const url =
output
.split("\n")
.map(line => line.trim())
.find(line => line.startsWith("https://github.com/")) ?? output.trim();
const parsed = parsePullRequestUrl(url);
const resolvedRepo = repo ?? parsed.repo;
let prView: GhPrViewData | undefined;
if (resolvedRepo && parsed.prNumber !== undefined) {
try {
prView = await git.github.json<GhPrViewData>(
session.cwd,
[
"pr",
"view",
String(parsed.prNumber),
"--repo",
resolvedRepo,
"--json",
GH_PR_FIELDS_NO_COMMENTS.join(","),
],
signal,
{ repoProvided: true },
);
} catch {
// Best-effort summary; PR creation already succeeded.
}
}
const text = formatPrCreateResult({
url,
prNumber: parsed.prNumber,
data: prView,
title,
base,
head,
draft,
});
return buildTextResult(text, url || prView?.url);
} finally {
if (bodyDir) {
await fs.rm(bodyDir, { recursive: true, force: true }).catch(() => {});
}
}
}
export function formatPrCreateResult(options: {
url: string;
prNumber?: number;
data?: GhPrViewData;
title?: string;
base?: string;
head?: string;
draft?: boolean;
}): string {
const number = options.prNumber ?? options.data?.number;
const headerTitle = options.data?.title ?? options.title ?? "Untitled";
const header =
number !== undefined
? `# Created Pull Request #${number}: ${headerTitle}`
: `# Created Pull Request: ${headerTitle}`;
const lines: string[] = [header, ""];
pushLine(lines, "URL", options.url || options.data?.url);
pushLine(lines, "State", options.data?.state);
pushLine(lines, "Draft", options.data?.isDraft ?? options.draft);
pushLine(lines, "Base", options.data?.baseRefName ?? options.base);
pushLine(lines, "Head", options.data?.headRefName ?? options.head);
pushLine(lines, "Author", formatAuthor(options.data?.author));
pushLine(lines, "Created", options.data?.createdAt);
pushLine(lines, "Labels", formatLabels(options.data?.labels));
const bodyText = normalizeText(options.data?.body);
if (bodyText) {
lines.push("");
lines.push("## Body");
lines.push("");
lines.push(bodyText);
}
return lines.join("\n").trim();
}