Files
shockbot/mcp/checkout.ts
T
Colin McDonnell 36ac64a5b6 fix(oss-codex): prefer user's uploaded Codex auth over OSS subsidy (#844)
* fix(oss-codex): prefer user's uploaded Codex auth over OSS subsidy

OSS-allowlisted repos with `CODEX_AUTH_JSON` uploaded via `pullfrog auth
codex` were still being routed through the OSS OpenRouter subsidy because
two paths ignored managed credentials:

- `hasProviderKey()` only checked `provider.envVars`, so an `openai/*`
  model with only `CODEX_AUTH_JSON` present silently fell back to
  `opencode/big-pickle` via `selectFallbackModelIfNeeded` — the maintainer
  saw "opencode/big-pickle (resolved from openai/gpt)" on CI even though
  Codex was configured.
- `run-context` set `proxyModel` for every OSS run unconditionally, which
  the action runtime threads through `payload.proxyModel` and uses to
  overwrite `OPENROUTER_API_KEY`. Even if `big-pickle` fallback hadn't
  fired, the runner would consume the $10 OSS subsidy key instead of the
  user's ChatGPT subscription.

Fix:

- Add `getModelAuthEnvVars()` covering both `envVars` and
  `managedCredentials` in `action/utils/apiKeys.ts`; route
  `hasProviderKey` + `validateAgentApiKey` through it.
- `run-context` now skips `proxyModel` for OSS repos when the configured
  model's provider has matching auth in Pullfrog-stored account/repo
  secrets, so the runner authenticates directly with the user's Codex
  subscription (or any other user-provided provider auth).

Triggered by mrlubos (`hey-api/openapi-ts`). Companion follow-up tracked
for the opaque "(no error message)" classifier swallow that masked the
OSS $10 cap exhaustion on PR #3872 runs 25815370844 + 25815443234.

* fix(oss): force Kimi K2 for OSS proxy + hide picker in console UI

OSS-funded runs were resolving `repo.model` through OpenRouter, so a
single Opus / GPT-5.5 run could burn an entire `oss_subsidy` key against
the per-key cap and crash mid-stream (e.g. `hey-api/openapi-ts` PR #3872
runs `25815370844` + `25815443234`, ~$9.20 each on a single key).

Force `DEFAULT_PROXY_MODEL` (Kimi K2.6 — ~10-50× cheaper) for every OSS
proxy mint, regardless of `repo.model`. Per-run spend stays bounded
within the cap by structure, not by hope. `repo.model` stays in the DB
unchanged — overriding at runtime means leaving the program restores the
user's prior pick without a migration.

UI: hide the model picker entirely on OSS repos in `AgentSettings`. The
field is effectively inert until the repo leaves the program, so
exposing it as if it were live was misleading. Replaced with a banner
naming Kimi K2 and pointing to `pullfrog auth …` as the opt-out path —
that lands the user on the existing #844 bug-2 branch (Pullfrog-stored
auth suppresses the OSS proxy entirely; runner uses user credentials
+ their preferred model).

ModelCostsInfo already has its own `isOss` branch for the cost copy, so
that section is unchanged.

* fix(oss): lowercase comment casing per AGENTS.md

* fix(oss): revert banner copy to 'It's on us.' framing per review

Maintainer felt 'Kimi K2' as the banner headline lost the warm 'we've
got you covered' framing that the existing OSS cost banner uses. Restore
'It's on us.' as the headline, move the model name into the body where
it explains the hardcoded choice and points to the opt-out (pullfrog
auth codex / account secret).

* docs(agents): screenshots must be of the live route, never synthetic

Caught myself building a temp `/dev/oss-ui-preview` route with hardcoded
JSX copy-pasted from the real component just to grab a screenshot — the
result told us nothing about whether the actual integrated UI worked,
and the user (rightly) called it out as a waste. Strengthen the rule:
screenshots must come from the live route in the running app, driven by
the actual component tree and real props. Note the GH OAuth interstitial
gotcha so the next agent gets through Clerk → GitHub sign-in on the
first try instead of bailing to a fake render. Also bans side-by-side
comparison screenshots unless explicitly requested.

* fix(oss): one 'It's on us.' banner, not two

OSS Agent settings was showing the message twice — once in the Model
section, once in the Model costs section right below it. Fold the cost
coverage into the model banner ('at no cost to you' + the spend stat)
and hide the Model costs subsection entirely for OSS. ModelCostsInfo
no longer needs `isOss` / `ossSpendThisMonthUsd` props — call site is
gated, so the OSS branch is dead. Removed it and the now-unused props.

Non-OSS rendering is unchanged: full Model picker + Model costs
subsection with Router / BYOK branches.

* feat(action): corepack-aware package manager provisioning before setup

customer setup scripts that did `npm i -g pnpm && pnpm install` were
installing whatever pnpm "latest" happens to be on the day the run fires,
not what the repo declares — and pnpm 11.3 silently writes a new
`packageManagerDependencies` block into lockfiles, which the agent's
"always push changes" rule then packages into a noisy PR (see #844).

resolve the project's pnpm/yarn pin from `package.json` (honoring pnpm
11+ precedence: `devEngines.packageManager` over `packageManager`) and
activate it via `corepack prepare ... --activate` BEFORE the setup hook
runs. corepack is bundled with node, so this is a no-op on managed
infra; failure (no corepack, no network, range-only version) degrades
to a warning and the existing PATH binary still runs.

