🤖 Generated from the Falconiere/toolu-ghactions monorepo. Edit there, not here.
Audits the diff against an 8-dimension checklist — correctness, security, performance, test coverage, doc accuracy, tight assertions, migration warnings, and adherence to the project's own convention files — by running one model through either OpenRouter (any OpenAI-compatible model id) or the native DeepSeek API (api.deepseek.com), selected with a single PROVIDER input, via the Vercel AI SDK (generateObject + Zod: structured output with retries, reasoning disabled). Posts a structured, machine-readable comment with inline, committable suggestions.
Quick start · Choosing a model · How it works · Example verdict · Coverage ledger · Finding clustering · Custom identity · @mention re-trigger · Review memory · Inputs · Outputs · v7 migration
Part of the toolu-ghactions monorepo.
Add an OpenRouter API key to your repo secrets, then drop this into .github/workflows/code-review.yml:
name: Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
permissions:
contents: read
pull-requests: write
issues: write
security-events: write # upload the gitleaks/opengrep SARIF to the Code Scanning tab
concurrency:
group: code-review-${{ github.ref }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the merge-base resolves without deepening
- uses: falconiere/toolu-ghactions/code-review@v4
with:
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
fetch-depth: 0is recommended but optional — on a shallow checkout the action deepens the history itself to find the merge-base.
Use MODEL_ID to switch models and REVIEW_PROMPT_FILE for a custom checklist:
- uses: falconiere/toolu-ghactions/code-review@v4
with:
PROVIDER: openrouter
MODEL_ID: 'anthropic/claude-sonnet-4'
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
REVIEW_PROMPT_FILE: '.github/review-prompt.md'On every PR push, the action shapes the diff, sends it to the configured model, and posts a verdict comment directly on the PR.
The action runs one model, selected with three flat inputs:
PROVIDER—openrouter(default) ordeepseek(nativeapi.deepseek.com).MODEL_ID— the model id (per-provider default if omitted).API_KEY— the provider API key (required).
By default it is resolved through OpenRouter; you can also point it at the native DeepSeek API (see below). Anything OpenRouter serves works as long as it's OpenAI-compatible:
- uses: falconiere/toolu-ghactions/code-review@v4
with:
PROVIDER: openrouter
MODEL_ID: 'anthropic/claude-sonnet-4-5' # default: deepseek/deepseek-v4-pro
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
MAX_TOKENS: '16384' # per-request completion budget (default 8192)For OpenRouter, MODEL_ID is an OpenRouter model id — openai/gpt-4o,
anthropic/claude-sonnet-4-5, deepseek/deepseek-v4-flash, moonshotai/kimi-k2,
and so on. One key, one endpoint: OpenRouter fronts every vendor, so switching
models is a string change, not a new integration.
Under the hood the action calls the model with the Vercel AI SDK's
generateObject against a Zod verdict schema, so the response is structured by
construction (with automatic retries and schema repair) rather than parsed out
of free text. Reasoning is disabled to keep the run fast and the token budget on
the review itself. If the model returns empty or unparseable output after retries,
the action surfaces an error verdict carrying the finish reason — rendered as
"🚫 Review incomplete" and labeled request-changes, so a failed review
never auto-merges. It never emits a silent null verdict.
To call DeepSeek's own API (api.deepseek.com) directly instead of going through
OpenRouter — lower cost, direct billing — set PROVIDER: deepseek and a native
model id (no vendor prefix):
- uses: falconiere/toolu-ghactions/code-review@v4
with:
PROVIDER: deepseek
MODEL_ID: deepseek-v4-flash
API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}PROVIDER: deepseek hits api.deepseek.com directly (lower cost, direct billing).
MODEL_ID takes a native DeepSeek model id (no vendor prefix) and defaults to
deepseek-v4-flash (fast, 1M context) when omitted. API_KEY is your
DeepSeek key — here ${{ secrets.DEEPSEEK_API_KEY }} is just the name of your
GitHub repo secret, passed into the single API_KEY input.
Only openrouter (default) and deepseek are implemented; any other PROVIDER
value fails the action with an error that points you at routing it through OpenRouter
instead (PROVIDER: "openrouter", MODEL_ID: "<vendor>/<model>").
v4 is a breaking change. The split provider/key inputs and the multi-vendor ensemble inputs are gone entirely — they no longer exist on the action (they are not silent no-ops). Migrate to the three flat provider inputs:
| Removed input | Replacement |
|---|---|
OPENROUTER_API_KEY, DEEPSEEK_API_KEY |
API_KEY (the key for the selected PROVIDER) |
MODEL |
MODEL_ID |
PROVIDERS |
PROVIDER + MODEL_ID + API_KEY (a single model only) |
MERGE_STRATEGY, FALLBACK_MODEL, REVIEW_MODE, ENFORCE_JSON_SCHEMA |
removed — one model, schema always enforced |
The action is a hybrid: deterministic scanners run first, then the LLM triages their findings and adds its own judgment. This makes the objective findings reproducible and means a provider error never leaves the PR un-reviewed.
- Secrets — gitleaks (
RUN_SECRET_SCAN, default on). - SAST — Opengrep (
RUN_SAST, default on; rules viaSAST_RULES).
Both run as steps of this composite action (pinned release binaries) and write SARIF.
Their findings (1) upload to the repo's Code Scanning tab — which requires
security-events: write in your workflow permissions — and (2) are passed to the LLM
as TRUSTED context to assess; confirmed ones appear in the verdict comment tagged with
their tool. A scanner that fails to install or run is non-fatal: the review degrades to
LLM-only. On a fork PR (where security-events: write and the token are read-only) the
SARIF upload is skipped and the review still posts as a comment.
If the LLM call errors, the comment still shows the deterministic findings under a Mechanical checks section with an "LLM judgment unavailable" note — never a blank verdict.
The action runs a shape → review → post pipeline:
1 — Shape the diff. Resolves the merge-base (deepening a shallow checkout if
needed), strips noise so the model reviews only human-authored changes, drops
binaries, and line-primes every diff line with its real source line number so
findings anchor to actual lines. The diff runs with rename detection (-M), so
a git mv appears as a rename (rename from/rename to plus only its real
edits, backed by a ## Renamed Files manifest) instead of being misread as a
deletion plus a brand-new file. Deleted files are classified from the base
commit, where their content still exists.
The noise filter (each dropped file is reported in the comment) covers:
- Lockfiles across ecosystems —
*.lock,*-lock.json,go.sum,npm-shrinkwrap.json,packages.lock.json,Package.resolved,.terraform.lock.hcl,*.gradle.lockfile,pnpm-lock.yaml,bun.lockb. - Vendored dependency dirs —
node_modules/,vendor/,third_party/,Pods/,Carthage/,bower_components/,.yarn/{releases,plugins,unplugged}/. - Build / output dirs —
dist/,build/,out/,target/,coverage/,__pycache__/,.next/,.nuxt/,.svelte-kit/,.terraform/,obj/, … (and*.pyc). - Generated code — minified (
*.min.*), source maps (*.map), protobuf/gRPC (*.pb.go,*_pb2.py), graphql-codegen (__generated__/,*.generated.ts), .NET (*.designer.cs), Dart (*.g.dart,*.freezed.dart), JS bundles (*.bundle.js), plus any file flagged@generated/DO NOT EDITby content. - Repo-marked generated — any path with
linguist-generatedin.gitattributes(the same signal GitHub's diff UI uses). - Your own globs — anything matched by the
EXCLUDE_GLOBSinput.
Migrations (migrations/) and snapshot tests (*.snap) are kept for review
by default — add them to EXCLUDE_GLOBS if you'd rather skip them.
2 — Gather rules. Reads the repo's own convention files from the base ref and folds them into the prompt (see Project conventions).
3 — Review. Builds the system + user prompt and calls the configured model (the
PROVIDER backend — OpenRouter or native DeepSeek) via
the Vercel AI SDK (generateObject + a Zod verdict schema) against the full
8-dimension checklist (the 8th, convention adherence, applies only when project
rules were found). Output is structured with automatic retries; reasoning is off on
both backends (reasoning: {effort: "none"} on OpenRouter, thinking: {type: "disabled"} on native DeepSeek) — it is billed against MAX_TOKENS, so a thinking
model spends the budget before emitting any JSON.
A response the schema rejects is not thrown away on sight: the findings completed
before a truncation cut are salvaged, and a complete response that merely deviates
("request_changes" for the verdict, a quoted line number, "CRITICAL" for a
severity) is normalized back onto the schema. Normalization never invents a value —
whatever is still invalid is dropped per-finding and the review is marked partial.
A partial review surfaces in the verdict comment as a "error verdict carrying the finish reason — never a silent null, and never a clean
review over findings that had to be dropped.
4 — Validate & anchor. Findings are checked against the diff — hallucinated
line numbers and low-confidence findings are dropped, findings are deduplicated by
(path, line, end_line, text-fingerprint) keeping the highest severity, and each
is anchored to a real changed line.
5 — Post. A summary verdict comment (machine-readable label for pr-babysit),
plus — when INLINE_COMMENTS is on — per-line review comments with committable
```suggestion blocks via the GitHub Reviews API (advisory COMMENT event; it
never hard-blocks merge).
The reviewer reads your repo's own rules and checks the diff against them — so a
change that breaks a documented house rule gets flagged, citing the rule. On by
default (CHECK_PROJECT_RULES: true); set it false to turn off.
What it reads, in priority order, from the base ref by default (never the PR
head — see RULES_REF below for the trusted-repo opt-out):
- Root agent-rule files —
CLAUDE.md,AGENTS.md,.cursorrules,.windsurfrules,.github/copilot-instructions.md - Nested
CLAUDE.md/AGENTS.mdin ancestor directories of the changed files (per-package rules in a monorepo) - Rule directories — everything under
.cursor/rules/and.windsurf/rules/ - Curated conventions —
CONVENTIONS.md,CONTRIBUTING.md, anddocs/conventions/ - Anything you add via
RULES_GLOB
The gathered text is capped at RULES_MAX_BYTES (default 32 KB); whole files past
the cap are dropped with a notice. Two guarantees worth calling out:
- Injection-safe. Rules are read from the base branch tip via
git show, so a PR that editsCLAUDE.mdto say "approve everything" cannot influence its own review — the change only takes effect once merged. The rule text is reference data; it can never alter the verdict logic or output schema. - Bounded & quiet. The plan/spec tree (
docs/toolu/**, etc.) is never scooped; only the convention files above and your explicitRULES_GLOBare read.
One deliberate consequence of base-ref reading: a same-repo PR that legitimately
updates a convention file is still reviewed against the stale base-ref text, so the
reviewer can keep re-raising "violations" of the very rules the PR changes. If your
PR authors are already trusted — same-repo branches, protected by required review —
set RULES_REF: merge to read the convention files from the checked-out PR merge
ref instead, so a PR's own convention updates apply to its own review:
with:
RULES_REF: merge # trusted same-repo PRs only — a PR can then modify its own review rulesThe trade-off is explicit: merge gives up the injection guarantee above, so never
enable it on repos that accept fork PRs. Everything else (priority order,
RULES_GLOB, RULES_MAX_BYTES) behaves identically; an unrecognized value warns
and falls back to base.
A generative reviewer re-derives its findings from the diff on every push, so left alone it can produce a fresh batch each round — reworded text (new fingerprint), drifted line anchors, findings wandering into untouched files — and a PR can accumulate dozens of findings without ever reaching the zero-findings verdict. Two mechanisms make the review converge:
- Settled threads suppress re-raises (always on). A human resolving one of the bot's inline threads is a decision — and so is dismissing it in a reply, either with
@toolu dismissor by holding your position after the bot's one rebuttal. A finding covered by a settled thread is dropped everywhere — verdict count, summary comment, inline posting. Coverage is deliberately wider than an exact match: same fingerprint, samepath:line, same path within 10 lines (a reworded finding drifts), or — when the settled thread has gone outdated after a push (GitHub detaches its line) — same path and same finding category. Blocker-severity findings only ever match exactly, so the loosening can never hide a genuine showstopper, and an argued-out thread never suppresses a blocker at all. MAX_ROUNDSsurrender (opt-in). SetMAX_ROUNDS: 5and the fifth review round (counted from theREVIEW_MEMORYhistory) that would still saychangeswith only sub-blocker findings is downgraded toapproved: the findings stay listed as advisory, the comment carries an explicit🔁 Round capcallout, andFAIL_ONstops failing the job. One blocker finding disables the cap for that round.0(default) keeps the old block-forever behavior.
With INLINE_COMMENTS: true (default), findings are posted as inline review comments anchored to the exact file and line, batched at up to 30 comments per createReview call. When the model has a concrete, high-confidence fix it attaches a ```suggestion block you can commit straight from the PR. Anchors are validated against GitHub's own view of the PR diff before posting: a finding on a file GitHub's own diff omits (or whose line can't be mapped onto it) is never posted inline — GitHub's Reviews API rejects the whole createReview call outright if it contains even one such comment, so there is no file-level fallback. It is instead rendered in the sticky comment's ### Unanchored findings section, so it is never silently lost. If a batch still fails after anchor validation (a comment GitHub's API rejects for a reason we couldn't predict up front), the batch is bisected to isolate the poison comment — every other comment in the batch still posts, and the isolated one is rendered in the sticky comment's ### Findings GitHub rejected inline section, so one bad comment can no longer zero out the whole review or silently drop a finding. Set INLINE_COMMENTS: false for a summary-comment-only review.
The verdict comment is compatible with parse-verdict.sh and the pr-babysit automation loop, so toolu users can drop this into CI and their existing babysit workflow consumes the verdict without changes. The elements that contract depends on — the ### Code Review heading, at least one checked - [x] box, the ### Findings block, and the machine-readable label — are present in both verbosity modes (below).
The default compact shape — a single checklist line, findings sorted worst-severity-first, and sections omitted when empty:
**AI Code Review finished in 2m 15s** —— [View job](https://github.com/...)
### Code Review — `feat/add-login`
- [x] Reviewed 4-file diff — verdict set
**Verdict:** ✅ Approved 🔵 2 low
### Review Plan
Reviewing 4 files: 1 correctness-critical (format.ts), 1 test-quality, 1 config,
1 security-sensitive (login.ts).
### Findings (2)
`src/utils/format.ts:17`: low: Comment says 'Temporary workaround' with no
removal date or tracking issue.
`src/utils/__tests__/format.test.ts:6`: low: Test assertion uses loose suffix
match. Tighten to assert full identity.
`merge-approved`Comment verbosity. VERBOSITY (default compact) controls the comment shape:
compact(default) — the checklist collapses to one line, and the review-memory recap lists changed findings as`path:line`refs (the full text already lives once in### Findings).full— restores the five-line static checklist and the inline recap text.
These changes apply in both modes, independent of VERBOSITY:
- The auto-generated
### Top-N must-fixsection is gone — it was a verbatim duplicate of the (now worst-first-sorted) Findings list. The section renders only when the model supplies an explicittop_must_fix. ### Review Planand### Other checksare omitted entirely when the model returns nothing for them (no_No … provided._filler).
The verdict label at the bottom is machine-readable: `merge-approved` or `request-changes`. pr-babysit parses it to decide whether the PR is ready to merge. Unless MANAGE_LABELS is false, the same verdict is also applied as a real PR label chip (the opposite one is removed), so PRs are filterable in the GitHub UI — this needs issues: write in the workflow's permissions block.
Every review posts a coverage ledger, so a large PR can never silently lose files the way a flat "1 of 9 chunks failed" note used to — the sticky comment always accounts for every changed path by name when something needs your attention. It renders as its own ### Coverage section: a one-line summary of counts per status is always present, plus per-path rows for the statuses below that must stay visible, capped at 50 rows (a "… N more" line covers the rest).
| Status | Meaning |
|---|---|
reviewed |
The model read this file's diff and returned a verdict for it. |
pattern |
This file's change is byte-identical (once normalized) to the same change in 3+ other files — only the group's exemplar was actually sent to a model call; a finding on the exemplar applies to every member. See Finding clustering. |
rename |
An exact or near-exact rename with no content of its own to review. |
formatting |
A whitespace-only change (empty diff under git diff -w). |
vendored / generated |
Excluded via a linguist-vendored/linguist-generated attribute in .gitattributes — a repo-declared exclusion, distinct from the built-in noise filter below. |
excluded |
Dropped by the noise filter or binary detection before diffing; a reason names why (lockfile, build-output, large-file, minified, generated, binary, …). |
carried |
Out of this round's incremental scope (or a cluster member whose exemplar wasn't re-examined) — not re-reviewed this round, so its prior finding rides forward unchanged rather than being silently dropped or falsely reported fixed. |
unreviewed |
Attempted and failed — a schema failure that survived bisection, a file past a set MAX_CHUNKS cap, or (rare) a path no layer accounted for at all. |
pending |
Not yet attempted because MAX_WALL_MS ran out this run — resumable, see @toolu resume. |
Any unreviewed or pending entry degrades a would-be approved verdict to error — the same fail-safe rule the reviewer has always applied to a failed chunk, now driven by the ledger: an approval can't honestly claim to cover code nobody read.
Findings that can't reach an inline comment are never silently lost either — they render in two companion sections, each capped the same way: ### Unanchored findings (files GitHub's own diff omits, or whose line can't be mapped onto it) and ### Findings GitHub rejected inline (a comment isolated by 422-bisection that still failed on its own). See Inline comments & suggestions.
The ledger section is itself droppable under GitHub's 65 KB comment-size ceiling, but last: an oversized comment first drops the per-path exception rows (keeping the one-line summary), then drops the whole ### Coverage section, and only then starts trimming findings, worst-severity-last. Coverage accounting is the last thing to go, not the first.
A defect that repeats identically across many files — the same convention violation copy-pasted, the same missing check after a mechanical refactor — used to post one inline comment per file, sometimes hundreds on a large PR. Findings sharing the same category and near-identical text across 3 or more files now collapse into one cluster: one inline comment, posted on an exemplar file, whose body enumerates every other file carrying the same finding. Smaller repeats (1–2 files) stay individual comments, as before.
The sticky comment's ### Repeated findings section lists every multi-member cluster the same way — the exemplar's finding, the member count, and the full member-path list — and says explicitly: dismissing or resolving the exemplar's thread dismisses the whole pattern, member findings included. That's deliberate, not a quirk of the implementation: every member still keeps its own individual fingerprint internally (nothing is merged for tracking purposes — the underlying finding count in the marker and in Review memory's recap is the true count), so a later push that fixes only some members re-splits the cluster and reopens a thread under whichever exemplar is still valid.
Across rounds, cluster identity is pinned: the same exemplar's thread is reused while that finding is still present. If the exemplar gets fixed but other members remain, the thread stays open under a newly promoted exemplar (the lowest surviving member) with a reply explaining the handoff — a cluster's thread resolves only once every member is gone.
By default the bot posts as the generic github-actions[bot] — anonymous, with
no project face. To brand it as Toolu — Code Review with its own avatar and
header logo, create a GitHub App and pass its APP_ID + APP_PRIVATE_KEY. The
private key is used only to mint a short-lived installation token at the start
of the run; it is never logged, and the comment then posts under the App's
identity. Without these inputs nothing changes — you stay on github-actions[bot].
The body header (logo + name) renders on both paths — even the
github-actions[bot]fallback reads as "Toolu — Code Review". The App only changes the posting account (avatar + login on the comment).
It's your App, not ours. This action is App-agnostic — it never ships or shares a private key. Whoever holds an App's key can post as that App, so a single shared identity across everyone's repos is impossible without a hosted token broker (which this action is not). To get a custom chip you create your own App and keep your own key in your own secrets. The App can be private (only your account can install it) — public is unnecessary.
Open code-review/app-manifest.html (host it on GitHub
Pages, or just open the file in a browser). Enter your org (or leave blank for a
personal account) and click Create the App — GitHub pre-fills the name, the
four least-privilege permissions, and disables the webhook from
app-manifest.json. Then, on the created App's page:
- Copy the App ID.
- Click Generate a private key → downloads a
.pem. - Install the App on the repo/org you want reviewed.
- Add both as secrets (repo or org):
APP_IDandAPP_PRIVATE_KEY. Paste the full PEM, or base64-encode it first (base64 -w0 key.pem) to keep it on a single line — the action auto-detects and decodes either form.
Prefer clicking through github.com → Settings → Developer settings → GitHub Apps:
- New GitHub App. Name it whatever you like; uncheck/disable the webhook.
- Upload a logo/avatar so the App has a face on the PR.
- Repository permissions — only these four: Pull requests: Read & write, Issues: Read & write, Contents: Read, Metadata: Read.
- Install it on the repo (or org), then Generate a private key.
- Store the App id and private key (PEM) as repo/org secrets.
- uses: falconiere/toolu-ghactions/code-review@v4
with:
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}The private key only signs a JWT exchanged for a short-lived installation token;
it is never written to logs. If exactly one of APP_ID/APP_PRIVATE_KEY is
set (or the mint fails), the action logs a [WARN] and continues on the
github-actions[bot] fallback.
Fork PRs stay unbranded. GitHub withholds secrets from
pull_requestruns triggered by forks, soAPP_PRIVATE_KEYis empty there and the bot falls back togithub-actions[bot](still fully functional). The branded identity shows on same-repo PRs and on@toolure-triggers by collaborators.
The header logo lives at
code-review/assets/logo.pngand is currently a placeholder — replace it with your own art, or pointBOT_LOGO_URL/BOT_NAMEat your branding.
On a PR that iterates, a maintainer can ask for a fresh pass from a comment:
@toolu review
@toolu review focus on the auth changes
To enable it, the workflow must listen on both pull_request and
issue_comment (the latter is where comments arrive), carry the right
permissions:, and set a per-PR concurrency: group so two quick re-triggers
don't race on the single sticky comment:
name: Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
issues: write
security-events: write # upload the gitleaks/opengrep SARIF to the Code Scanning tab
concurrency:
group: code-review-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: falconiere/toolu-ghactions/code-review@v4
with:
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}The concurrency: group is keyed per PR — note the
github.event.issue.number || github.event.pull_request.number fallback, since
issue_comment and pull_request events expose the PR number on different
fields. On a comment trigger the action self-fetches the PR head, so no extra
checkout step is needed.
Security gate. A comment trigger runs with the repo's secrets, so the gate fails closed:
- Only commenters with write / maintain / admin (per
MIN_TRIGGER_PERMISSION, defaultwrite) can trigger. The permission check is denied on any error (non-2xx, missing field, network failure) — never failing open. - Comments from bots, non-PR issues, or comments without the
TRIGGER_PHRASE+review(orTRIGGER_PHRASE+resume, below) are ignored before any permission API call (no noise). - The trailing text (
focus on …) is treated as an untrusted focus hint: it is sanitized and injected into the user prompt inside a delimited UNTRUSTED block as a hint about where to look — never as instructions, and it cannot change the task, output schema, or verdict rules. - An allowed trigger reacts 👀 on the comment; a denied one reacts 👎.
- A scoped/steered review (
@toolu review focus on …) does not recompute "resolved" — see Review memory.
When MAX_WALL_MS cuts a run short mid-review, the sticky comment says so, and the round is left resumable: the marker records which files were attempted-and-failed (unreviewed) and which were never reached (pending) without touching reviewed_tree, so nothing yet reviewed counts as done. Three things resume it, and all three review only the exception paths — never the whole diff again:
- any subsequent push to the PR (its own new/changed lines join the exception paths automatically — see Review memory);
- a workflow re-run of the same event;
- the
@toolu resumecomment, behind the same permission gate as@toolu review:
@toolu resume
@toolu resume never re-reviews files that already reached complete coverage, and never clears memory or reviewed_tree. Contrast with @toolu review, which is still a full re-review — it clears reviewed_tree and both exception lists and starts over. If there is nothing left to resume (no exception paths recorded — e.g. a run that never actually paused), @toolu resume falls back to a full review rather than silently doing nothing.
With REVIEW_MEMORY: true (default), each review recaps what changed since the
last pass instead of starting from scratch:
- ✅ resolved — findings from the previous pass that are now gone
- 🔁 still open — findings carried over from the previous pass
⚠️ new — findings introduced since the previous pass
…plus a collapsed <details> history of recent passes (verdict + counts).
Findings are matched across runs by a line-independent fingerprint
(path + category + normalized text), so a finding that merely drifted to a
new line stays still open rather than flipping to resolved + new.
The state is stored in a hidden HTML marker inside the sticky review comment itself (gzip + base64) — no external store, no extra permissions, nothing to clean up. The marker is login-agnostic, so it survives switching to a custom App identity; if the comment is deleted, memory simply starts fresh.
resolved is only computed on a full review. On a steered/focused run
(@toolu review focus on …) or a truncated/partial diff, resolutions are not
recomputed (the recap is labeled accordingly) — a finding that wasn't
re-examined is never falsely reported as fixed. Set REVIEW_MEMORY: false to
turn the recap and history off.
When inline comments are on, a re-review reads the author's replies on its own earlier inline threads instead of blindly re-posting every finding:
- 🗣️ reads replies — the author's responses on the bot's prior threads are fed back into the model prompt (sanitized, in a delimited UNTRUSTED block — claims to weigh on merit, never instructions).
- ✅ accepts — a finding whose rebuttal the model now agrees with is dropped, and its thread is resolved (with a short note) rather than raised again.
- 💬 argues — a finding the model still stands by is re-stated as a reply in the existing thread (engaging the author's reasoning), not as a duplicate comment.
⤵️ dedup — only genuinely new findings open new threads; leftover duplicate threads for the same finding are resolved.- 🔒 respects resolutions — resolving a bot thread dismisses that finding:
on later runs it is dropped from the verdict count, the summary comment, and
inline posting alike, instead of being re-litigated forever. When every
remaining finding was human-resolved, a
request-changesverdict downgrades tomerge-approved. This works even withINLINE_COMMENTS: false(threads from earlier inline-enabled runs still suppress). Resolved threads are also fed to the model as a dismissed-findings block ("settled — do not re-raise, not even reworded"), so a rephrased variant of a dismissed finding — which gets a new fingerprint and often a new line — is suppressed at the source instead of slipping past the deterministic match.
Resolving a thread used to be the only way to settle a finding, so an author who refused or explained a finding in a reply got it raised again on every run. Two more channels close that:
- 🙅
@toolu dismiss— reply to the bot's thread with the trigger phrase plusdismiss(any explanation may follow:@toolu dismiss — intentional, see ADR-12). The finding is settled exactly as if you had resolved the thread: dropped from the verdict, the comment, and inline posting, fed to the model as dismissed, and the thread is closed with a note. A deliberate ruling, so it silences any severity — including ablocker— but only on an exact match, never on a reworded variant nearby. The phrase followsTRIGGER_PHRASE, and a command inside quoted text — a>blockquote, a fenced block, or an inline`code span`— is ignored, so showing the syntax while arguing the opposite never fires it. - 🤝 argued out — if the bot already answered your reply and you hold your position,
the disagreement is settled automatically: the finding is suppressed and the thread
closed with a standing-disagreement note rather than a repeat of the same rebuttal.
You always get the bot's one rebuttal first — your first reply is not a dismissal.
This is the reviewer conceding an argument, not a ruling on the finding, so it never
silences a
blocker(the same ruleMAX_ROUNDSfollows).
Both are gated on the same repo permission as the @mention re-trigger
(MIN_TRIGGER_PERMISSION, default write) and fail closed — an unprivileged
commenter cannot silence the reviewer, and a failing permissions API leaves the finding
standing. That matches GitHub's own model: resolving a thread already requires write or
triage access.
Threads are matched to findings by the same line-independent fingerprint used by
Review memory, carried in a hidden marker on each inline comment.
Only the bot's own threads are touched — human review threads are never modified.
Thread reads/writes are best-effort: a GitHub API hiccup degrades to the previous
"post fresh" behavior and never fails the job. If the bot's
Re-reviewed — this no longer applies (addressed, or point taken). Resolving.
reply lands but GitHub's separate resolve mutation fails, that exact bot-authored
note acts as a durable marker: the next run suppresses the finding and retries the
resolution without posting the note again.
| Input | Required | Default | Description |
|---|---|---|---|
PROVIDER |
no | openrouter |
Backend to call: openrouter (any OpenAI-compatible model via OpenRouter) or deepseek (native api.deepseek.com, lower cost). Any other value fails the action with an error suggesting PROVIDER: "openrouter" + MODEL_ID: "<vendor>/<model>". See Native DeepSeek API. |
MODEL_ID |
no | per-provider | Model id. Defaults to deepseek/deepseek-v4-pro for openrouter (1M-token context, 384k max output, so large diffs and verbose reviews rarely truncate) and deepseek-v4-flash for deepseek. Use <vendor>/<model> ids for OpenRouter; bare ids for native DeepSeek. Pick one with reliable JSON-schema structured output. |
API_KEY |
yes | — | API key for the selected PROVIDER (OpenRouter or DeepSeek). Required — an empty value fails the action. Pass via a step-level env:/secrets reference for secret hygiene. |
MAX_TOKENS |
no | 8192 |
Max completion-token budget per request (always sent — omitting it makes OpenRouter reserve the model's full output window against your credits and can 402-reject). A response truncated at this limit (finish_reason: length) is retried with a doubled budget up to the 131072 ceiling (escalations don't consume hang retries); whatever the outcome, the findings completed before a cut are salvaged. |
MIN_CONFIDENCE |
no | high |
Drop findings below this confidence unless severity is blocker/high (high or medium) |
INLINE_COMMENTS |
no | true |
Post per-line review comments with committable code suggestions (Reviews API), in addition to the summary comment |
MANAGE_LABELS |
no | true |
Set a real PR label chip matching the verdict (merge-approved / request-changes) and remove the opposite one. Requires issues: write. |
FAIL_ON |
no | changes |
Comma-separated verdicts that fail the job (turn this check red so branch protection can block the PR): changes, error, or both. Defaults to changes — the job goes red when the bot requests changes. Set none to keep the job green on every verdict (advisory only), or changes,error to also block when the review could not run (error). The comment, label, and outputs are still posted; only the exit code changes. Governs the verdict-driven gate only — a thrown infra error fails the job regardless. Mark this check Required in branch protection for the red to actually block a merge. See Blocking merges. |
BASE_BRANCH |
no | main |
Base branch for diff comparison. Falls back to GITHUB_BASE_REF if unset. |
REVIEW_PROMPT_FILE |
no | (8-dimension checklist) | Path to a markdown file (relative to repo root) with a custom review prompt. Overrides the default checklist. Project conventions are still gathered and injected, but a custom prompt supplies its own dimensions. |
CODEBASE_OVERVIEW |
no | — | High-level context about the codebase (framework, patterns, architecture) injected into the review prompt. |
CHECK_PROJECT_RULES |
no | true |
Auto-read the repo's own convention files from the base ref (CLAUDE.md, AGENTS.md, .cursor/rules, .cursorrules, .windsurfrules, .github/copilot-instructions.md, plus CONVENTIONS.md / CONTRIBUTING.md / docs/conventions/) and review the diff against them via the Convention adherence dimension. Set false to disable. See Project conventions. |
RULES_GLOB |
no | — | Extra path globs (relative to repo root, newline- or comma-separated) to include as project rules, e.g. docs/architecture/**. Matched against tracked files at the rules ref (see RULES_REF). |
RULES_REF |
no | base |
Which ref the convention files are read from. base (default) reads the base-branch tip — injection-safe: a PR cannot change the rules it is reviewed against. merge reads the checked-out PR merge ref instead, so a same-repo PR that legitimately updates a convention is reviewed against its own text — only enable it where PR authors are already trusted (same-repo branches, protected by required review); it lets a PR modify its own review rules, so never use it on repos accepting fork PRs. An unrecognized value warns and falls back to base. See Project conventions. |
EXCLUDE_GLOBS |
no | — | Extra path globs (newline- or comma-separated) to exclude from the reviewed diff, on top of the built-in generated/vendored/lockfile set and any .gitattributes linguist-generated paths, e.g. migrations/**, **/*.snap. Excluded files are still committed and CI-checked — only kept out of the LLM review. |
RULES_MAX_BYTES |
no | 32768 |
Byte cap on the gathered rules. Files are added in priority order until the cap; whole files past it are dropped with a truncation notice. |
MAX_FILES |
no | 0 (unlimited) |
Maximum changed files (counted after generated/vendored/excluded files are dropped) before the action skips. 0 reviews any number of files — the only ceiling is your OpenRouter billing balance. Set a positive value to opt into a hard skip on huge PRs. |
MAX_DIFF_LINES |
no | 0 (unlimited) |
Maximum diff lines before truncation, applied before chunking. 0 reviews the whole diff. Set a positive value to keep the first N lines (lexicographic by file path) and append a truncation notice. |
MAX_CHUNK_LINES |
no | 1500 |
Per-chunk diff-line budget, applied to the diff after distillation has collapsed mechanical repeats/renames/formatting away. When the remaining diff exceeds this, it is split into packages of whole files (≤ this many primed lines each), each reviewed in its own model call and the results merged — so a large PR no longer overwhelms a single call and abstains. Module-coupled files (e.g. a Rust #[path]/mod parent and its child) always share a package, and a single file over the budget rides alone with its full post-change content attached as read-only context, so the model never judges a construct from a truncated view. A package whose call fails to produce valid structured output is split and retried in bisecting halves (up to 4 leaves) to isolate just the file(s) actually at fault, instead of writing off the whole package; a leaf that still fails is recorded unreviewed in the coverage ledger (never a confident approval over unreviewed files). 0 disables chunking (always one call). |
MAX_CHUNKS |
no | 0 |
Maximum chunks (= model calls) per review, bounding cost and wall-clock on very large PRs. 0 (default, changed from 20 prior to @v7 — see v7 migration) = unlimited. When set, files beyond the cap are recorded per-file as unreviewed in the coverage ledger, which also degrades a would-be approved verdict to error; the ### Other checks section of the summary comment also carries a short notice naming them, on top of (not instead of) the per-file ledger rows. |
MAX_WALL_MS |
no | 0 |
Soft wall-clock budget, in milliseconds, for the whole review loop. 0 (default) = off, never interrupts a run. When set and exceeded mid-run, packages not yet started are recorded pending in the coverage ledger instead of being attempted, reviewed_tree does not advance, and the round becomes resumable — via @toolu resume, a plain workflow re-run, or automatically on the PR's next push. Checked before every package and before every bisection split; a call already in flight always finishes. |
REQUEST_TIMEOUT_MS |
no | 180000 (3 min) |
Per-attempt model deadline in milliseconds. Each chunk gets up to this long per attempt (retried a few times) before it is aborted and the chunk abstains (This operation was aborted). Raise it for slow/large models, lower it to fail faster. A non-positive value falls back to the default. |
TOKEN |
no | ${{ github.token }} |
GitHub token for posting and editing comments. |
APP_ID |
no | — | GitHub App id. Set together with APP_PRIVATE_KEY to post as a custom-branded App (Toolu — Code Review) instead of github-actions[bot]. Both must be set or the action falls back to the default identity. See Custom identity. |
APP_PRIVATE_KEY |
no | — | GitHub App private key — raw PEM or base64-encoded PEM (auto-decoded). Pair with APP_ID. Pass via a secret; never inline. Used only to mint a short-lived installation token — never logged. |
TRIGGER_PHRASE |
no | @toolu |
Mention prefix for the bot's two comment commands: @toolu review [focus on …] re-triggers a review (requires the workflow to also listen on issue_comment — see @mention re-trigger), and @toolu dismiss in a reply on one of the bot's inline threads settles that finding (see Dismissing a finding). |
MIN_TRIGGER_PERMISSION |
no | write |
Minimum repo permission a commenter needs to trigger a review via @mention or dismiss a finding on a bot thread: write or admin. The check fails closed (denied on any error). |
BOT_NAME |
no | Toolu — Code Review |
Display name shown in the comment body header. |
BOT_LOGO_URL |
no | …/code-review/assets/logo.png |
Logo image shown in the comment body header. |
REVIEW_MEMORY |
no | true |
Recap what changed since the last review (resolved / still-open / new) and keep a collapsed history, using a hidden state marker in the sticky comment. Set false to disable. See Review memory. |
VERBOSITY |
no | compact |
Verdict-comment shape: compact (default) collapses the checklist to one line and renders recap buckets as path:line refs; full restores the multi-line checklist and inline recap text. Findings, the ### Findings heading, and the state marker are identical in both. An unrecognized value warns and falls back to compact. See Example verdict. |
RUN_SECRET_SCAN |
no | true |
Run the deterministic secret scan (gitleaks) before the LLM review; its findings feed the LLM as triage context and upload to Code Scanning. See Deterministic checks. |
RUN_SAST |
no | true |
Run the deterministic SAST pass (Opengrep) before the LLM review; same flow as above. |
SAST_RULES |
no | p/typescript |
Opengrep rule config(s) for the SAST pass (comma-separated). |
TOOLU_API_KEY |
no | `` | toolu.sh org API token (toolu_…) enabling review-run reporting to the platform. Empty (default) disables reporting. Only finding metadata is sent — never code, text, suggestions, or quoted lines. Requires INLINE_COMMENTS: true. See Platform reporting. |
TOOLU_API_URL |
no | https://api.toolu.sh |
Base URL of the toolu.sh API. Override only for a self-hosted platform. |
When TOOLU_API_KEY is set, the action reports each review run to the toolu.sh platform so findings and verdicts are aggregated for metrics and history — metrics that would otherwise be lost when the PR closes. Reporting is opt-in (empty by default).
Metadata only. Only finding metadata is sent: fingerprint, file path, line, severity, category, and provenance (the source tool: llm, gitleaks, opengrep, or eslint). The verdict and run timing are included. Never sent: the finding text, the quoted source line, code suggestions, or any code of any kind. The console links back to the GitHub comment for the full finding prose.
Requires inline comments. Reporting is skipped when INLINE_COMMENTS is false — the action only reports findings that appear in inline threads, and without inline comments, no threads are posted. In that case, every persisting finding would look new on every push, making reconciliation impossible. A warning is logged when reporting is skipped.
Best-effort. Reporting never fails the job. A non-2xx response, timeout, or network error produces a warning and nothing else. One attempt, no retry.
Fork PRs. Runs from fork PRs are never reported — GitHub withholds secrets from fork runs, so TOOLU_API_KEY is empty there.
These inputs were removed in v4 (breaking change) — they no longer exist on the action. See Removed in v4 (migration) for the full mapping.
| Removed input | Replacement |
|---|---|
OPENROUTER_API_KEY, DEEPSEEK_API_KEY |
API_KEY |
MODEL |
MODEL_ID |
PROVIDERS |
PROVIDER + MODEL_ID + API_KEY (single model only) |
MERGE_STRATEGY, FALLBACK_MODEL, REVIEW_MODE, ENFORCE_JSON_SCHEMA |
removed — one model, schema always enforced |
| Output | Description |
|---|---|
verdict |
approved, changes, error, or skip |
findings-count |
Number of findings reported |
comment-url |
URL of the posted verdict comment |
Use outputs in downstream workflow steps. Note that once the action fails the job (see Blocking merges), later steps that read these outputs need if: always() to run at all:
- uses: falconiere/toolu-ghactions/code-review@v4
id: review
with:
API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- if: always() && steps.review.outputs.verdict == 'changes'
run: echo "PR needs work — ${{ steps.review.outputs.findings-count }} findings"By default (FAIL_ON: changes) the action fails its own job when the bot's verdict is changes — the check turns red, the verdict comment and label are still posted. To make that red check actually block a merge, mark this action's check as a required status check in the repository's branch-protection rules (Settings → Branches). Without that, the red check is visible but advisory.
FAIL_ON: changes(default) — block when the bot requests changes.FAIL_ON: changes,error— also block when the review could not run (provider error/timeout). Safer, but a transient failure reds the check until re-run.FAIL_ON: none— never fail on a verdict; the review stays purely advisory (the pre-4.x behavior). You can still gate yourself withif: steps.review.outputs.verdict == 'changes'.
The gate governs the verdict only; a thrown infra error fails the job regardless of FAIL_ON. A skip (non-trigger event) never blocks.
@v7 ships the size-proof review pipeline described throughout this README — deterministic diff distillation (renames/formatting/mechanical repeats collapsed before any model call) → a small intent-brief call → bounded per-package reviewers with schema bisection → a deterministic reducer (carry-forward, clustering, publish hardening). No input is removed, but defaults and comment shapes change, so it ships as a new major tag rather than a silent behavior flip:
| What changed | Detail |
|---|---|
MAX_CHUNKS default |
20 → 0 (unlimited). Files beyond an explicitly-set cap are now recorded per-file unreviewed in the coverage ledger — the summary's ### Other checks notice is still posted alongside those per-file rows, not instead of them. |
| Comment shape | The sticky comment gains four new sections — ### Coverage, ### Repeated findings, ### Unanchored findings, ### Findings GitHub rejected inline — see Coverage ledger, Finding clustering, and Inline comments & suggestions. |
| Prompt bytes | The user prompt's block order changed (the shared prefix — system, codebase overview, project rules, brief, prior threads — now extends further before the per-package blocks, for prompt-cache efficiency), so prompt byte counts differ from @v6 even on an identical diff. The review's substance is unaffected. |
| Inline anchoring | The subject_type: "file" fallback is gone — a finding on a file GitHub's own diff can't anchor a comment to is never posted as a file-level comment. It appears in the sticky comment's ### Unanchored findings section instead. See Inline comments & suggestions. |
| New input | MAX_WALL_MS — soft wall-clock budget, resumable via @toolu resume. See Inputs. |
No workflow YAML changes are required to adopt @v7 — bump the pinned ref (falconiere/toolu-ghactions/code-review@v7) and the new behavior applies on the next run. If any downstream tooling parses the sticky comment's markdown directly (rather than the machine-readable verdict label pr-babysit uses), re-check it against the new section shapes above; the verdict label, checklist line, and ### Findings block are unchanged.
The action is a composite whose two node steps are nested node24 actions, each with its bundle colocated and committed:
run/— the main LLM review (run/index.cjs)sanitize-sarif/— the SARIF region sanitizer (sanitize-sarif/index.cjs)
The composite invokes them with the $/ self-repository syntax (GA 2026-07-30), which resolves against this action's repository at your pinned ref — so the nested steps always match the version you pinned.
Self-hosted runners: because the node steps are node-type actions, the Actions runner supplies its own bundled Node — no node on the runner's PATH is required. The $/ syntax requires Actions runner ≥ 2.336.0 (GitHub-hosted and auto-updating self-hosted runners are already there; a version-pinned older runner must update).
It was rewritten from the previous Dockerized bash action; there is no Docker image anymore.
- Breaking packaging change, no contract change. Every
action.ymlinput and output name and default is preserved, so an existing@v2workflow keeps working untouched — only the way the action runs changed. - Fixes land on merge. Because consumers run the checked-out ref directly (no image to rebuild and re-push to a registry), a fix reaches the action the moment it merges — no release required.
- Single model, two backends. The 6-vendor parallel ensemble was dropped in
favor of one model — via OpenRouter or the native DeepSeek API, selected with
PROVIDER— through the Vercel AI SDK. The old ensemble inputs (PROVIDERS,MERGE_STRATEGY,FALLBACK_MODEL,REVIEW_MODE,ENFORCE_JSON_SCHEMA) and the split key/model inputs (OPENROUTER_API_KEY,DEEPSEEK_API_KEY,MODEL) were removed in v4.
Runs automatically inside the composite — nothing to wire. gitleaks emits region values of 0 for findings it cannot anchor to a line (path-based rules like private-key files), and GitHub Code Scanning rejects the entire SARIF file for it (startLine must be greater than or equal to 1).
Between the scanner steps and the upload, the sanitizer copies each *.sarif into an upload-only directory, fixing regions only in the copies: startLine < 1 is clamped to 1; sub-1 startColumn/endLine/endColumn are deleted (their SARIF defaults are sane, and a clamped empty region would trade one rejection for another). The upload step reads only the sanitized copies.
The originals in $RUNNER_TEMP are untouched on purpose: the LLM triage reads those and deliberately drops unanchorable line-0 results instead of rewriting them — a finding that cannot be cited in the diff should not reach the review, but a real secret in a key file should still reach the Code Scanning tab (anchored at line 1).
Best-effort like the scanner steps: an unparseable file is skipped with a workflow warning, and the step never fails the review.
Large PRs can exceed the per-request MAX_TOKENS budget. When this happens:
- Streaming salvage — findings completed before the truncation cut are kept (partial review), instead of the whole chunk being lost.
- Budget escalation — a truncated response is retried with double the budget (
8192 → 16384 → 32768 → 65536 → 131072), up to the 131072-token ceiling. - Honest partial banner — when the budget still runs out, the comment names the lever that can actually help: raise
MAX_TOKENSwhile below the 131072 ceiling, lowerMAX_CHUNK_LINESat it.
A truncation salvage never approves: a chunk cut mid-review always carries a changes verdict (the model never finished deciding), and unreviewed/pending files degrade a would-be approved to error via the coverage ledger — incomplete coverage never auto-merges.
TypeScript bundled to run/index.cjs and sanitize-sarif/index.cjs; the dev loop runs on bun. See CONTRIBUTING for the full guide.
cd code-review
bun install # deps + git hooks (lefthook)
bun run check # typecheck + lint (oxlint, type-aware) + fmt:check (oxfmt) + test (vitest)
bun run build # esbuild → run/index.cjs and sanitize-sarif/index.cjs (commit both; CI fails if they drift from src)code-review/evals/ is a live eval harness, not a unit test: it fetches a real
PR's changed files via the gh CLI, replays them into a scratch git repo, and runs
the SAME runReview() entry point this action calls — distill → cartographer →
chunked package reviewers (bisection included) → clustering → render — with a REAL
model call, but with GitHub posting swapped for a recording fake so nothing is
actually posted. It then prints a scorecard: changed files, per-stratum counts,
pattern groups (count + biggest), packages, model calls (cartographer vs.
package-layer, which folds in any bisection retries), findings raw vs. clustered,
coverage-status counts, marker bytes vs. the 65 000-char comment-size ceiling, and
wall time.
It is excluded from bun run check — evals/ sits outside tsconfig.json's
include and outside oxlint/oxfmt's walk (see package.json's lint/fmt
scripts), and vitest's own include glob never reaches it either. Its own tests
live in evals/__tests__/, run directly with bun test evals/__tests__.
cd code-review
bun run eval -- --help # usage; no key, gh, or network needed
API_KEY=sk-or-... bun run eval -- \ # a live run (needs `gh`, authenticated)
--pr Falconiere/comemory#72 \
--provider openrouter --model deepseek/deepseek-v4-pro \
--out scorecard.jsonFlags: --pr <owner/repo#number> (default Falconiere/comemory#72), --provider/
--model (defaulting to this action's own defaults), --max-wall-ms (forwarded as
MAX_WALL_MS), --out <file> (also write the scorecard as JSON).
MIT — see LICENSE.