Skip to content

Commit 32f5530

Browse files
detainclaude
andcommitted
fix(github-code-review): pin PR review to head commit, fix posting auth, fix trivial issues
- Pin the normal PR review to a specific head commit instead of the live `gh pr diff`: resolvePrHeadSha() (job sha, else API), setupPrHeadBaseline() (merge-base from the compare API -> setupCommitRangeBaseline(_base=mergeBase, HEAD=headSha)). `git diff _base HEAD` now reproduces the PR's Files-changed view at that exact commit, and the posted commit_id matches what was reviewed. Falls back to the existing getPRDiff+applyPRDiff+commitPrSnapshot flow when the SHA/merge-base can't be fetched. Push and commit-scoped paths unchanged. - Fix comment posting 401 "Bad credentials": posting uses raw curl with an explicit token, but only GITHUB_TOKEN was honored. Now resolve GITHUB_TOKEN -> GH_TOKEN -> `gh auth token` (gh's own credential, which reads already rely on), so `gh auth login` alone is enough to post. - review.txt: default to FIXING trivial, unambiguous issues (typos, misspelled literals/constants, obvious wrong values) in place instead of flagging them; reserve FLAGGED-only for changes needing human judgment. - New PrHeadBaselineTest (network-free, real throwaway repos) covers the pinned baseline + agent-fix isolation, job-sha preference, and the fallback trigger. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b4fbf44 commit 32f5530

3 files changed

Lines changed: 300 additions & 22 deletions

File tree

scripts/github-code-review.php

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
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).
821864
const 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.

scripts/prompts/review.txt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ problems elsewhere in the repository.
1717

1818
## How to fix
1919

20-
For each real issue in the changed lines, edit the file in place to fix it:
20+
For each real issue in the changed lines, edit the file in place to fix it.
21+
22+
**Default to FIXING.** A fix that is small, safe, and unambiguous — a typo, a
23+
misspelled string literal or constant (e.g. `'pendapprova'` should be
24+
`'pendapproval'`), an obviously wrong value, a missing null check on a changed
25+
line — MUST be fixed in place, never merely flagged. Only fall back to
26+
FLAGGED-only when a fix genuinely needs human judgment.
2127

2228
- Make MINIMAL, SURGICAL changes. Touch only what is needed to fix the specific
2329
issue, scoped to the lines the change added or modified.
@@ -30,9 +36,10 @@ For each real issue in the changed lines, edit the file in place to fix it:
3036
`git add`, `git reset`, `git stash`, `git checkout`, or any other command that
3137
stages, commits, or discards changes. The worker captures your edits with a
3238
plain `git diff` afterwards.
33-
- If you cannot fix an issue safely with a small in-place change (for example it
34-
would require an API change, a new dependency, or broad restructuring), leave
35-
the code as-is and only FLAG it.
39+
- Only FLAG instead of fixing when the fix genuinely needs human judgment — it
40+
would require an API or signature change, a new dependency, broad
41+
restructuring, or you are not confident the change is correct. Do NOT flag a
42+
trivial, unambiguous fix; make it.
3643

