Skip to content

Commit 07198fa

Browse files
committed
Merge remote-tracking branch 'origin/develop' into feat/stack-runtime-rewrite
2 parents 559fa3c + 3488004 commit 07198fa

196 files changed

Lines changed: 22204 additions & 9243 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/ai-review/README.md‎

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ resolve ──────>┤ ├──> adjudicate ──> po
3232

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

74-
## Rollout
75-
76-
The pipeline currently runs only on-demand (`workflow_dispatch` or
77-
`/ai-review`) — the `pull_request` trigger in the workflow is commented out
78-
("shadow mode"). Rollout plan:
79-
80-
1. Run it manually against a sample of recent real PRs; tune the two prompts
81-
in this directory against what it actually produces. **This only works
82-
end-to-end once the current security fixes are merged to `develop`**: the
83-
prompts, schemas, and validation script are read from a trusted checkout of
84-
the _default branch_ (not the PR under review), and `post-review` checks
85-
out `develop` explicitly — so prompt/script tweaks on a feature branch
86-
don't take effect until they land on `develop`. Use `workflow_dispatch`
87-
against real merged/in-flight PRs post-merge to iterate.
88-
2. Once satisfied, uncomment the `pull_request` trigger block in
89-
`ai-review.yml`.
90-
3. In the same change, disable the Codex GitHub App's automatic reviews at
91-
<https://chatgpt.com/codex/settings/code-review> so PRs aren't
92-
double-reviewed.
74+
## Automatic trigger
75+
76+
The `pull_request` trigger (`opened` / `ready_for_review`) is live. The
77+
automatic path is **internal PRs only**: `resolve.ts` skips drafts, bots, and
78+
fork PRs, and requires the PR author to hold effective repository **write
79+
access** (`admin`/`write`, the same `WRITE_PERMISSIONS` gate as the manual
80+
`/ai-review` path). The permission lookup is the authoritative author check:
81+
a same-repo head branch only proves the branch exists in this repo, not that
82+
the PR author pushed it, so the author's own permission is always resolved.
83+
External contributors' PRs are never reviewed automatically; a maintainer
84+
comments `/ai-review` to request one.
85+
86+
Prompt/script tweaks take effect only once they land on `develop`: the
87+
prompts, schemas, and validation script are read from a trusted checkout of
88+
the _default branch_ (not the PR under review), and `post-review` checks out
89+
`develop` explicitly. Use `workflow_dispatch` against real merged/in-flight
90+
PRs post-merge to iterate.
91+
92+
The Codex GitHub App's automatic reviews must stay disabled at
93+
<https://chatgpt.com/codex/settings/code-review> so PRs aren't
94+
double-reviewed.
9395

9496
`merged-review.schema.json` uses `pattern` (on `category`) and `minItems` (on
9597
`sources`); some OpenAI structured-output strict-mode implementations have
@@ -161,15 +163,27 @@ run 400s on the output schema because of this, drop `pattern`/`minItems` from
161163
(`/ai-reviewers`, `/ai-review-please`, etc. don't fire). The workflow's job
162164
`if:` also pre-filters cheaply on `author_association` as defense-in-depth,
163165
but `resolve.ts`'s checks are the actual gate.
166+
- **The automatic trigger requires the PR author to hold write access.**
167+
`resolve.ts` resolves the PR author's effective repository permission and
168+
requires `admin`/`write` before an automatic review runs, on top of the
169+
fork/draft/bot skips — so an external contributor's PR can never spend
170+
review budget or feed the models without a maintainer explicitly asking
171+
via `/ai-review`.
164172
- **The only write-capable job runs exclusively trusted code.**
165173
`post-review` checks out the base branch (`develop`) explicitly and never
166-
the PR head, so a malicious PR cannot smuggle a change into the one job
167-
that can write back to it.
174+
the PR head, so a PR cannot smuggle a script change into the one job that
175+
can write back to it. The checkout pin alone is not the whole boundary for
176+
`pull_request` runs, though: GitHub executes the workflow FILE from the
177+
PR's own ref for those events. That is safe here because the automatic
178+
path only admits same-repo PRs, whose authors hold write access anyway
179+
(a workflow edit gains them nothing they don't already have), while fork
180+
PRs run with a read-only token and no secrets. `issue_comment` and
181+
`workflow_dispatch` runs always use the default branch's workflow file.
168182
- **Model text is sanitized before it's rendered.** `sanitizeModelText()`
169-
redacts secret-shaped substrings (`redactSecrets()`; see below), strips HTML
170-
comments (so injected diff content can't forge the hidden dedup/supersede
171-
markers), and neutralizes `@mentions`/`#issue-refs` in every model-provided
172-
string (`summary`, `claim`, `evidence`, `suggested_fix`,
183+
redacts secret-shaped substrings (`redactSecrets()`; see below), breaks
184+
every HTML comment opener (so injected diff content can't forge the hidden
185+
dedup/supersede markers), and neutralizes `@mentions`/`#issue-refs` in
186+
every model-provided string (`summary`, `claim`, `evidence`, `suggested_fix`,
173187
`adjudication.reason`) before it's posted. `file` is separately validated at
174188
parse time (`assertFindings`/`assertMergedReview` reject a backtick,
175189
newline, control character, `<`, or a reserved marker string in it) and

‎.github/scripts/ai-review/post-review.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type ReviewIo,
2121
type ReviewPayload,
2222
sanitizeFilePath,
23+
sanitizeModelText,
2324
supersededBody,
2425
truncateReviewBody,
2526
} from "./post-review.ts";
@@ -935,6 +936,20 @@ describe("sanitizeFilePath", () => {
935936
});
936937
});
937938

