diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 880c91ce..bfd68ea7 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -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 { 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 { + 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; +} diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 54e838a4..e827b416 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -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