also replaces the legacy `npm install -g <pm>@<v>` path in prep with
the same helper so behavior is consistent end-to-end. bun/deno still
use the legacy installer because corepack doesn't ship shims for them.

* chore(console): drop 'npm i -g pnpm' anti-pattern from setup-script placeholder

the suggested example trained customers to install pnpm unpinned, which
silently picks up whatever's latest at run time. that's exactly the
behavior #844 traced lockfile drift back to. now that prep handles
package-manager provisioning via corepack from the repo's declared pin,
the placeholder is just a frozen-lockfile install — load-bearing only
when the repo wants `pnpm install` to actually run (prep already does
that), but a much safer default for customers who do paste it in.

* refactor(action): introspect opencode models for BYOK detection

Replace the static `provider.envVars + provider.managedCredentials`
catalog gate in `selectFallbackModelIfNeeded` + `validateAgentApiKey`
with two `opencode models` captures around the auth merge:

  - `captureBaselineModels` BEFORE dbSecrets + Codex auth.json
  - `captureAuthorizedModels` AFTER both

The authorized set is the authoritative source for "can OpenCode route
this model" — strictly more accurate than the catalog, which can miss
new auth shapes (Codex was one, there will be more). The diff between
baseline and authorized is logged as `BYOK auth enabled N model(s)`
for operator visibility.

Sequencing changes in main.ts:

  - `createTempDirectory` hoisted out of the try block so
    `PULLFROG_TEMP_DIR` is set before the early opencode install
  - `agents.opencode.install()` + baseline capture before dbSecrets
  - `installCodexAuth()` hoisted up (idempotent — agent re-calls it
    inside run() and writes the same file)
  - authorized capture after Codex auth.json materializes
  - fallback + validateAgentApiKey receive the authorized set as a
    parameter; tests inject directly with no mocks

