99 * with `_base` and HEAD set so that `git diff _base HEAD` is exactly the change
1010 * under review and a later plain `git diff` is exactly the agent's in-place fixes:
1111 *
12- * - kind=pr (no options.commit): clone the PR base branch, apply the PR diff,
13- * and commit it as a snapshot (HEAD = base+PR, _base = base commit).
12+ * - kind=pr (no options.commit): clone the PR base branch and pin the review to
13+ * the PR head commit — _base = GitHub's 3-dot merge-base, HEAD = head SHA — so
14+ * a later synchronize push or a moving base branch cannot shift the tree under
15+ * review. Falls back to applying the live PR diff as a snapshot (HEAD =
16+ * base+PR, _base = base commit) only when the head SHA cannot be pinned.
1417 * - kind=pr WITH options.commit=SHA: review just that one commit
1518 * (_base = SHA^, HEAD = SHA); results still post to the PR.
1619 * - kind=push: review a before..after commit range with no PR; the commits
@@ -696,6 +699,46 @@ function getPRBaseBranch(string $repo, int $prNumber): ?string
696699 return getPRRef ($ repo , $ prNumber , 'base ' );
697700}
698701
702+ /**
703+ * Resolve the head commit SHA to review for a PR job. Prefers a non-empty
704+ * $job['sha'] (the pr.head.sha captured at enqueue time) so no network call is
705+ * needed in the common case; otherwise it looks the SHA up from the GitHub API.
706+ * Returns '' when the SHA cannot be resolved (the caller then falls back to the
707+ * live diff-apply path).
708+ *
709+ * @param array<string, mixed> $job
710+ */
711+ function resolvePrHeadSha (array $ job , string $ repo , int $ prNumber ): string
712+ {
713+ $ sha = trim ((string )($ job ['sha ' ] ?? '' ));
714+ if ($ sha !== '' ) {
715+ return $ sha ;
716+ }
717+
718+ // No SHA on the job envelope — look it up. Skip the API entirely when we
719+ // have nothing to query with (keeps callers/tests network-free).
720+ if ($ repo === '' || $ prNumber <= 0 ) {
721+ return '' ;
722+ }
723+
724+ $ cmd = sprintf (
725+ 'gh api %s --jq .head.sha 2>&1 ' ,
726+ escapeshellarg ("repos/ {$ repo }/pulls/ {$ prNumber }" )
727+ );
728+ $ output = [];
729+ $ ret = 0 ;
730+ exec ($ cmd , $ output , $ ret );
731+ verbose_raw ('gh_api_pr_head_sha ' , "cmd: {$ cmd }\noutput: " . implode ("\n" , $ output ) . "\nret: {$ ret }" );
732+ if ($ ret === 0 && !empty ($ output )) {
733+ $ resolved = trim (implode ("\n" , $ output ));
734+ // Guard against error text / non-SHA output leaking through 2>&1.
735+ if (preg_match ('/^[0-9a-f]{7,40}$/i ' , $ resolved ) === 1 ) {
736+ return $ resolved ;
737+ }
738+ }
739+ return '' ;
740+ }
741+
699742/**
700743 * Get the diff for a PR using gh pr diff
701744 *
@@ -820,7 +863,10 @@ function applyPRDiff(string $checkoutPath, ?string $diff): bool
820863// root commit that has no parent (brand-new branch's very first commit).
821864const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904 ' ;
822865
823- $ githubToken = getenv (GITHUB_TOKEN ) ?: '' ;
866+ // Comment posting uses raw curl with an explicit token (reads go through the
867+ // gh CLI's own auth). Honor GITHUB_TOKEN, then GH_TOKEN, then (in the direct-run
868+ // block below) fall back to whatever `gh` is authenticated with.
869+ $ githubToken = getenv (GITHUB_TOKEN ) ?: (getenv ('GH_TOKEN ' ) ?: '' );
824870$ ghToken = getenv ('GH_TOKEN ' ) ?: '' ;
825871$ checkoutRoot = getenv (CHECKOUT_ROOT ) ?: '/tmp/pr-checkouts ' ;
826872$ opencodeAnalyzeCmd = getenv (OPENCODE_ANALYZE_CMD ) ?: 'sh -c "cd {dir} && opencode run \"Analyze all PHP files in this directory tree. Find issues and return JSON array with fields: file, line, severity (error/warning/info), message for each issue\" --format json" 2>&1 ' ;
@@ -833,6 +879,16 @@ function applyPRDiff(string $checkoutPath, ?string $diff): bool
833879 exit (1 );
834880 }
835881
882+ // No token was exported, but `gh` is authenticated (checked above) — reuse
883+ // its credential so posting works with `gh auth login` alone instead of
884+ // 401ing with "Bad credentials".
885+ if ($ githubToken === '' ) {
886+ $ ghAuthToken = trim ((string )shell_exec ('gh auth token 2>/dev/null ' ));
887+ if ($ ghAuthToken !== '' ) {
888+ $ githubToken = $ ghAuthToken ;
889+ }
890+ }
891+
836892 // Pass tokens to child processes if set
837893 if ($ githubToken !== '' ) {
838894 putenv ("GITHUB_TOKEN= {$ githubToken }" );
@@ -1145,25 +1201,48 @@ function processPrJob(array $job, string $repo, string $jobId, array $auditTypes
11451201 // Anchor inline comments on the reviewed commit.
11461202 $ sha = $ commitSha ;
11471203 } else {
1148- // Get the PR diff, apply it, and commit it as a snapshot. After a
1149- // successful apply the working tree holds the PR changes on top of
1150- // the base commit; we record the base commit as `_base` and commit
1151- // the PR changes so HEAD = base+PR with a clean tree.
1152- $ diff = getPRDiff ($ repo , $ prNumber );
1153- if ($ diff !== null && $ diff !== '' ) {
1154- verbose_log ("applying PR diff for {$ repo }# {$ prNumber }" , 2 );
1155- if (applyPRDiff ($ checkoutPath , $ diff )) {
1156- if (commitPrSnapshot ($ checkoutPath , $ prNumber )) {
1157- $ baselineReady = true ;
1158- verbose_log ("PR snapshot committed (HEAD=base+PR, _base=base) " , 2 );
1204+ // Pin the review to a specific head commit (mirrors the push path)
1205+ // so a later synchronize push or a base branch that moves between
1206+ // enqueue and processing cannot make us review the wrong tree while
1207+ // anchoring comments to a stale SHA. Resolve the head SHA, then pin
1208+ // _base = GitHub's 3-dot merge-base and HEAD = head SHA.
1209+ $ headSha = resolvePrHeadSha ($ job , $ repo , $ prNumber );
1210+ if ($ headSha !== '' ) {
1211+ verbose_log ("pinning PR review to head commit {$ headSha } for {$ repo }# {$ prNumber }" , 2 );
1212+ $ baseErr = setupPrHeadBaseline ($ checkoutPath , $ repo , $ baseBranch , $ headSha );
1213+ if ($ baseErr === 'true ' ) {
1214+ $ baselineReady = true ;
1215+ // Anchor inline comments on the EXACT reviewed commit.
1216+ $ sha = $ headSha ;
1217+ verbose_log ("PR head baseline pinned (_base=merge-base, HEAD= {$ headSha }) " , 2 );
1218+ } else {
1219+ verbose_log ("PR head baseline failed ( {$ baseErr }); falling back to live diff apply " , 1 );
1220+ }
1221+ } else {
1222+ verbose_log ("could not resolve PR head SHA; falling back to live diff apply " , 1 );
1223+ }
1224+
1225+ // Fallback: head SHA unresolved or the merge-base/head SHA could not
1226+ // be fetched. Use the live `gh pr diff`, apply it, and commit it as a
1227+ // snapshot (HEAD = base+PR, _base = base commit). This path is NOT
1228+ // pinned to a specific commit — it reviews the current PR state.
1229+ if (!$ baselineReady ) {
1230+ $ diff = getPRDiff ($ repo , $ prNumber );
1231+ if ($ diff !== null && $ diff !== '' ) {
1232+ verbose_log ("applying PR diff for {$ repo }# {$ prNumber }" , 2 );
1233+ if (applyPRDiff ($ checkoutPath , $ diff )) {
1234+ if (commitPrSnapshot ($ checkoutPath , $ prNumber )) {
1235+ $ baselineReady = true ;
1236+ verbose_log ("PR snapshot committed (HEAD=base+PR, _base=base) " , 2 );
1237+ } else {
1238+ verbose_log ("failed to commit PR snapshot - agent fixes cannot be captured " , 1 );
1239+ }
11591240 } else {
1160- verbose_log ("failed to commit PR snapshot - agent fixes cannot be captured " , 1 );
1241+ verbose_log ("failed to apply PR diff - analyzing base branch only " , 2 );
11611242 }
11621243 } else {
1163- verbose_log ("failed to apply PR diff - analyzing base branch only " , 2 );
1244+ verbose_log ("no diff retrieved - analyzing base branch only " , 2 );
11641245 }
1165- } else {
1166- verbose_log ("no diff retrieved - analyzing base branch only " , 2 );
11671246 }
11681247 }
11691248
@@ -1964,6 +2043,64 @@ function setupCommitScopedBaseline(string $checkoutPath, string $repo, string $s
19642043 return setupCommitRangeBaseline ($ checkoutPath , $ repo , '' , $ sha );
19652044}
19662045
2046+ /**
2047+ * Resolve GitHub's true merge-base for a PR head against its base branch using
2048+ * the compare API's 3-dot semantics — the same base the PR "Files changed" view
2049+ * diffs against. Prefers .merge_base_commit.sha, falls back to .base_commit.sha
2050+ * (a.k.a. .base.sha). Returns '' when the API is unavailable so the caller can
2051+ * fall back to the base branch tip.
2052+ */
2053+ function getPrMergeBaseSha (string $ repo , string $ baseBranch , string $ headSha ): string
2054+ {
2055+ if ($ repo === '' || $ baseBranch === '' || $ headSha === '' ) {
2056+ return '' ;
2057+ }
2058+ $ cmd = sprintf (
2059+ 'gh api %s 2>&1 ' ,
2060+ escapeshellarg ("repos/ {$ repo }/compare/ {$ baseBranch }... {$ headSha }" )
2061+ );
2062+ $ output = [];
2063+ $ ret = 0 ;
2064+ exec ($ cmd , $ output , $ ret );
2065+ if ($ ret !== 0 ) {
2066+ verbose_log ('getPrMergeBaseSha: compare API failed: ' . substr (implode ("\n" , $ output ), 0 , 200 ), 2 );
2067+ return '' ;
2068+ }
2069+ $ json = json_decode (implode ("\n" , $ output ), true );
2070+ if (!is_array ($ json )) {
2071+ return '' ;
2072+ }
2073+ foreach (['merge_base_commit ' , 'base_commit ' , 'base ' ] as $ key ) {
2074+ $ sha = $ json [$ key ]['sha ' ] ?? '' ;
2075+ if (is_string ($ sha ) && $ sha !== '' ) {
2076+ return $ sha ;
2077+ }
2078+ }
2079+ return '' ;
2080+ }
2081+
2082+ /**
2083+ * Pin a normal (non-commit-scoped) PR review to a specific head commit, mirroring
2084+ * the push path. Resolves GitHub's 3-dot merge-base so `git diff _base HEAD`
2085+ * matches the PR's Files-changed view exactly, then materializes
2086+ * _base = merge-base and HEAD = $headSha via setupCommitRangeBaseline(). When the
2087+ * merge-base cannot be fetched from the API, falls back to the base branch tip.
2088+ * Returns 'true' or an error string.
2089+ */
2090+ function setupPrHeadBaseline (string $ checkoutPath , string $ repo , string $ baseBranch , string $ headSha ): string
2091+ {
2092+ if ($ headSha === '' ) {
2093+ return 'missing head sha ' ;
2094+ }
2095+ $ mergeBase = getPrMergeBaseSha ($ repo , $ baseBranch , $ headSha );
2096+ if ($ mergeBase === '' ) {
2097+ // API unavailable: use the base branch tip as the baseline. An empty
2098+ // baseBranch degrades to headSha^ inside setupCommitRangeBaseline().
2099+ $ mergeBase = $ baseBranch ;
2100+ }
2101+ return setupCommitRangeBaseline ($ checkoutPath , $ repo , $ mergeBase , $ headSha );
2102+ }
2103+
19672104/**
19682105 * Strip ANSI escape sequences (colors, cursor movement, OSC titles) from text.
19692106 * opencode --format default may emit these; they break JSON/markdown parsing.
0 commit comments