@@ -2,19 +2,32 @@ name: Codex PR Review
22
33on :
44 pull_request :
5+ branches : [main]
56 types : [opened, synchronize, reopened]
67 issue_comment :
78 types : [created]
89
10+ # Only collide for runs that review the same PR (a push or a trusted @codex review
11+ # on PR #N share group codex-review-N). The comment branch mirrors the full authorize
12+ # gate, including author_association, so an untrusted comment can never land in the
13+ # shared group and cancel an in-flight review; it falls back to the unique run id.
914concurrency :
10- group : codex-review-${{ github.event.pull_request.number || github.event.issue.number }}
15+ group : >-
16+ codex-review-${{
17+ (github.event_name == 'pull_request' && github.event.pull_request.number) ||
18+ (github.event_name == 'issue_comment' &&
19+ github.event.issue.pull_request != null &&
20+ contains(github.event.comment.body, '@codex review') &&
21+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) &&
22+ github.event.issue.number) ||
23+ github.run_id }}
1124 cancel-in-progress : true
1225
1326jobs :
1427 authorize :
15- # Trigger gate: PR events always, comment events only for "@codex review" from a
16- # trusted commenter. The step then restricts to same-repo (non-fork) PRs so a
17- # public fork can never reach the privileged checkout below.
28+ # Trigger gate: PR events and "@codex review" from a trusted commenter. The
29+ # step then restricts to same-repo (non-fork) PRs so fork-controlled content
30+ # can never reach the secret-backed checkout/review below.
1831 if : >
1932 github.event_name == 'pull_request' ||
2033 (github.event.issue.pull_request != null &&
@@ -91,40 +104,56 @@ jobs:
91104 "required": ["summary", "findings"]
92105 }
93106 prompt : |
94- You are reviewing pull request #${{ needs.authorize.outputs.pr }} in ${{ github.repository }}.
95-
96- The PR's changes are exactly the diff between the merge commit's two parents.
97- Run `git diff HEAD^1 HEAD^2` to see everything that changed, and
98- `git diff HEAD^1 HEAD^2 -- <path>` to focus on a single file .
107+ You are a senior staff engineer doing a deep, context-aware review of pull
108+ request #${{ needs.authorize.outputs.pr }} in ${{ github.repository }}. Review
109+ like Devin: do not skim the diff, investigate the whole repository to judge
110+ each change in the context of the code around it. The full repo is checked
111+ out at the PR merge commit and you have a read-only shell, so actually explore .
99112
100- Review ONLY those changes. Report high-signal findings only.
113+ Step 1 - establish what changed:
114+ - `git diff HEAD^1 HEAD^2 --stat` for the shape, then
115+ `git diff HEAD^1 HEAD^2 -- <path>` per file for the exact changes.
101116
102- Correctness & safety:
103- - logic errors, unhandled edge cases, broken assumptions
104- - security vulnerabilities
105- - data loss, concurrency hazards, resource leaks
117+ Step 2 - crawl the repository for context. Do not review a hunk in isolation:
118+ - Open each changed file IN FULL (not just the diff window) to understand
119+ surrounding logic, invariants, and intent.
120+ - Trace every symbol the change touches outward through the repo. For each
121+ changed function/class/constant/export/route/env var, find its definition
122+ and ALL usages with `git grep -n` / ripgrep, and read those call sites.
123+ - Follow imports both directions: what this code depends on, and what depends
124+ on it. Read the real implementations being called, never assume behavior.
125+ - Pull in the related files the diff did NOT touch but should be checked
126+ against: callers, tests, type definitions, schemas/migrations, configs,
127+ API contracts, fixtures, and docs.
106128
107- Design & code quality:
108- - the soundness of the overall approach, not just line-level bugs
109- - elegance: is there a simpler, cleaner way to achieve the same result?
110- - abstraction: prefer the most general clean abstraction that fits the problem,
111- without over-engineering for cases that don't exist
112- - redundancy: flag duplicated logic, dead code, and anything that violates DRY
129+ Step 3 - assess impact across the whole repo, not just the changed lines:
130+ - Ripple effects: does this change break or silently require updates in
131+ callers, tests, types, serialization, DB schema, or public contracts
132+ elsewhere in the repo? Did the PR update everything it needed to?
133+ - Consistency: does it match this repo's established conventions and patterns
134+ (naming, error handling, logging, layering, auth)? Cite the existing pattern.
135+ - Correctness & safety: logic errors, unhandled edge cases, broken
136+ assumptions, security holes, data loss, concurrency hazards, resource leaks.
137+ - Design: soundness of the overall approach; a simpler/cleaner way; the right
138+ abstraction without over-engineering; duplicated logic / dead code / DRY.
113139
114- Skip pure formatting and style nits.
115-
116- Report at most the 5 most important findings. Consolidate an issue that
117- recurs in several places into one finding at the most representative location.
140+ Report high-signal findings only; skip pure formatting and style nits. Report
141+ at most the 5 most important findings, consolidating a recurring issue into a
142+ single finding at the most representative location. A finding may be rooted in
143+ an untouched file (e.g. a caller this PR breaks): anchor it to the changed line
144+ that causes the problem and name the affected file(s) in the comment.
118145
119146 Output JSON matching the provided schema:
120- - `summary`: one or two sentences on the PR overall. If there are no real
121- issues, set summary to "No issues found." and findings to [].
147+ - `summary`: one or two sentences on the PR overall, reflecting repo-wide
148+ impact. If there are no real issues, set summary to "No issues found." and
149+ findings to [].
122150 - `findings[].path`: repository-relative file path, exactly as git reports it.
123151 - `findings[].line`: the line number in the NEW (post-change) version of the
124152 file. It MUST be a line the PR adds or modifies.
125153 - `findings[].severity`: "blocking" or "consider".
126- - `findings[].comment`: markdown review comment with a short code snippet and a
127- concrete fix.
154+ - `findings[].comment`: markdown review comment that explains the repo-wide
155+ impact (reference the specific other files/call sites you inspected), with a
156+ short code snippet and a concrete fix.
128157
129158 post_review :
130159 needs : [authorize, review]
@@ -148,6 +177,14 @@ jobs:
148177 const INLINE_MARKER = '<!-- codex-review-inline -->';
149178 const MAX_COMMENTS = 5;
150179
180+ // Only ever mutate comments this workflow itself authored, identified by
181+ // the GITHUB_TOKEN bot actor plus our hidden marker. Never touch a human's
182+ // (or another bot's) comment even if it happens to quote a marker.
183+ const isOurComment = (c, marker) =>
184+ c.user?.type === 'Bot' &&
185+ c.user?.login === 'github-actions[bot]' &&
186+ c.body?.includes(marker);
187+
151188 // Parse Codex JSON. With --output-schema the result is already pure JSON,
152189 // and its findings may contain fenced code blocks, so never grab an inner
153190 // fence: parse the whole string first, then a fence wrapping the whole
@@ -208,14 +245,16 @@ jobs:
208245 }
209246 }
210247
211- // Always clear our prior inline comments first, so findings resolved in a
212- // later push disappear even when this run produces no inline comments.
248+ // Clear our own prior inline comments so resolved findings disappear on a
249+ // later push, but never delete a thread that has replies (someone engaged
250+ // with it) or a comment we did not author.
213251 try {
214252 const prior = await github.paginate(github.rest.pulls.listReviewComments, {
215253 owner, repo, pull_number, per_page: 100,
216254 });
255+ const repliedTo = new Set(prior.filter(c => c.in_reply_to_id).map(c => c.in_reply_to_id));
217256 for (const c of prior) {
218- if (c.body && c.body.includes(INLINE_MARKER )) {
257+ if (isOurComment(c, INLINE_MARKER) && !c.in_reply_to_id && !repliedTo.has(c.id )) {
219258 try { await github.rest.pulls.deleteReviewComment({ owner, repo, comment_id: c.id }); }
220259 catch {}
221260 }
@@ -228,7 +267,9 @@ jobs:
228267 try {
229268 await github.rest.pulls.createReview({
230269 owner, repo, pull_number, commit_id: headSha,
231- event: 'COMMENT', comments: inline,
270+ event: 'COMMENT',
271+ body: `${SUMMARY_MARKER.replace('summary','review-header')}\nCodex flagged ${inline.length} item(s) inline below.`,
272+ comments: inline,
232273 });
233274 inlinePosted = true;
234275 } catch (err) {
@@ -247,11 +288,11 @@ jobs:
247288 for (const o of leftover) body += `\n- \`${o.path}:${o.line}\` **${o.sev}** ${o.comment}`;
248289 }
249290
250- // Upsert one rolling summary comment instead of stacking on each push .
291+ // Upsert our own rolling summary comment (never overwrite a human's) .
251292 const comments = await github.paginate(github.rest.issues.listComments, {
252293 owner, repo, issue_number: pull_number, per_page: 100,
253294 });
254- const existing = comments.find(c => c.body && c.body.includes( SUMMARY_MARKER));
295+ const existing = comments.find(c => isOurComment(c, SUMMARY_MARKER));
255296 if (existing) {
256297 await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
257298 } else {
0 commit comments