checkout_pr: retry missing pull/N/head ref with PR-state guard (#627)
* checkout_pr: retry missing pull/N/head ref with PR-state guard Co-authored-by: Cursor <cursoragent@cursor.com> * checkout_pr tests: satisfy ToolState required fields Co-authored-by: Cursor <cursoragent@cursor.com> * checkout_pr: tighten retry-helper semantics (anneal round 1) Co-authored-by: Cursor <cursoragent@cursor.com> * checkout_pr: use retry util, drop retry tests * Update action/mcp/checkout.ts Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
10aeaf8c11
commit
10590993f4
+88
-3
@@ -8,6 +8,7 @@ import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
|
|||||||
import { $git } from "../utils/gitAuth.ts";
|
import { $git } from "../utils/gitAuth.ts";
|
||||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||||
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
||||||
|
import { retry } from "../utils/retry.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { rejectIfLeadingDash } from "./git.ts";
|
import { rejectIfLeadingDash } from "./git.ts";
|
||||||
import { commentableLinesForFile } from "./review.ts";
|
import { commentableLinesForFile } from "./review.ts";
|
||||||
@@ -281,6 +282,12 @@ type CheckoutPrBranchParams = GitContext & {
|
|||||||
// legitimate git op that's holding the lock.
|
// legitimate git op that's holding the lock.
|
||||||
const STALE_LOCK_AGE_MS = 30_000;
|
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 = [
|
const GIT_LOCK_PATHS = [
|
||||||
".git/shallow.lock",
|
".git/shallow.lock",
|
||||||
".git/index.lock",
|
".git/index.lock",
|
||||||
@@ -308,6 +315,62 @@ function cleanupStaleGitLocks(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Shared helper to checkout a PR branch and configure fork remotes.
|
||||||
* Assumes origin remote is already configured with authentication.
|
* Assumes origin remote is already configured with authentication.
|
||||||
@@ -365,9 +428,31 @@ export async function checkoutPrBranch(
|
|||||||
|
|
||||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||||
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
||||||
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
await retry(
|
||||||
token: gitToken,
|
async () => {
|
||||||
});
|
try {
|
||||||
|
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
||||||
|
token: gitToken,
|
||||||
|
});
|
||||||
|
} 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
|
// checkout the branch
|
||||||
$("git", ["checkout", localBranch], { log: false });
|
$("git", ["checkout", localBranch], { log: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user