3744
## Confidence
3845

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Webhooks\Tests\Unit\CodeReviewWorker;
5+
6+
use PHPUnit\Framework\TestCase;
7+
8+
/**
9+
* Tests for the pinned normal-PR review path added to
10+
* scripts/github-code-review.php: resolvePrHeadSha(), setupPrHeadBaseline() and
11+
* the underlying setupCommitRangeBaseline() plumbing it relies on.
12+
*
13+
* Every test is network-free: the git-plumbing case drives
14+
* setupCommitRangeBaseline() directly with local SHAs (the merge-base and head),
15+
* and the resolver/guard cases never reach the GitHub API.
16+
*/
17+
class PrHeadBaselineTest extends TestCase
18+
{
19+
private string $dir = '';
20+
21+
protected function setUp(): void
22+
{
23+
require_once __DIR__ . '/../../../../scripts/github-code-review.php';
24+
$GLOBALS['verbose'] = 0;
25+
$GLOBALS['logFile'] = null;
26+
27+
$this->dir = sys_get_temp_dir() . '/crw_prhead_' . bin2hex(random_bytes(6));
28+
mkdir($this->dir, 0777, true);
29+
}
30+
31+
protected function tearDown(): void
32+
{
33+
if ($this->dir !== '' && is_dir($this->dir)) {
34+
exec('rm -rf ' . escapeshellarg($this->dir));
35+
}
36+
}
37+
38+
private function git(string $args): string
39+
{
40+
return trim((string)shell_exec('cd ' . escapeshellarg($this->dir) . ' && git ' . $args . ' 2>&1'));
41+
}
42+
43+
/**
44+
* Build a merge-base commit + a feature (head) commit on main and return
45+
* [mergeBaseSha, headSha]. In this linear history the merge-base is simply
46+
* the commit the feature was built on — exactly what GitHub's 3-dot compare
47+
* would report for a PR.
48+
*
49+
* @return array{0: string, 1: string}
50+
*/
51+
private function initMergeBaseAndHead(): array
52+
{
53+
$this->git('init -q');
54+
file_put_contents($this->dir . '/a.php', "<?php\necho 1;\n");
55+
$this->git('add -A');
56+
$this->git('-c user.email=setup@test -c user.name=Setup commit -q -m base');
57+
$this->git('branch -M main');
58+
$mergeBase = $this->git('rev-parse HEAD');
59+
60+
file_put_contents($this->dir . '/a.php', "<?php\necho 2;\n");
61+
file_put_contents($this->dir . '/b.php', "<?php\necho 'feature';\n");
62+
$this->git('add -A');
63+
$this->git('-c user.email=setup@test -c user.name=Setup commit -q -m feature');
64+
$head = $this->git('rev-parse HEAD');
65+
66+
return [$mergeBase, $head];
67+
}
68+
69+
/**
70+
* The plumbing setupPrHeadBaseline() delegates to: driven directly with the
71+
* local merge-base and head SHAs so no network fetch happens. `git diff
72+
* _base HEAD` must be exactly the PR change, and a later agent edit must be
73+
* the only thing captureAgentFixes() reports.
74+
*/
75+
public function testCommitRangeBaselinePinsMergeBaseAndHead(): void
76+
{
77+
[$mergeBase, $head] = $this->initMergeBaseAndHead();
78+
79+
$this->assertSame('true', setupCommitRangeBaseline($this->dir, 'owner/repo', $mergeBase, $head));
80+
81+
$this->assertSame($mergeBase, $this->git('rev-parse _base'), '_base pins the merge-base commit');
82+
$this->assertSame($head, $this->git('rev-parse HEAD'), 'HEAD lands on the head commit');
83+
84+
// The PR change is exactly _base..HEAD.
85+
$changed = $this->git('diff --name-only _base HEAD');
86+
$this->assertStringContainsString('a.php', $changed);
87+
$this->assertStringContainsString('b.php', $changed);
88+
89+
// Agent edits one file in place -> captureAgentFixes returns ONLY that.
90+
file_put_contents($this->dir . '/a.php', "<?php\necho 2; // fixed by agent\n");
91+
$fixes = captureAgentFixes($this->dir);
92+
$this->assertSame(['a.php'], $fixes['files'], 'only the agent-edited file is listed');
93+
$this->assertStringContainsString('fixed by agent', $fixes['diff']);
94+
}
95+
96+
/**
97+
* resolvePrHeadSha() prefers a non-empty $job['sha'] and returns it verbatim
98+
* (trimmed) without any GitHub API lookup.
99+
*/
100+
public function testResolvePrHeadShaPrefersJobSha(): void
101+
{
102+
$sha = '0123456789abcdef0123456789abcdef01234567';
103+
$job = ['sha' => $sha, 'pr_number' => 42, 'base_branch' => 'main'];
104+
105+
$this->assertSame($sha, resolvePrHeadSha($job, 'owner/repo', 42));
106+
}
107+
108+
public function testResolvePrHeadShaTrimsJobSha(): void
109+
{
110+
$job = ['sha' => " abc1234 \n"];
111+
$this->assertSame('abc1234', resolvePrHeadSha($job, 'owner/repo', 42));
112+
}
113+
114+
/**
115+
* With no job SHA and nothing to query against (pr_number 0), the resolver
116+
* returns '' without touching the network so the caller takes the fallback.
117+
*/
118+
public function testResolvePrHeadShaEmptyWhenNoShaAndNoPrNumber(): void
119+
{
120+
$this->assertSame('', resolvePrHeadSha(['sha' => ''], 'owner/repo', 0));
121+
$this->assertSame('', resolvePrHeadSha([], 'owner/repo', 0));
122+
$this->assertSame('', resolvePrHeadSha(['sha' => ''], '', 42));
123+
}
124+
125+
/**
126+
* setupPrHeadBaseline() short-circuits on an empty head SHA before any API
127+
* call, so the guard is exercised network-free.
128+
*/
129+
public function testSetupPrHeadBaselineMissingHeadShaReturnsError(): void
130+
{
131+
$this->git('init -q');
132+
$this->assertSame('missing head sha', setupPrHeadBaseline($this->dir, 'owner/repo', 'main', ''));
133+
}
134+
}

0 commit comments

Comments
 (0)