Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 44 additions & 30 deletions .github/ai-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ resolve ──────>┤ ├──> adjudicate ──> po

- **`resolve`** (`.github/scripts/ai-review/resolve.ts`) decides whether this
run should happen at all. It applies the once-per-PR dedup guard, the
draft/bot/fork skips (for the future automatic trigger), and authorization
for manual `/ai-review` requests. There is no size cap: the models review
agentically — reading the diff and the changed files via their own tools over
many turns, like the local CLI — so PRs of any size are reviewed (very large
diffs best-effort, within the model's context/turn budget). One caveat: the
diff is fetched with `gh pr diff`, which GitHub itself caps (≈300 files /
20k lines / 1 MB); a PR beyond those limits gets a truncated diff, so the
review is truncated with it. Generating the diff from the base/head refs
instead is a possible follow-up.
automatic trigger's draft/bot/fork skips and author write-access gate, and
authorization for manual `/ai-review` requests. There is no size cap: the
models review agentically — reading the diff and the changed files via
their own tools over many turns, like the local CLI — so PRs of any size
are reviewed (very large diffs best-effort, within the model's context/turn
budget). One caveat: the diff is fetched with `gh pr diff`, which GitHub
itself caps (≈300 files / 20k lines / 1 MB); a PR beyond those limits gets
a truncated diff, so the review is truncated with it. Generating the diff
from the base/head refs instead is a possible follow-up.
- **`claude-review`** and **`codex-review`** run **in parallel** — each gives
its model an independent, exhaustive pass and produces structured JSON
findings validated against `findings.schema.json`. Claude reads the PR's
Expand Down Expand Up @@ -71,25 +71,27 @@ on the same PR:
Both bypass the dedup guard and the draft/fork/bot skips (a human explicitly
asked).

## Rollout

The pipeline currently runs only on-demand (`workflow_dispatch` or
`/ai-review`) — the `pull_request` trigger in the workflow is commented out
("shadow mode"). Rollout plan:

1. Run it manually against a sample of recent real PRs; tune the two prompts
in this directory against what it actually produces. **This only works
end-to-end once the current security fixes are merged to `develop`**: the
prompts, schemas, and validation script are read from a trusted checkout of
the _default branch_ (not the PR under review), and `post-review` checks
out `develop` explicitly — so prompt/script tweaks on a feature branch
don't take effect until they land on `develop`. Use `workflow_dispatch`
against real merged/in-flight PRs post-merge to iterate.
2. Once satisfied, uncomment the `pull_request` trigger block in
`ai-review.yml`.
3. In the same change, disable the Codex GitHub App's automatic reviews at
<https://chatgpt.com/codex/settings/code-review> so PRs aren't
double-reviewed.
## Automatic trigger

The `pull_request` trigger (`opened` / `ready_for_review`) is live. The
automatic path is **internal PRs only**: `resolve.ts` skips drafts, bots, and
fork PRs, and requires the PR author to hold effective repository **write
access** (`admin`/`write`, the same `WRITE_PERMISSIONS` gate as the manual
`/ai-review` path). The permission lookup is the authoritative author check:
a same-repo head branch only proves the branch exists in this repo, not that
the PR author pushed it, so the author's own permission is always resolved.
External contributors' PRs are never reviewed automatically; a maintainer
comments `/ai-review` to request one.

Prompt/script tweaks take effect only once they land on `develop`: the
prompts, schemas, and validation script are read from a trusted checkout of
the _default branch_ (not the PR under review), and `post-review` checks out
`develop` explicitly. Use `workflow_dispatch` against real merged/in-flight
PRs post-merge to iterate.

The Codex GitHub App's automatic reviews must stay disabled at
<https://chatgpt.com/codex/settings/code-review> so PRs aren't
double-reviewed.

`merged-review.schema.json` uses `pattern` (on `category`) and `minItems` (on
`sources`); some OpenAI structured-output strict-mode implementations have
Expand Down Expand Up @@ -161,10 +163,22 @@ run 400s on the output schema because of this, drop `pattern`/`minItems` from
(`/ai-reviewers`, `/ai-review-please`, etc. don't fire). The workflow's job
`if:` also pre-filters cheaply on `author_association` as defense-in-depth,
but `resolve.ts`'s checks are the actual gate.
- **The automatic trigger requires the PR author to hold write access.**
`resolve.ts` resolves the PR author's effective repository permission and
requires `admin`/`write` before an automatic review runs, on top of the
fork/draft/bot skips — so an external contributor's PR can never spend
review budget or feed the models without a maintainer explicitly asking
via `/ai-review`.
- **The only write-capable job runs exclusively trusted code.**
Comment thread
Coly010 marked this conversation as resolved.
Resolved
`post-review` checks out the base branch (`develop`) explicitly and never
the PR head, so a malicious PR cannot smuggle a change into the one job
that can write back to it.
the PR head, so a PR cannot smuggle a script change into the one job that
can write back to it. The checkout pin alone is not the whole boundary for
`pull_request` runs, though: GitHub executes the workflow FILE from the
PR's own ref for those events. That is safe here because the automatic
path only admits same-repo PRs, whose authors hold write access anyway
(a workflow edit gains them nothing they don't already have), while fork
PRs run with a read-only token and no secrets. `issue_comment` and
`workflow_dispatch` runs always use the default branch's workflow file.
- **Model text is sanitized before it's rendered.** `sanitizeModelText()`
redacts secret-shaped substrings (`redactSecrets()`; see below), strips HTML
comments (so injected diff content can't forge the hidden dedup/supersede
Expand Down
28 changes: 27 additions & 1 deletion .github/scripts/ai-review/post-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,16 @@ describe("post flow via injected ReviewIo", () => {
if (opts.failSupersede) {
return Promise.reject(new Error("listReviews failed"));
}
return Promise.resolve(opts.reviews ?? []);
// Mirror real GitHub: a review posted earlier in the same run shows
// up in later listings as a marker-bearing bot review. The supersede
// pass must snapshot BEFORE posting or it would wrap the fresh
// review as "superseded" too.
const alreadyPosted = postedReviews.map((payload, i) => ({
id: 900 + i,
body: payload.body,
authorLogin: "github-actions[bot]",
}));
return Promise.resolve([...(opts.reviews ?? []), ...alreadyPosted]);
},
listIssueComments: () => {
calls.push("listIssueComments");
Expand Down Expand Up @@ -1109,6 +1118,23 @@ describe("post flow via injected ReviewIo", () => {
expect(calls.indexOf("postReview")).toBeLessThan(calls.indexOf("updateReviewBody"));
});

test("the freshly posted review is never swept into its own supersede pass", async () => {
const review = makeMergedReview({ findings: [] });
const { io, updatedReviews, updatedComments, postedReviews, calls } = makeReviewIo({
diff: SINGLE_HUNK_DIFF,
});

await postConsolidatedReview(io, 42, review, footer);

// With no prior AI review on the PR, nothing may be wrapped as superseded
// — especially not the review this run just posted (which the fake's
// listReviews, like real GitHub, includes in post-POST listings).
expect(postedReviews).toHaveLength(1);
expect(updatedReviews).toEqual([]);
expect(updatedComments).toEqual([]);
expect(calls.indexOf("listReviews")).toBeLessThan(calls.indexOf("postReview"));
});

test("a review still posts even when the best-effort supersede fails", async () => {
const review = makeMergedReview({ findings: [] });
const { io, postedReviews } = makeReviewIo({ diff: SINGLE_HUNK_DIFF, failSupersede: true });
Expand Down
101 changes: 64 additions & 37 deletions .github/scripts/ai-review/post-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
* artifact, so a prompt-injected `Read` of a secret-bearing path can't
* smuggle a credential out through the artifact even though the posted
* review is already scrubbed at render time.
* - `post` — posts the consolidated review, THEN best-effort supersedes any
* prior AI review on the PR (the marker/dedup guard in `resolve.ts` should
* normally prevent a second run, but `/ai-review` lets a maintainer force
* one; posting before superseding, and treating the supersede as
* best-effort, means a cosmetic supersede failure can never cost the real
* review).
* - `post` — snapshots the PR's prior AI reviews, posts the consolidated
* review, THEN best-effort supersedes the snapshotted ones (the
* marker/dedup guard in `resolve.ts` should normally prevent a second
* run, but `/ai-review` lets a maintainer force one). The snapshot must
* happen BEFORE the POST — the fresh review is itself a marker-bearing
* bot review, so a post-hoc listing would sweep it into its own
* supersede pass and every new review would collapse itself. Posting
* before superseding, and treating both the snapshot and the supersede
* as best-effort, means a cosmetic failure can never cost the real
* review.
*
* `parseDiffAnchors`, `partitionFindings`, `renderReviewBody`,
* `renderInlineComment`, `buildReviewPayload`, `foldInlineCommentsIntoBody`,
Expand Down Expand Up @@ -842,42 +846,61 @@ export interface ReviewIo {
) => Promise<{ status: number; body?: string }>;
}

/** Wraps every prior AI review/comment on the PR in a superseded `<details>` block. Idempotent. */
async function supersedePriorRuns(io: ReviewIo, prNumber: number): Promise<void> {
const [reviews, comments] = await Promise.all([
io.listReviews(prNumber),
io.listIssueComments(prNumber),
]);
/** The prior AI reviews/comments this run will supersede, snapshotted BEFORE
* the new review is posted. */
interface PriorRuns {
reviews: MarkedEntry[];
comments: MarkedEntry[];
}

for (const review of reviews) {
if (
review.authorLogin !== WORKFLOW_BOT_LOGIN ||
!review.body.includes(AI_REVIEW_MARKER) ||
isSuperseded(review.body)
) {
continue;
}
await io.updateReviewBody(prNumber, review.id, supersededBody(review.body));
}
/** A marker-bearing AI review/comment by the workflow bot that hasn't been
* superseded yet — the only kind a supersede pass may wrap. */
function isSupersedableAiEntry(entry: MarkedEntry): boolean {
return (
entry.authorLogin === WORKFLOW_BOT_LOGIN &&
entry.body.includes(AI_REVIEW_MARKER) &&
!isSuperseded(entry.body)
);
}

for (const comment of comments) {
if (
comment.authorLogin !== WORKFLOW_BOT_LOGIN ||
!comment.body.includes(AI_REVIEW_MARKER) ||
isSuperseded(comment.body)
) {
continue;
}
await io.updateIssueCommentBody(comment.id, supersededBody(comment.body));
/** Snapshots the prior AI reviews/comments to supersede. MUST run before the
* new review is posted: the fresh review is itself a marker-bearing bot
* review, so a post-hoc listing would sweep it into its own supersede pass
* and every new review would immediately collapse as "superseded".
* Best-effort — a listing failure degrades to an empty snapshot (prior runs
* stay unwrapped) rather than costing the real review. */
async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise<PriorRuns> {
try {
const [reviews, comments] = await Promise.all([
io.listReviews(prNumber),
io.listIssueComments(prNumber),
]);
return {
reviews: reviews.filter(isSupersedableAiEntry),
comments: comments.filter(isSupersedableAiEntry),
};
} catch (error) {
console.warn(`Could not list prior AI review runs on PR #${prNumber}: ${String(error)}`);
return { reviews: [], comments: [] };
}
}

/** Best-effort wrapper around `supersedePriorRuns`: a cosmetic failure here
* (e.g. a transient 404 on a review that was deleted mid-run) must never
* fail the pipeline after the real review/notice has already been posted. */
async function supersedePriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise<void> {
/** Wraps the snapshotted prior AI reviews/comments in a superseded `<details>`
* block. Best-effort: a cosmetic failure here (e.g. a transient 404 on a
* review that was deleted mid-run) must never fail the pipeline after the
* real review has already been posted. */
async function supersedePriorRunsBestEffort(
io: ReviewIo,
prNumber: number,
prior: PriorRuns,
): Promise<void> {
try {
await supersedePriorRuns(io, prNumber);
for (const review of prior.reviews) {
await io.updateReviewBody(prNumber, review.id, supersededBody(review.body));
}
for (const comment of prior.comments) {
await io.updateIssueCommentBody(comment.id, supersededBody(comment.body));
}
} catch (error) {
console.warn(`Could not supersede prior AI review runs on PR #${prNumber}: ${String(error)}`);
}
Expand All @@ -893,6 +916,10 @@ export async function postConsolidatedReview(
const anchors = parseDiffAnchors(diff);
const payload = buildReviewPayload(review, anchors, footer);

// Snapshot before the POST — see `listPriorRunsBestEffort` for why the
// ordering is load-bearing.
const prior = await listPriorRunsBestEffort(io, prNumber);

const result = await io.postReview(prNumber, payload);
if (result.status === 422 && payload.comments.length > 0) {
console.warn(
Expand All @@ -915,7 +942,7 @@ export async function postConsolidatedReview(
);
}

await supersedePriorRunsBestEffort(io, prNumber);
await supersedePriorRunsBestEffort(io, prNumber, prior);
}

// --- Real GitHub I/O (only runs when executed directly) ---
Expand Down
Loading
Loading