939+
describe("sanitizeModelText", () => {
940+
test("breaks a comment opener that stripping would have re-formed", () => {
941+
expect(sanitizeModelText("Forged <!<!---->-- supabase-ai-review:superseded --> marker")).toBe(
942+
"Forged <!<\u200B!---->-- supabase-ai-review:superseded --> marker",
943+
);
944+
});
945+
946+
test("keeps the zero-width mention and issue-ref breakers intact", () => {
947+
expect(sanitizeModelText("<!-- x --> @user #12")).toBe(
948+
"<\u200B!-- x --> @<!---->user #<!---->12",
949+
);
950+
});
951+
});
952+
938953
describe("redactSecrets", () => {
939954
test.each([
940955
["an Anthropic API key", "sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345"],
@@ -1030,7 +1045,16 @@ describe("post flow via injected ReviewIo", () => {
10301045
if (opts.failSupersede) {
10311046
return Promise.reject(new Error("listReviews failed"));
10321047
}
1033-
return Promise.resolve(opts.reviews ?? []);
1048+
// Mirror real GitHub: a review posted earlier in the same run shows
1049+
// up in later listings as a marker-bearing bot review. The supersede
1050+
// pass must snapshot BEFORE posting or it would wrap the fresh
1051+
// review as "superseded" too.
1052+
const alreadyPosted = postedReviews.map((payload, i) => ({
1053+
id: 900 + i,
1054+
body: payload.body,
1055+
authorLogin: "github-actions[bot]",
1056+
}));
1057+
return Promise.resolve([...(opts.reviews ?? []), ...alreadyPosted]);
10341058
},
10351059
listIssueComments: () => {
10361060
calls.push("listIssueComments");
@@ -1109,6 +1133,23 @@ describe("post flow via injected ReviewIo", () => {
11091133
expect(calls.indexOf("postReview")).toBeLessThan(calls.indexOf("updateReviewBody"));
11101134
});
11111135

1136+
test("the freshly posted review is never swept into its own supersede pass", async () => {
1137+
const review = makeMergedReview({ findings: [] });
1138+
const { io, updatedReviews, updatedComments, postedReviews, calls } = makeReviewIo({
1139+
diff: SINGLE_HUNK_DIFF,
1140+
});
1141+
1142+
await postConsolidatedReview(io, 42, review, footer);
1143+
1144+
// With no prior AI review on the PR, nothing may be wrapped as superseded
1145+
// — especially not the review this run just posted (which the fake's
1146+
// listReviews, like real GitHub, includes in post-POST listings).
1147+
expect(postedReviews).toHaveLength(1);
1148+
expect(updatedReviews).toEqual([]);
1149+
expect(updatedComments).toEqual([]);
1150+
expect(calls.indexOf("listReviews")).toBeLessThan(calls.indexOf("postReview"));
1151+
});
1152+
11121153
test("a review still posts even when the best-effort supersede fails", async () => {
11131154
const review = makeMergedReview({ findings: [] });
11141155
const { io, postedReviews } = makeReviewIo({ diff: SINGLE_HUNK_DIFF, failSupersede: true });

‎.github/scripts/ai-review/post-review.ts‎

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@
1717
* artifact, so a prompt-injected `Read` of a secret-bearing path can't
1818
* smuggle a credential out through the artifact even though the posted
1919
* review is already scrubbed at render time.
20-
* - `post` — posts the consolidated review, THEN best-effort supersedes any
21-
* prior AI review on the PR (the marker/dedup guard in `resolve.ts` should
22-
* normally prevent a second run, but `/ai-review` lets a maintainer force
23-
* one; posting before superseding, and treating the supersede as
24-
* best-effort, means a cosmetic supersede failure can never cost the real
25-
* review).
20+
* - `post` — snapshots the PR's prior AI reviews, posts the consolidated
21+
* review, THEN best-effort supersedes the snapshotted ones (the
22+
* marker/dedup guard in `resolve.ts` should normally prevent a second
23+
* run, but `/ai-review` lets a maintainer force one). The snapshot must
24+
* happen BEFORE the POST — the fresh review is itself a marker-bearing
25+
* bot review, so a post-hoc listing would sweep it into its own
26+
* supersede pass and every new review would collapse itself. Posting
27+
* before superseding, and treating both the snapshot and the supersede
28+
* as best-effort, means a cosmetic failure can never cost the real
29+
* review.
2630
*
2731
* `parseDiffAnchors`, `partitionFindings`, `renderReviewBody`,
2832
* `renderInlineComment`, `buildReviewPayload`, `foldInlineCommentsIntoBody`,
@@ -39,7 +43,7 @@
3943
export const AI_REVIEW_MARKER = "<!-- supabase-ai-review -->";
4044
const SUPERSEDED_SUMMARY = "Superseded by a newer AI review";
4145
/** Hidden marker `isSuperseded` looks for. Kept out of the human-readable
42-
* `SUPERSEDED_SUMMARY` text and stripped by `sanitizeModelText` so a model
46+
* `SUPERSEDED_SUMMARY` text and broken by `sanitizeModelText` so a model
4347
* can't forge or evade a supersede by echoing the visible text into a
4448
* `claim`/`summary` field. */
4549
const SUPERSEDED_MARKER = "<!-- supabase-ai-review:superseded -->";
@@ -502,7 +506,7 @@ export function computeVerdictCounts(findings: MergedFinding[]): VerdictCounts {
502506

503507
const MENTION_PATTERN = /@(?=\w)/g;
504508
const ISSUE_REF_PATTERN = /#(?=\d)/g;
505-
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
509+
const HTML_COMMENT_OPENER_PATTERN = /<!--/g;
506510

507511
const REDACTED_SECRET = "«redacted»";
508512

@@ -560,8 +564,8 @@ export function redactSecretsDeep(value: unknown): unknown {
560564

561565
/**
562566
* Neutralizes a model-provided string before it's rendered into a
563-
* `github-actions[bot]` review: redacts secret-shaped substrings first, strips
564-
* HTML comments (so injected diff content can't forge the hidden
567+
* `github-actions[bot]` review: redacts secret-shaped substrings first, breaks
568+
* HTML comment openers (so injected diff content can't forge the hidden
565569
* `AI_REVIEW_MARKER`/`SUPERSEDED_MARKER` comments), then breaks
566570
* `@mention`/`#123` syntax with a zero-width HTML comment so GitHub never
567571
* renders them as a live mention or issue reference. Pure; apply to every
@@ -570,7 +574,7 @@ export function redactSecretsDeep(value: unknown): unknown {
570574
*/
571575
export function sanitizeModelText(text: string): string {
572576
return redactSecrets(text)
573-
.replace(HTML_COMMENT_PATTERN, "")
577+
.replace(HTML_COMMENT_OPENER_PATTERN, "<\u200B!--")
574578
.replace(MENTION_PATTERN, "@<!---->")
575579
.replace(ISSUE_REF_PATTERN, "#<!---->");
576580
}
@@ -842,42 +846,61 @@ export interface ReviewIo {
842846
) => Promise<{ status: number; body?: string }>;
843847
}
844848

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

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

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

875-
/** Best-effort wrapper around `supersedePriorRuns`: a cosmetic failure here
876-
* (e.g. a transient 404 on a review that was deleted mid-run) must never
877-
* fail the pipeline after the real review/notice has already been posted. */
878-
async function supersedePriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise<void> {
888+
/** Wraps the snapshotted prior AI reviews/comments in a superseded `<details>`
889+
* block. Best-effort: a cosmetic failure here (e.g. a transient 404 on a
890+
* review that was deleted mid-run) must never fail the pipeline after the
891+
* real review has already been posted. */
892+
async function supersedePriorRunsBestEffort(
893+
io: ReviewIo,
894+
prNumber: number,
895+
prior: PriorRuns,
896+
): Promise<void> {
879897
try {
880-
await supersedePriorRuns(io, prNumber);
898+
for (const review of prior.reviews) {
899+
await io.updateReviewBody(prNumber, review.id, supersededBody(review.body));
900+
}
901+
for (const comment of prior.comments) {
902+
await io.updateIssueCommentBody(comment.id, supersededBody(comment.body));
903+
}
881904
} catch (error) {
882905
console.warn(`Could not supersede prior AI review runs on PR #${prNumber}: ${String(error)}`);
883906
}
@@ -893,6 +916,10 @@ export async function postConsolidatedReview(
893916
const anchors = parseDiffAnchors(diff);
894917
const payload = buildReviewPayload(review, anchors, footer);
895918

919+
// Snapshot before the POST — see `listPriorRunsBestEffort` for why the
920+
// ordering is load-bearing.
921+
const prior = await listPriorRunsBestEffort(io, prNumber);
922+
896923
const result = await io.postReview(prNumber, payload);
897924
if (result.status === 422 && payload.comments.length > 0) {
898925
console.warn(
@@ -915,7 +942,7 @@ export async function postConsolidatedReview(
915942
);
916943
}
917944

918-
await supersedePriorRunsBestEffort(io, prNumber);
945+
await supersedePriorRunsBestEffort(io, prNumber, prior);
919946
}
920947

921948
// --- Real GitHub I/O (only runs when executed directly) ---

0 commit comments

Comments
 (0)