@@ -70,24 +70,52 @@ jobs:
7070 const { owner, repo } = context.repo;
7171 const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
7272
73+ // head.repo can be null on fork PRs from deleted forks; fall back
74+ // to the base repo so checkout still has a sensible target.
75+ const headRepoFullName =
76+ pr.data.head.repo?.full_name || `${owner}/${repo}`;
77+
7378 core.setOutput("number", prNumber);
7479 core.setOutput("title", pr.data.title || "");
7580 core.setOutput("body", pr.data.body || "");
7681 core.setOutput("base_sha", pr.data.base.sha);
7782 core.setOutput("head_sha", pr.data.head.sha);
7883 core.setOutput("base_ref", pr.data.base.ref);
7984 core.setOutput("head_ref", pr.data.head.ref);
85+ core.setOutput("head_repo_full_name", headRepoFullName);
86+ core.setOutput("state", pr.data.state);
87+
88+ # Closed/merged PR (e.g. `/ai-review` rerun on a merged PR):
89+ # use the base-repo mirror of the PR head, which GitHub keeps
90+ # durably even after the fork is deleted or branches removed.
91+ # The previous workflow used `refs/pull/<N>/merge`, which is
92+ # garbage-collected on closed PRs — this path replaces that.
93+ - uses : actions/checkout@v6
94+ if : steps.pr.outputs.state != 'open'
95+ with :
96+ ref : refs/pull/${{ steps.pr.outputs.number }}/head
8097
98+ # Open PR: check out by head_sha from the head repo (= base repo
99+ # for owner PRs, = the fork for fork PRs). This avoids the
100+ # documented race where `refs/pull/<N>/head` on the base repo
101+ # has not yet mirrored a freshly API-created PR's head
102+ # (see .claude/commands/submit-pr.md:327-345). head_sha is
103+ # guaranteed to exist on the head repo for an open PR.
81104 - uses : actions/checkout@v6
105+ if : steps.pr.outputs.state == 'open'
82106 with :
83- ref : refs/pull/${{ steps.pr.outputs.number }}/merge
107+ repository : ${{ steps.pr.outputs.head_repo_full_name }}
108+ ref : ${{ steps.pr.outputs.head_sha }}
84109
85- - name : Pre-fetch base and head refs
110+ - name : Pre-fetch base SHA
86111 run : |
87112 set -euo pipefail
88- git fetch --no-tags origin \
89- "${{ steps.pr.outputs.base_ref }}" \
90- +refs/pull/${{ steps.pr.outputs.number }}/head
113+ # base_sha lives on the base repo (github.repository), which differs
114+ # from origin when this is an open fork PR. Add an explicit `base`
115+ # remote so `git diff BASE_SHA HEAD_SHA` finds the base-side tree
116+ # regardless of which checkout path ran.
117+ git remote add base "https://github.com/${{ github.repository }}.git"
118+ git fetch --no-tags --depth=1 base "${{ steps.pr.outputs.base_sha }}"
91119
92120 - name : Fetch previous AI review (if any)
93121 id : prev_review
@@ -125,7 +153,14 @@ jobs:
125153 set -euo pipefail
126154 PROMPT=.github/codex/prompts/pr_review_compiled.md
127155
128- cat .github/codex/prompts/pr_review.md > "$PROMPT"
156+ # Source the review prompt from base_sha rather than the PR head.
157+ # The prompt defines HOW the reviewer reviews; sourcing it from
158+ # base prevents a PR from modifying its own review rules. (Note:
159+ # docs/methodology/REGISTRY.md and TODO.md remain from the PR
160+ # head intentionally - the prompt instructs the reviewer to
161+ # recognize PR-added Note/Deviation labels and tracked TODOs as
162+ # mitigations, so those must reflect the PR's edits.)
163+ git show "${BASE_SHA}":.github/codex/prompts/pr_review.md > "$PROMPT"
129164
130165 # Sanitize untrusted text so hostile content can't close the
131166 # wrapper tags and inject instructions to the reviewer.
0 commit comments