Pin the PR version a run checks out (#1915)

Squash merge of 2 commits
This commit is contained in:
Octavian Guzu
2026-10-23 09:26:23 -07:00
committed by GitHub
parent ae68417a74
commit 8d70a0dd26
2 changed files with 105 additions and 19 deletions
+75 -17
View File
@@ -9,6 +9,8 @@
import { $ } from "bun";
import { execFileSync } from "child_process";
import type { ParsedGitHubContext } from "../context";
import { isPullRequestEvent } from "../context";
import { retryWithBackoff } from "../../utils/retry";
import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
@@ -156,6 +158,7 @@ export async function setupBranch(
octokits: Octokits,
githubData: FetchDataResult,
context: ParsedGitHubContext,
triggerTime?: string,
): Promise<BranchInfo> {
const { owner, repo } = context.repository;
const entityNumber = context.entityNumber;
@@ -177,6 +180,7 @@ export async function setupBranch(
console.log("This is an open PR, checking out PR branch...");
const branchName = prData.headRefName;
const headSha = prData.headRefOid;
// Determine optimal fetch depth based on PR commit count, with a minimum
// of 20. Only applied to a checkout that is already shallow — see
@@ -195,25 +199,56 @@ export async function setupBranch(
// Validate branch names before use to prevent command injection
validateBranchName(branchName);
if (!/^[0-9a-f]{40}$/.test(headSha)) {
throw new Error(
`Unexpected head SHA for PR #${entityNumber}: ${headSha}`,
);
}
// For cross-repository (fork) PRs, fetch via the pull ref since the
// branch only exists on the fork's remote, not on origin.
if (prData.isCrossRepository) {
console.log(
`PR #${entityNumber} is from a fork, fetching via refs/pull/${entityNumber}/head...`,
if (isPullRequestEvent(context)) {
const eventSha = context.payload.pull_request.head.sha;
if (eventSha !== headSha) {
throw new Error(
`PR #${entityNumber} was updated after the trigger (${eventSha} -> ${headSha}). Push again or mention Claude to run on the latest version.`,
);
}
} else if (triggerTime) {
if (!prData.headRepository) {
throw new Error(
`PR #${entityNumber}: head repository is not available, cannot pin the PR version`,
);
}
const pushedAt = await headPushedAt(
octokits,
{
owner: prData.headRepository.owner.login,
repo: prData.headRepository.name,
},
branchName,
headSha,
);
if (new Date(pushedAt).getTime() >= new Date(triggerTime).getTime()) {
throw new Error(
`PR #${entityNumber} was updated after the trigger (${pushedAt} >= ${triggerTime}). Mention Claude again to run on the latest version.`,
);
}
}
const localBranch = prData.isCrossRepository
? `pr-${entityNumber}`
: branchName;
console.log(`PR #${entityNumber}: pinning ${localBranch} to ${headSha}`);
execGit(["fetch", "origin", ...depthArgs, headSha]);
execGit(["checkout", "-B", localBranch, headSha, "--"]);
const checkedOut = execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (checkedOut !== headSha) {
throw new Error(
`PR #${entityNumber}: checked out ${checkedOut}, expected ${headSha}`,
);
execGit([
"fetch",
"origin",
...depthArgs,
`pull/${entityNumber}/head:${branchName}`,
]);
} else {
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
// Using execFileSync instead of shell template literals for security
execGit(["fetch", "origin", ...depthArgs, branchName]);
}
execGit(["checkout", branchName, "--"]);
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
@@ -223,7 +258,7 @@ export async function setupBranch(
return {
baseBranch,
currentBranch: branchName,
currentBranch: localBranch,
};
}
}
@@ -350,3 +385,26 @@ export async function setupBranch(
process.exit(1);
}
}
async function headPushedAt(
octokits: Octokits,
headRepo: { owner: string; repo: string },
branchName: string,
headSha: string,
): Promise<string> {
const { data } = await retryWithBackoff(() =>
octokits.rest.request("GET /repos/{owner}/{repo}/activity", {
owner: headRepo.owner,
repo: headRepo.repo,
ref: `refs/heads/${branchName}`,
per_page: 1,
}),
);
const latest = (data as Array<{ after: string; timestamp: string }>)[0];
if (!latest || latest.after !== headSha) {
throw new Error(
`Could not determine when ${headSha} was pushed to ${headRepo.owner}/${headRepo.repo}:${branchName}`,
);
}
return latest.timestamp;
}
+30 -2
View File
@@ -14,7 +14,12 @@ import {
extractOriginalBody,
} from "../../github/data/fetcher";
import { createPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context";
import {
isEntityContext,
isIssueCommentEvent,
isPullRequestReviewCommentEvent,
} from "../../github/context";
import { updateClaudeComment } from "../../github/operations/comments/update-claude-comment";
import type { GitHubContext } from "../../github/context";
import type { Octokits } from "../../github/api/client";
import { parseAllowedTools } from "../agent/parse-tools";
@@ -64,7 +69,30 @@ export async function prepareTagMode({
});
// Setup branch
const branchInfo = await setupBranch(octokit, githubData, context);
const editedAt =
context.eventAction === "edited" &&
(isIssueCommentEvent(context) || isPullRequestReviewCommentEvent(context))
? context.payload.comment.updated_at
: undefined;
let branchInfo;
try {
branchInfo = await setupBranch(
octokit,
githubData,
context,
editedAt || triggerTime,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await updateClaudeComment(octokit.rest, {
owner: context.repository.owner,
repo: context.repository.repo,
commentId,
body: message,
isPullRequestReviewComment: isPullRequestReviewCommentEvent(context),
}).catch((e) => console.error(`Failed to update tracking comment: ${e}`));
throw error;
}
// Configure git authentication
// SSH signing takes precedence if provided