Deleted: `hasProviderKey`, `getModelAuthEnvVars`, `knownApiKeys` in
`action/utils/apiKeys.ts` (only `selectFallbackModelIfNeeded` consumed
them, and PR #844's catalog-extension fix is superseded by introspection).
`getModelEnvVars` / `getModelManagedCredentials` stay exported for UI
and the server-side OSS proxy heuristic in run-context/route.ts.

For the claude agent path, validateAgentApiKey keeps the static
single-provider check on `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`
— `opencode models` is opencode-specific. validateBedrockSetup /
validateVertexSetup also stay; they cover region/location/model-id
which `opencode models` doesn't catch.

When fallback engages, the post-fallback model is the guaranteed-free
`opencode/big-pickle`, so validateAgentApiKey is skipped — the fallback
gate already authoritatively decided "this model is OK to run".

* test(oss): temp add preview-844 to ossRepos for O4 e2e

* Revert "test(oss): temp add preview-844 to ossRepos for O4 e2e"

This reverts commit 8167e560126b2ac516c32ba1c63c36aa32ae4019.

* test(oss): temp add preview-844 to ossRepos for O5 e2e

* fix(action): skip validateAgentApiKey when proxyModel is set

The new opencode-models BYOK introspection in PR #844 captures the
authorized set BEFORE runProxyResolution mints OPENROUTER_API_KEY, so
the proxy slug (e.g. `openrouter/moonshotai/kimi-k2.6`) is never in
the set. validateAgentApiKey then spuriously threw "no API key found"
on every OSS run, even though the proxy key was minted correctly and
the inference would have worked.

Mirrors the analogous skip in `selectFallbackModelIfNeeded`: when
proxyModel is set, the server-side gate (`run-context/route.ts`) is
the authority and the proxy mint itself is the validation.

Caught by O5 e2e on `pullfrog/preview-844-heyapi-oss-bug`.

* Revert "test(oss): temp add preview-844 to ossRepos for O5 e2e"

This reverts commit 3bae075ceeb188ee272c45c13b5080e15bcd00a5.

* fix(action): discard hook-generated tracked-file drift before agent sees it

addresses bug 3 in #844: customer setup/post-checkout hooks like `pnpm install`
or `corepack prepare` left the working tree dirty (e.g. `M pnpm-lock.yaml`),
the agent took the prompt's "must push" rule literally, opened a spurious bot
PR for the lockfile drift, and we ate runs+spend on noise.

after each setup / post-checkout hook (opt-in via `normalizeWorkingTreeAfter`),
discard tracked-file mods with `git restore --staged --worktree .`. untracked
files are preserved — a hook that materializes a `.env` from a template, or
emits codegen output, stays visible to the agent.

guarded by a pre-hook `git status --porcelain` snapshot: if the tree was
already dirty before the hook ran (shouldn't happen — setup runs before any
working-tree writes; checkout_pr refuses to run dirty), we warn and skip the
discard rather than clobber whatever was there.

prepush hook (action/mcp/git.ts) intentionally does NOT opt in — its job is
to read the about-to-be-pushed state, not normalize it.

* test(oss): temp add preview-844 to ossRepos for bug 3 e2e (revert before merge)

* fix(action): skip eager pnpm/npm/etc install when no lockfile exists

second half of bug 3 in #844. the eager prep step assumed `pnpm install
--frozen-lockfile` (and equivalents) would fail cleanly without a lockfile,
leaving the tree untouched. that assumption is false for pnpm 11.1.1 against
a no-deps `package.json`: the command reports "Already up to date" with exit 0
AND silently materializes an empty `pnpm-lock.yaml` despite the `--frozen-lockfile`
flag. the resulting untracked file trips the post-run dirty-tree gate, the
agent reads it as "must push uncommitted work", and a spurious
"Add pnpm lockfile" PR lands. smoking gun: pullfrog/preview-844-heyapi-oss-bug
PRs #1/#2/#3, all auto-opened by the bot against a repo that contains nothing
but a one-line README + a no-deps package.json.

guard explicitly with an `existsSync` per manager. if the lockfile is absent,
skip eager prep entirely with an info log; the agent can install on demand
via the `setup` lifecycle hook (which non-frozen `pnpm install` would handle
correctly), or just leave deps uninstalled when the prompt doesn't need them
(e.g. the O5 "tell me a joke" path).

orthogonal to the lifecycle-hook normalization in 0051bd2a — together they
cover the full bug 3 surface:
  - eager prep can't materialize a lockfile (this commit)
  - setup/postCheckout hooks that rewrite tracked files have the drift
    discarded before the agent sees it (prior commit)

* fix(action): address Pullfrog review on hook normalization

two fixes in `executeLifecycleHook` from review on f6f3b32:

1. pre-hook snapshot was `git status --porcelain` which counts untracked
   files; in practice any repo with pre-existing untracked content (e.g.
   `.plans/`, an ignored-but-not-yet-gitignored scratch dir, codegen
   artifacts) would trip the guard and silently skip normalization,
   defeating the fix. switch to `git diff --name-only HEAD` so the gate
   measures the same thing the discard targets — tracked-file mods only.
   pre-existing untracked files are safe regardless because `git restore
   --staged --worktree .` never touches them.

2. normalization fired only on the happy path; a hook that updated a
   lockfile then exploded on a peer-dep conflict left tracked drift for
   the agent. move the call into a `finally` so it runs on success,
   non-zero exit, timeout, AND spawn failure. the pre-hook guard still
   protects pre-existing work in every case.

* Revert "test(oss): temp add preview-844 to ossRepos for bug 3 e2e (revert before merge)"

This reverts commit f6f3b325d6bf9a1720754ed1d39d248dab76cfa8.

* fix(action): use detect lockfile strategy for eager-prep gate

addresses Pullfrog review on be3c207b. two findings, one root cause:

- the hardcoded LOCKFILE_BY_MANAGER map missed `bun.lockb` and
  `npm-shrinkwrap.json`, two managers' accepted lockfile variants.
- `existsSync(join(cwd, lockfile))` only checked the immediate directory,
  breaking monorepo subpackages where the lockfile lives at the workspace
  root.

both fall out by replacing the custom check with `detect({ strategies:
["lockfile"] })`. the detector already walks up the tree (subpackage →
workspace root) and recognizes every accepted lockfile name across all
managers it supports. restricting to the `lockfile` strategy is load-
bearing: the default strategy set also matches on `packageManager` /
`devEngines.packageManager` package.json fields, which would return
non-null and re-mask the very case we're trying to detect (declared
manager, no lockfile committed — the O5 / hey-api preview repro).

drops the LOCKFILE_BY_MANAGER map entirely; no need for a second
detect() call since the existing one was only used for `agent`
resolution and that consumer is now after the lockfile gate, where
`detected` is guaranteed non-null.
2026-05-28 00:26:35 +00:00

882 lines
35 KiB
TypeScript

import { createHash } from "node:crypto";
import { statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
import { $git, $gitFetchWithDeepen } from "../utils/gitAuth.ts";
import { executeLifecycleHook } from "../utils/lifecycle.ts";
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
import { retry } from "../utils/retry.ts";
import { $ } from "../utils/shell.ts";
import { rejectIfLeadingDash } from "./git.ts";
import { commentableLinesForFile } from "./review.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
export type FormatFilesResult = {
content: string;
toc: string;
};
export type FetchAndFormatPrDiffResult = FormatFilesResult & {
files: PullFile[];
};
/**
* formats PR files with explicit line numbers for each code line.
* preserves all original diff info (file headers, hunk headers) and adds:
* | OLD | NEW | TYPE | code
* returns both the formatted content and a TOC with line ranges per file.
*/
export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
const output: string[] = [];
const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
// calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
const tocHeaderSize = 1 + files.length + 2;
let currentLine = tocHeaderSize + 1;
for (const file of files) {
const fileStartLine = currentLine;
// file header
output.push(`diff --git a/${file.filename} b/${file.filename}`);
output.push(`--- a/${file.filename}`);
output.push(`+++ b/${file.filename}`);
currentLine += 3;
if (!file.patch) {
output.push("(binary file or no changes)");
output.push("");
currentLine += 2;
tocEntries.push({
filename: file.filename,
startLine: fileStartLine,
endLine: currentLine - 1,
});
continue;
}
// parse and format the patch with line numbers
const lines = file.patch.split("\n");
let oldLine = 0;
let newLine = 0;
for (const line of lines) {
// hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkMatch) {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
output.push(line); // pass through unchanged
currentLine++;
continue;
}
// code lines within hunks
const changeType = line[0] || " ";
const code = line.slice(1);
if (changeType === "-") {
// removed line: show old line number, no new line number
output.push(`| ${padNum(oldLine)} | | - | ${code}`);
oldLine++;
} else if (changeType === "+") {
// added line: no old line number, show new line number
output.push(`| | ${padNum(newLine)} | + | ${code}`);
newLine++;
} else if (changeType === " " || changeType === "\\") {
// context line or "\ No newline at end of file"
if (changeType === "\\") {
output.push(line); // pass through as-is
} else {
output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
oldLine++;
newLine++;
}
} else {
// unknown line type, pass through
output.push(line);
}
currentLine++;
}
output.push(""); // blank line between files
currentLine++;
tocEntries.push({
filename: file.filename,
startLine: fileStartLine,
endLine: currentLine - 1,
});
}
// build TOC. each entry includes the precomputed sha256 anchor used in
// github PR Files Changed URLs (#diff-<hex>), so the agent never needs to
// shell out to sha256sum.
const tocLines = [`## Files (${files.length})`];
for (const entry of tocEntries) {
const anchor = createHash("sha256").update(entry.filename).digest("hex");
tocLines.push(
`- ${entry.filename} → lines ${entry.startLine}-${entry.endLine} · diff-${anchor}`
);
}
tocLines.push("");
tocLines.push("---");
tocLines.push("");
const toc = tocLines.join("\n");
const content = toc + output.join("\n");
return { content, toc };
}
function padNum(n: number): string {
return n.toString().padStart(4, " ");
}
export const CheckoutPr = type({
pull_number: type.number.describe("the pull request number to checkout"),
});
export type CheckoutPrResult = {
success: true;
number: number;
title: string;
body: string | null;
base: string;
localBranch: string;
remoteBranch: string;
isFork: boolean;
maintainerCanModify: boolean;
url: string;
headRepo: string;
diffPath: string;
incrementalDiffPath?: string | undefined;
toc: string;
commitCount: number;
commitLog: string;
/** true when commitLog was capped because the PR has more commits than we render */
commitLogTruncated: boolean;
/** true when commit metadata could not be computed (e.g. base ref unreachable after shallow fetch). commitCount/commitLog are zero/empty in that case, not "no commits". */
commitLogUnavailable: boolean;
/** non-fatal warning from the post-checkout lifecycle hook, if any */
hookWarning?: string | undefined;
instructions: string;
};
/**
* fetches PR files from GitHub and formats them with line numbers and TOC.
* this is the core diff formatting logic, extracted for testability.
*/
export async function fetchAndFormatPrDiff(
ctx: ToolContext,
pullNumber: number
): Promise<FetchAndFormatPrDiffResult> {
const files = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: pullNumber,
per_page: 100,
});
return { ...formatFilesWithLineNumbers(files), files };
}
import { captureInitialHead, type GitContext } from "../utils/setup.ts";
export type PrData = {
number: number;
headSha: string;
headRef: string;
headRepoFullName: string;
baseRef: string;
baseRepoFullName: string;
maintainerCanModify: boolean;
};
type EnsureBeforeShaParams = {
sha: string;
octokit: Octokit;
owner: string;
repo: string;
gitToken: string;
isShallow: boolean;
};
type CreateTempBranchParams = {
octokit: Octokit;
owner: string;
repo: string;
ref: string;
sha: string;
};
async function createTempBranch(params: CreateTempBranchParams) {
const response = await params.octokit.rest.git.createRef({
owner: params.owner,
repo: params.repo,
ref: `refs/heads/${params.ref}`,
sha: params.sha,
});
return {
data: response.data,
async [Symbol.asyncDispose]() {
try {
await params.octokit.rest.git.deleteRef({
owner: params.owner,
repo: params.repo,
ref: `heads/${params.ref}`,
});
log.debug(`» deleted temp branch ${params.ref}`);
} catch (e) {
log.debug(
`» failed to delete temp branch ${params.ref}: ${e instanceof Error ? e.message : String(e)}`
);
}
},
};
}
async function ensureBeforeShaReachable(params: EnsureBeforeShaParams): Promise<boolean> {
try {
$("git", ["cat-file", "-t", params.sha], { log: false });
log.debug(`» before_sha ${params.sha.slice(0, 7)} is reachable`);
return true;
} catch {
// not available locally — create a temporary branch to fetch it
}
const tempBranch = `pullfrog/tmp/${params.sha.slice(0, 12)}`;
try {
log.debug(`» before_sha ${params.sha.slice(0, 7)} not reachable, creating temp branch...`);
await using _ref = await createTempBranch({
octokit: params.octokit,
owner: params.owner,
repo: params.repo,
sha: params.sha,
ref: tempBranch,
});
await $gitFetchWithDeepen(
["--no-tags", ...(params.isShallow ? ["--depth=1"] : []), "origin", tempBranch],
{ token: params.gitToken },
`before_sha temp branch ${tempBranch}`
);
log.debug(`» fetched before_sha via temp branch ${tempBranch}`);
return true;
} catch (e) {
log.debug(`» failed to fetch before_sha: ${e instanceof Error ? e.message : String(e)}`);
return false;
}
}
type CheckoutPrBranchParams = GitContext & {
beforeSha?: string | undefined;
};
// stale lock files left over from a crashed/cancelled prior git process block
// every subsequent fetch with `Unable to create '<path>': File exists`. only
// sweep locks older than this threshold so we never race a concurrent
// legitimate git op that's holding the lock.
const STALE_LOCK_AGE_MS = 30_000;
// PR head refs (refs/pull/N/head) sometimes lag the pull_request.opened
// webhook by a few seconds. retry the missing-ref case with backoff
// before giving up — see issue #591.
const PULL_REF_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
const PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
const GIT_LOCK_PATHS = [
".git/shallow.lock",
".git/index.lock",
".git/objects/maintenance.lock",
] as const;
function cleanupStaleGitLocks(): void {
const now = Date.now();
for (const relPath of GIT_LOCK_PATHS) {
let mtimeMs: number;
try {
mtimeMs = statSync(relPath).mtimeMs;
} catch {
continue;
}
if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
try {
unlinkSync(relPath);
log.warning(`» removed stale ${relPath} from prior run`);
} catch (e) {
log.debug(
`» failed to remove stale ${relPath}: ${e instanceof Error ? e.message : String(e)}`
);
}
}
}
/**
* Returns false when a PR's current state diverges from what we dispatched
* on (closed/merged, or head SHA differs from pr.headSha). Used to short-
* circuit the pull/N/head retry loop when the ref is missing because the
* PR has moved on, not because of a webhook race.
*
* Network failures here are treated as "still valid" — we'd rather burn the
* retry budget than wrongly abort on a transient API blip.
*
* Note: this answers "should we keep trying?", NOT "will the next fetch
* succeed?". `pulls.get` (REST API) and `pull/N/head` (git ref) are served
* by independent GitHub replicas with their own propagation lag, so
* `pulls.get` reporting an open PR with a matching head SHA does not
* guarantee the git ref is yet visible — and vice versa (see issue #591
* for the original webhook-vs-ref replication-lag context).
*/
async function isPullRequestStillDispatchable(args: {
octokit: Octokit;
owner: string;
repo: string;
pr: PrData;
}): Promise<boolean> {
try {
const { data } = await args.octokit.rest.pulls.get({
owner: args.owner,
repo: args.repo,
pull_number: args.pr.number,
});
if (data.state !== "open") return false;
if (data.head.sha !== args.pr.headSha) return false;
return true;
} catch {
// lenient — don't abort on API hiccups
return true;
}
}
/**
* Throws the friendly clean-abort error when the PR has moved on since
* dispatch. Wraps `isPullRequestStillDispatchable` so the abort message
* lives in one place and is invoked from the inner `catch` around the
* `pull/N/head` fetch on every missing-ref failure.
*/
async function abortIfPullRequestMoved(args: {
octokit: Octokit;
owner: string;
repo: string;
pr: PrData;
}): Promise<void> {
const stillValid = await isPullRequestStillDispatchable(args);
if (stillValid) return;
throw new Error(
`PR #${args.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout — re-trigger the run if this PR is still active.`
);
}
/**
* Shared helper to checkout a PR branch and configure fork remotes.
* Assumes origin remote is already configured with authentication.
* Updates toolState.issueNumber, toolState.checkoutSha, and toolState.pushUrl (for fork PRs).
*/
export async function checkoutPrBranch(
pr: PrData,
params: CheckoutPrBranchParams
): Promise<{ hookWarning?: string | undefined }> {
const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
log.info(`» checking out PR #${pr.number}...`);
// SECURITY: PR ref names come from GitHub and are attacker-controlled on
// forks (the PR author picks headRef freely, and baseRef could be a
// maliciously-named branch on the target repo). reject leading-dash names
// before they reach any git command — without this, a ref like
// "-upload-pack=evil" fed into `git fetch origin <ref>` would be parsed as
// a flag, not a refspec.
rejectIfLeadingDash(pr.baseRef, "PR base ref");
rejectIfLeadingDash(pr.headRef, "PR head ref");
// self-hosted runners and cancelled jobs frequently leave stale .git/*.lock
// files behind. without this sweep, the first fetch below aborts with
// `Unable to create '.git/shallow.lock': File exists` and the agent has to
// shell out to `rm -f` (issue #564).
cleanupStaleGitLocks();
const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
// always use pr-{number} as local branch name for consistency
// this avoids naming conflicts and makes push config simpler
const localBranch = `pr-${pr.number}`;
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
// fetch base branch so origin/<base> exists for diff operations.
// wrap with deepen-retry: on shallow clones (the actions/checkout default
// is depth=1), repos with deep PR ancestry can't reach the baseRef tip in
// a single round trip, surfacing as `Could not read <sha>` / `remote did
// not send all necessary objects` (issue #656).
log.debug(`» fetching base branch (${pr.baseRef})...`);
await $gitFetchWithDeepen(
["--no-tags", "origin", pr.baseRef],
{ token: gitToken },
`base branch ${pr.baseRef}`
);
// alreadyOnBranch only matches for repeated checkout_pr calls for the same PR in one session
// (without the tip moving), or if an external setup already checked out the PR head.
// normal PR-triggered runs won't match here — actions/checkout lands on a synthesized
// merge commit whose SHA differs from pr.headSha.
//
// so the fetch+checkout block below will almost always execute, and the fetched HEAD
// might differ from pr.headSha. toolState.checkoutSha is set after to capture the actual SHA.
if (!alreadyOnBranch) {
// checkout base branch first to avoid "refusing to fetch into current branch" error
// -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs).
// two transient classes wrap this fetch:
// - shallow-unreachable (`Could not read <sha>` etc.) — handled by the
// inner `$gitFetchWithDeepen` deepen-retry (one shot, see issue #656)
// - pull/N/head webhook race (`couldn't find remote ref pull/N/head`) —
// handled by the outer retry below (see issue #591)
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
await retry(
async () => {
try {
await $gitFetchWithDeepen(
["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
{ token: gitToken },
`PR #${pr.number}`
);
} catch (e) {
// on the webhook race, check whether the PR still matches what we
// dispatched on. if it's been closed/merged or the head SHA moved,
// no amount of retrying will populate the expected ref — surface a
// clean abort error instead of burning the full retry budget.
const msg = e instanceof Error ? e.message : String(e);
if (PULL_REF_MISSING_PATTERN.test(msg)) {
await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
}
throw e;
}
},
{
delaysMs: PULL_REF_RETRY_DELAYS_MS,
label: `pull/${pr.number}/head fetch`,
shouldRetry: (e) =>
PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
}
);
// checkout the branch
$("git", ["checkout", localBranch], { log: false });
log.debug(`» checked out PR #${pr.number}`);
// make sure toolState.checkoutSha is set to the actual checked-out SHA (which might be different from pr.headSha)
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
}
const beforeShaReachable = beforeSha
? await ensureBeforeShaReachable({
sha: beforeSha,
octokit,
owner,
repo: name,
gitToken,
isShallow,
})
: false;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
// computed after checkout so compareCommits uses the actual checked-out SHA.
if (isShallow) {
let deepenDepth = 0;
try {
// ahead_by = PR commits past merge base, behind_by = base commits past merge base.
// --deepen extends ALL shallow roots equally (can't deepen a single branch),
// so we need the max across both the PR head and before_sha to ensure all
// three points (base, head, before_sha) reach the merge base in a single deepen call.
const [prComparison, beforeShaComparison] = await Promise.all([
octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: toolState.checkoutSha,
}),
beforeSha && beforeShaReachable
? octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: beforeSha,
})
: undefined,
]);
deepenDepth =
Math.max(
prComparison.data.ahead_by,
prComparison.data.behind_by,
beforeShaComparison?.data.ahead_by ?? 0,
beforeShaComparison?.data.behind_by ?? 0
) + 10;
log.debug(
`» PR: ${prComparison.data.ahead_by} ahead / ${prComparison.data.behind_by} behind` +
(beforeShaComparison
? `, before_sha: ${beforeShaComparison.data.ahead_by} ahead / ${beforeShaComparison.data.behind_by} behind`
: "") +
`, deepen by ${deepenDepth}`
);
} catch {
deepenDepth = 1000;
log.debug(`» compare API failed, falling back to --deepen=${deepenDepth}`);
}
// deepen after both branches are fetched so the merge base is reachable from both sides
if (deepenDepth) {
log.debug(`» deepening by ${deepenDepth} to reach merge base...`);
await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
token: gitToken,
});
}
}
// configure push remote for this branch
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
if (isFork) {
const remoteName = `pr-${pr.number}`;
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
const forkUrl = `https://github.com/${pr.headRepoFullName}.git`;
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
try {
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
log.debug(`» added remote '${remoteName}' for fork ${pr.headRepoFullName}`);
} catch {
// remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
log.debug(`» updated remote '${remoteName}' for fork ${pr.headRepoFullName}`);
}
// set branch push config so `git push` knows where to push
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
// set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
// warn if maintainer can't modify (push will likely fail)
if (!pr.maintainerCanModify) {
log.warning(
`» fork PR has maintainer_can_modify=false - push operations will fail. ` +
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
);
}
} else {
// for same-repo PRs, push to origin
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
}
// update toolState
toolState.issueNumber = pr.number;
if (isFork) {
toolState.pushUrl = `https://github.com/${pr.headRepoFullName}.git`;
}
// store push destination so push_branch can use it directly
// git config is the primary mechanism, but toolState serves as a reliable fallback
// in case git config reads fail in certain environments
toolState.pushDest = {
remoteName: isFork ? `pr-${pr.number}` : "origin",
remoteBranch: pr.headRef,
localBranch,
};
// execute post-checkout lifecycle hook. soft-fail: surface the warning
// to the agent via the tool response instead of throwing, so a flaky or
// slightly-broken hook doesn't block checkout entirely.
const postCheckoutHook = await executeLifecycleHook({
event: "post-checkout",
script: params.postCheckoutScript,
normalizeWorkingTreeAfter: true,
});
return { hookWarning: postCheckoutHook.warning };
}
/**
* dedupes concurrent `checkout_pr` calls for the same PR. agents (notably
* Sonnet/Claude) occasionally emit duplicate parallel tool_use blocks for the
* same args in one turn; without this, both invocations race
* `checkoutPrBranch` against the same `.git/shallow.lock` and one fails with
* `File exists` (issue #642). cleared in `finally` so subsequent same-PR
* calls re-do the work normally.
*/
const inFlightCheckouts = new Map<number, Promise<CheckoutPrResult>>();
type InitialHead = NonNullable<ToolContext["toolState"]["initialHead"]>;
function headsEqual(a: InitialHead, b: InitialHead): boolean {
if (a.kind === "branch" && b.kind === "branch") return a.name === b.name;
if (a.kind === "detached" && b.kind === "detached") return a.sha === b.sha;
return false;
}
function describeHead(h: InitialHead): string {
if (h.kind === "branch") return `branch \`${h.name}\``;
return `detached HEAD \`${h.sha}\``;
}
export function CheckoutPrTool(ctx: ToolContext) {
const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => {
const prResponse = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const headRepo = prResponse.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
const pr: PrData = {
number: pull_number,
headSha: prResponse.data.head.sha,
headRef: prResponse.data.head.ref,
headRepoFullName: headRepo.full_name,
baseRef: prResponse.data.base.ref,
baseRepoFullName: prResponse.data.base.repo.full_name,
maintainerCanModify: prResponse.data.maintainer_can_modify,
};
const checkoutResult = await checkoutPrBranch(pr, {
octokit: ctx.octokit,
owner: ctx.repo.owner,
name: ctx.repo.name,
gitToken: ctx.gitToken,
toolState: ctx.toolState,
shell: ctx.payload.shell,
postCheckoutScript: ctx.postCheckoutScript,
beforeSha: ctx.toolState.beforeSha,
});
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
);
}
const headShort = ctx.toolState.checkoutSha!.slice(0, 7);
// compute incremental diff if we have a beforeSha to compare against
let incrementalDiffPath: string | undefined;
if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
const incremental = computeIncrementalDiff({
baseBranch: pr.baseRef,
beforeSha: ctx.toolState.beforeSha,
headSha: ctx.toolState.checkoutSha,
});
if (incremental) {
incrementalDiffPath = join(
tempDir,
`pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
);
writeFileSync(incrementalDiffPath, incremental);
log.info(
`» incremental diff computed (${incremental.length} bytes) → ${incrementalDiffPath}`
);
}
}
// fetch PR files and format with line numbers
const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
writeFileSync(diffPath, formatResult.content);
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
ctx.toolState.diffCoverage = createDiffCoverageState({
diffPath,
totalLines: countLines({ content: formatResult.content }),
toc: formatResult.toc,
previous: ctx.toolState.diffCoverage,
});
log.debug(
`» diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
);
// cache commentable-lines snapshot so review-time validation matches what
// GitHub will anchor to (commit_id=checkoutSha), even if the PR is updated
// between checkout and review.
const cached = new Map<string, ReturnType<typeof commentableLinesForFile>>();
for (const file of formatResult.files) {
cached.set(file.filename, commentableLinesForFile(file.patch));
}
ctx.toolState.commentableLinesByFile = cached;
ctx.toolState.commentableLinesPullNumber = pull_number;
ctx.toolState.commentableLinesCheckoutSha = ctx.toolState.checkoutSha;
const incrementalInstructions = incrementalDiffPath
? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
`(computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, ` +
`then use diffPath for full PR context. do NOT skip the incremental diff.`
: "";
// commit metadata relative to the PR base (e.g. main). use origin/<base>
// because the local base ref may not exist after a shallow fetch. cap
// the log so a PR with thousands of commits doesn't blow up the tool
// response. if the base ref can't be resolved (e.g. shallow fetch that
// didn't pull down origin/<base>), degrade gracefully rather than
// failing the whole checkout_pr call over metadata.
const COMMIT_LOG_MAX = 200;
const baseRange = `origin/${pr.baseRef}..HEAD`;
let commitCount = 0;
let commitLog = "";
let commitLogUnavailable = false;
try {
commitCount = parseInt(
$("git", ["rev-list", "--count", baseRange], { log: false }).trim() || "0",
10
);
commitLog = $("git", ["log", "--oneline", `--max-count=${COMMIT_LOG_MAX}`, baseRange], {
log: false,
});
} catch (err) {
commitLogUnavailable = true;
log.debug(
`» unable to compute commit metadata for ${baseRange}: ${err instanceof Error ? err.message : String(err)}`
);
}
const commitLogTruncated = commitCount > COMMIT_LOG_MAX;
const hookWarningInstructions = checkoutResult.hookWarning
? ` HOOK WARNING: the post-checkout lifecycle hook reported a non-fatal failure (see hookWarning). ` +
`decide whether to retry based on the guidance in that field before proceeding.`
: "";
const commitLogInstructions = commitLogUnavailable
? ` NOTE: commit metadata is partial (base ref unreachable, likely a shallow fetch). ` +
`commitCount/commitLog may be 0/empty or incomplete; treat them as "unknown" rather than "no commits", ` +
`and use \`git log\` directly if you need the full history.`
: commitLogTruncated
? ` NOTE: commitLog was capped at ${COMMIT_LOG_MAX} entries out of ${commitCount} commits; ` +
`use \`git log\` directly if you need the full history.`
: "";
return {
success: true,
number: prResponse.data.number,
title: prResponse.data.title,
body: prResponse.data.body,
base: pr.baseRef,
localBranch: `pr-${pull_number}`,
remoteBranch: `refs/heads/${pr.headRef}`,
isFork: pr.headRepoFullName !== pr.baseRepoFullName,
maintainerCanModify: pr.maintainerCanModify,
url: prResponse.data.html_url,
headRepo: pr.headRepoFullName,
diffPath,
incrementalDiffPath,
toc: formatResult.toc,
commitCount,
commitLog,
commitLogTruncated,
commitLogUnavailable,
hookWarning: checkoutResult.hookWarning,
instructions:
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
`use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. ` +
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
`review files selectively based on relevance rather than reading everything sequentially. ` +
`to inspect the PR's changed files, use diffPath — do NOT run \`git diff <base>..<head>\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. ` +
`\`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes — but PR review content MUST come from diffPath. ` +
`before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. ` +
`retry the same create_pull_request_review call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session. ` +
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
incrementalInstructions +
hookWarningInstructions +
commitLogInstructions,
} satisfies CheckoutPrResult;
};
return tool({
name: "checkout_pr",
timeoutMs: 600_000,
description:
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
"Returns diffPath pointing to the formatted diff file. " +
"Example: `checkout_pr({ pull_number: 1234 })`. " +
"Large repos can take several minutes — wait for the call to finish; do not treat a slow response as failure. " +
"If you see `MCP error -32001: Request timed out`, retry the same call without touching git lock files first — that error is a client-side abort. " +
"If the retry then reports `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, remove those lock files via the shell tool and retry again.",
parameters: CheckoutPr,
execute: execute(async ({ pull_number }) => {
const inFlight = inFlightCheckouts.get(pull_number);
if (inFlight) {
log.info(`» checkout_pr({pull_number:${pull_number}}) already in flight — sharing result`);
return inFlight;
}
// unconditional refusal: any dirty working tree blocks checkout_pr, even
// when HEAD is already on pr-N. no stashing, no live-HEAD escape hatch.
// shared-cwd subagents made "carry edits along" semantics dangerous
// (zed-industries/cloud, 2026-05-18) — forcing commit/discard before
// any PR-context op eliminates the entire carry-forward failure class.
const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
if (dirty) {
throw new Error(
`cannot checkout PR #${pull_number} while the working tree has uncommitted changes. ` +
`commit (then push if needed), or discard with \`git restore --staged --worktree .\` / \`git clean -fd\` before retrying. ` +
`this refusal is unconditional — even re-checking-out the PR you're already on is refused, ` +
`because shared-working-tree subagents make carry-forward edits unsafe. dirty paths:\n${dirty}`
);
}
// initial-branch invariant: the only sanctioned HEAD positions for a
// checkout_pr call are (a) the run-entry HEAD captured by setupGit, or
// (b) `pr-${pull_number}` for idempotent same-PR re-checkout (e.g.
// re-fetch after the PR head moved). anything else means a subagent
// silently parked HEAD on another PR, which is the zed-industries/cloud
// (2026-05-18) cross-PR clobber shape. uses the same live probe (not
// toolState.issueNumber, poisonable per the PR #796 review) and
// discriminates branch vs detached so detached-entry runs don't get a
// trivial "any future detached state matches" carve-out.
const initialHead = ctx.toolState.initialHead;
if (initialHead) {
const currentHead = captureInitialHead(process.cwd());
const targetBranch = `pr-${pull_number}`;
const onTarget = currentHead.kind === "branch" && currentHead.name === targetBranch;
const onInitial = headsEqual(currentHead, initialHead);
if (!onTarget && !onInitial) {
const recoverCmd =
initialHead.kind === "branch"
? `git checkout ${initialHead.name}`
: `git checkout ${initialHead.sha}`;
throw new Error(
`cannot checkout PR #${pull_number} from ${describeHead(currentHead)}. ` +
`the only sanctioned HEAD positions for checkout_pr are the run-entry HEAD ` +
`(${describeHead(initialHead)}) or the target PR's branch (\`${targetBranch}\`, idempotent re-checkout). ` +
`recover with \`${recoverCmd}\` first — if that would carry uncommitted ` +
`work along, commit or discard it (\`git restore --staged --worktree .\` / \`git clean -fd\`) before switching. ` +
`routing around this via the \`git\` tool's \`checkout\`/\`switch\` subcommands is not sanctioned: ` +
`this guard exists to prevent the shared-working-tree cross-PR clobber pattern from the ` +
`zed-industries/cloud (2026-05-18) incident.`
);
}
}
const promise = runCheckout(pull_number);
inFlightCheckouts.set(pull_number, promise);
try {
return await promise;
} finally {
inFlightCheckouts.delete(pull_number);
}
}),
});
}