Review configuration
Your team standards live in a .reviewgate/config.yml file inside your repository, the way a linter's do. They are versioned with the code and changed through pull requests. That is the key difference from generic review: you describe your conventions in plain English and the model applies them to the code.
version: 1
language: en # language of the findings and the summary frame (en · ru · any other)
preset: angular # the stack preset (see the table) · none
angular:
signals_naming: "camelCase, no $ prefix"
prefer: combineLatest # instead of forkJoin where applicable
zone_less: false
a11y: true # accessibility block: ARIA, keyboard, focus (off by default)
review_prompt: # your review guideline on top of the default (optional)
mode: extend # extend — add to the default · replace — replace it entirely
text: |
Pay particular attention to the resilience of HTTP calls and to leaked RxJS subscriptions.
Keep the tone short and to the point.
severity_gate: off # off (the default) | blocker | critical | major | minor | info
tests: optional # required | optional — optional does not flag missing tests
incremental_review: true # a re-push reviews only the changed files (true by default)
review_drafts: false # do not review drafts; the run happens when it is marked ready
committable_suggestions: false # ready fixes (multi-line included) as a native suggestion (1 click); better with a judge
min_severity: info # threshold for inline comments; below it they are folded into the summary (info = post everything)
rules:
- id: commit-prefix
description: "The pull request title and the commits start with TASK-<number>"
severity: critical
- id: no-any
description: "The any type is forbidden, except in *.spec.ts files"
severity: major
- id: error-handling
description: "Every HTTP call has a catchError with a typed error"
severity: major
dont_flag: # explicit team assumptions — they silence whole classes of false positives
- "Internal links do not get rel=noopener noreferrer — our convention, for analytics"
- "The empty value of our UI kit inputs is '' by convention, not null"
ignore:
- "**/*.generated.ts"
- "**/migrations/**"
integration_branches: # review only working-branch → integration pull requests (see below)
- dev
- master
- "release/**"
# Vendor keys and addresses live in the deployment environment (the LLM_BACKEND_* catalog),
# not here. config.yml holds the role layout; set it explicitly.
llm:
generators: # the reviewing models; the FIRST one is the main one
- model: claude-sonnet-4-6
effort: high # reasoning depth of the role (optional)
judges: # the judge: a second generate→verify pass (fewer false positives)
- model: claude-opus-4-8
max_context_files: 10
full_file_context: true # recall: whole files plus neighbours for the generator, not only the diff
environment_context: true # recall: stack versions (Node/TS/Angular) from the manifests into the prompt The file name is .reviewgate/config.yml — and only it: the .yaml spelling is not read. If a config.yaml appears in the repository, the bot reports it in the summary and asks to rename it, so a policy written into the wrong file does not sit silent.
llm block explicitlyVendor keys and addresses live in the deployment environment (the LLM_BACKEND_* catalog and the flat LLM_* variables) — they must not appear in config.yml. The role layout, on the other hand, is worth setting explicitly: generators (the reviewing models; the first is the main one) and judges (the judge that confirms findings against whole files). Without them the review runs on the deployment's default model in a single pass — with no generate→verify to cut off false positives.Fields
| field | what it sets |
|---|---|
| version | the config schema version (currently 1) |
| language | the language of the bot output: the review texts (findings, the closing note), the summary frame — the gate verdict, the counters, the hints — and the notices about a degraded run (judging that did not start, a panel split resolved without an arbiter, an answer cut off by the output ceiling). Any string (en · ru · de, say): the bot writes findings in that language, while the frame exists in English and Russian, and for any other language the frame is English. en by default |
| preset | a ready template for your stack (optional). JS/TS:angular · react · vue · svelte · nextjs · nestjs · express · typescript. Python:django · fastapi · flask · python. Go:gin · echo · fiber · go. Java:spring · java. C#/.NET:aspnet · csharp. PHP:laravel · symfony · yii2 · php. Kotlin:android · ktor · kotlin. Ruby:rails · ruby. Rust:axum · actix · rust. Swift:ios · swift. Dart:flutter · dart. Plus none |
| review_prompt | your review guideline: mode (extend — on top of the default · replace — instead of it) plus text |
| severity_gate | the merge blocking threshold: off (the default — nothing is blocked) · blocker · critical · major · minor · info. Values from the old scale are accepted indefinitely: for THRESHOLDS warning = major and comment = info (the lowest level — «block everything»); for rules[].severity, comment = minor. Configs written before 2.0 work unchanged |
| tests | the test policy: required (the default) · optional — do not flag missing tests |
| rules[] | your team rules in plain English: id, description, severity |
| dont_flag[] | explicit team assumptions («this is not a problem») — they silence whole classes of false positives; see the section below |
| ignore[] | glob masks of files not to review. The bot explicitly tells the model that hidden files are «present in the request but outside the review», so that false «the file is missing» findings never appear. Do not hide under ignore anything a rule talks about — the migrations directory, say, when a rule demands that a migration be in the request |
| integration_branches[] | review only «working branch → integration branch» requests (see the section below); empty means every request |
| incremental_review | a re-push reviews only the changed files (true by default; false always reviews the whole request) |
| review_drafts | whether to review drafts (false by default — drafts are skipped and the review happens when the request is marked ready) |
| committable_suggestions | unambiguous mechanical fixes (multi-line ones included, up to 20 lines) as a native suggestion block (the «Apply suggestion» button). false by default; it makes sense together with a judge (llm.judges), which checks the fix itself |
| min_severity | the threshold for inline comments: info (the default — post everything) | minor | major | critical | blocker; the old threshold values: warning = major, comment = info (post everything). Findings below the threshold are not posted on code lines — they appear as a folded list in the summary (the list survives incremental runs, and comments you have closed are not raised again). It affects neither severity_gate nor the summary counters |
| cost | the cost of the review in the summary: show (false by default) turns on the spend lines — the current run, the thread replies and the total for the request; models holds prices per 1M tokens (input/output) keyed by model name, and currency is the currency label. Money is only calculated when a price is set for every model used; prices are in your own currency, and the bot never fetches exchange rates |
| diagnostics | the 🔬 block: model calls with timings, the context, and the judge decisions with reasons — including the findings it dropped (see the section below). The bot folds it inside the summary; the CLI prints it at the end of the report (and in JSON as the diagnostics field). There is no code in the block. false by default; if unset, the deployment's DIAGNOSTICS env default applies |
| questions | the ❓ question genre (see the section below): the candidates the judge rejected get a second look under a different bar — «is the doubt apt», not «is the defect proven» — and apt ones are posted as ❓ threads outside the severity gate. false by default; requires configured judging (llm.judges or the deployment's judge defaults) |
| reply | reply mode — the bot answers replies in the threads of its own review and @mentions (see the section below): enabled (false by default; if unset, the REPLY_ENABLED env default), model / effort / backend (the generator model by default), max_replies_per_thread (the cap on replies per thread, 3 by default). The webhook needs the Comments trigger |
| llm | roles that all share the shape {model, backend, effort}: generators[] (the reviewing models, the first is the main one), judges[] (the panel: 0 — no validation, 1 — generate→verify, 2 — agreement decides and a split goes to the arbiter), arbiter (the chairman of panel splits); plus max_context_files, full_file_context (whole files and neighbours for the generator, recall) and environment_context (stack versions from the manifests). Keys and addresses live in the deployment environment (the LLM_BACKEND_* catalog), and a role references an entry by name: backend: deepseek; backend: default is the main provider |
Mistakes in the config never pass silently
The parser is defensive: a value it does not understand never fails the review, it falls back to the default. But it announces the fallback — as a line in the summary and in the run.config.notices field of the CLI report. Named there are: a typo in a key (sevirity_gate — the gate would have stayed off while the team believed blocking was configured), a value in the wrong shape (rules as a mapping instead of a list, ignore as a string, review_prompt as a string instead of an object), and unrecognised preset, severity_gate, tests, min_severity, rules[].severity, or the effort and backend of a role. The last two matter most: a rejected backend sends the request to the default provider — one your team did not choose — and a rule whose severity was not understood falls back to major, so a rule meant to block stops holding the gate.
The check goes deeper than the top level. An extra key inside a known block is announced too: a rule with globs or prompt (the rule schema is closed — id, description, severity; scoping and conditions belong in review_prompt), a provider inside a role, a typo inside reply or cost. So is a boolean that is not one (questions: enable), a review_prompt block without text, and a stray config.yaml — the bot reads config.yml only and says so.
The same goes for the file as a whole. If it is there but the bot could not read it — the token lacks permission on the repository, the network to the repository failed — the review still runs, but on defaults: no team rules, no ignore, no gate. That case now carries its own line in the summary, and it names the cure precisely, because it is the opposite of a broken file: check the bot token's permissions, not the YAML.
llm.api_key, base_url and proxy are not applied here: the connection and the secrets are configured by the deployment environment — otherwise a clone of somebody else's repository would steer your code to somebody else's address. If a key does end up in the file, we say so plainly: the file lives in git, so the key is already in the history and must be revoked.Validating before the commit — JSON Schema
Everything above happens at review time. The same mistakes can be caught before the commit: the config schema is published as JSON Schema at reviewgate.dev/config.schema.json. Point your editor at it with the first-line comment below — the YAML extension of VS Code (yaml-language-server) then validates and autocompletes as you type. The same file works in CI and in the hands of an AI agent writing the config for you: unknown keys, closed-schema rules, role shapes, caps — the schema mirrors the parser and is kept in lockstep by tests.
# yaml-language-server: $schema=https://reviewgate.dev/config.schema.json
version: 1
# … the rest of the configThe full example — every option
Every field at once, for reference. All of it is optional (the parser is defensive: anything unknown or invalid is replaced by a default, and the bot warns about the invalid parts in its logs), except the llm block, which is worth setting explicitly (see above).
# The full .reviewgate/config.yml schema — every field, for reference.
# The parser is defensive: anything unknown or invalid is replaced by a default (and the bot
# warns about the invalid parts). Most fields are optional; set the llm block explicitly.
version: 1 # schema version (currently 1)
language: en # language of the findings and the summary frame (en · ru · any other)
preset: angular # the stack preset (see the table) · none — no preset
angular: # options of the chosen preset (the key is the preset name)
signals_naming: "camelCase, no $ prefix"
prefer: combineLatest
zone_less: false
a11y: true # optional: accessibility checks (ARIA / keyboard / focus)
review_prompt: # your own review guideline (optional)
mode: extend # extend — on top of the default · replace — entirely instead of it
text: |
The guideline text in any language — what to check, the tone, the domain.
severity_gate: off # off | blocker | critical | major | minor | info — the merge threshold
tests: required # required | optional — optional does not flag missing tests
incremental_review: true # a re-push reviews only the changed files (saves tokens)
review_drafts: false # whether to review drafts (no by default)
committable_suggestions: false # fixes as a native suggestion block (1 click); better with a judge (llm.judges)
min_severity: info # blocker | critical | major | minor | info — threshold for inline comments
rules: # your team rules in plain English
- id: error-handling
description: "Every HTTP call handles its error (ADR-003)"
severity: major # blocker | critical | major | minor | info
- id: mr-description # request variables: {mr:author} {mr:title} {mr:description}
description: "If the request has no description — ask {mr:author} to add one" # {mr:source_branch}
severity: minor # {mr:target_branch}
# {mr:url} {mr:project}
dont_flag: # team assumptions — «not a problem, do not flag» (silences false positives)
- "Internal links without rel=noreferrer — our convention"
ignore: # glob masks of files outside the review
- "**/*.generated.ts"
integration_branches: # review only working → integration (empty — every request)
- dev
- master
diagnostics: false # the 🔬 block in the summary: calls, timings, judge decisions with reasons
# (no code in the block; unset — the DIAGNOSTICS env default)
reply: # reply mode: the bot answers replies in threads (see the section)
enabled: true # unset — the REPLY_ENABLED env default; the webhook needs the Comments trigger
model: claude-sonnet-4-6 # the model for replies (optional; the generator model by default)
effort: high # reasoning depth (optional)
backend: deepseek # a named backend from the deployment environment (optional, multi-vendor)
max_replies_per_thread: 3 # cap on the bot's replies per thread (anti-loop)
cost: # the cost of the review in the summary (off by default)
show: true # spend lines in the summary: this run, thread replies, the total
currency: "$" # the currency label ($ by default); prices are given in it — the bot fetches no rates
models: # prices per 1 MILLION tokens; the key is the model name EXACTLY as in llm/env
# prices are needed for EVERY model of the run: generators, judges and the arbiter.
# The numbers below are an illustration; take the current ones from YOUR provider's price list.
claude-sonnet-4-6: { input: 3, output: 15 }
claude-opus-4-8: { input: 5, output: 25 }
claude-fable-5: { input: 10, output: 50 }
deepseek-chat: { input: 0.3, output: 1.2 }
gpt-4o: { input: 2.5, output: 10 }
# Vendor keys and addresses live in the deployment environment (the LLM_BACKEND_* catalog),
# NOT in config.yml. Every role has the same shape {model, backend, effort}; backend is the name
# of a catalog entry (default = the deployment's main provider), and an unset effort falls back
# to the deployment default.
llm:
generators: # the reviewing models; the FIRST is the main one (replies, fallbacks)
- model: claude-sonnet-4-6
effort: high
- model: claude-opus-4-8 # another pair of eyes — recall goes up
- backend: deepseek # a role on a named backend from the environment (multi-vendor)
judges: # the panel: 0 — no validation; 1 — generate→verify;
- model: claude-opus-4-8 # 2 — agreement decides, a split goes to the arbiter (cap 2)
arbiter: # the chairman: resolves splits of a two-judge panel
model: claude-fable-5 # (never called without judges)
max_context_files: 10 # how many diff files to keep in context
full_file_context: true # recall: whole files plus imported neighbours for the generator
environment_context: true # recall: stack versions from the manifests into the promptWriting a config from scratch
An order that produces a working config without guesswork:
- Stack → preset. Is your stack in the preset table above? Take it as the base. Not on the list —
preset: none, and describe the review inreview_prompt. - Rules come from what already exists. Walk through your linters (
.eslintrc,phpcs.xml…) and your team canon (ADRs, CONTRIBUTING, the wiki). Put intorules[]only what the linter does NOT catch — the team's agreements — and cite the source inside the rule text. Duplicating a linter is noise. - Branches. Set
integration_branches(see the section below), otherwise the bot reviews every request in a row, internal merges included. - Tone for your domain. If you need an emphasis, add
review_prompt(extendon top of the preset, orreplaceentirely). - Precision against cost. If you need high precision, add a judge:
llm.judges: [{ model: … }](the second generate→verify pass, see models). For a stream of cheap reviews, one generator and no judges. - Maximum quality (an ensemble, optional). For «perfect code by your rules», use several independent generators:
llm.generatorslists the reviewing models (each searches on its own, and different models see different things, so recall goes up), while the judge weighs the merged findings, collapses duplicates and filters out the false ones (which holds precision). A second judge turns judging into a panel: agreement decides, and a split is resolved byllm.arbiter, the chairman. Every role is another paid pass; enable them when the bar is at its highest. A role can be moved to another vendor withbackend: namefrom the deployment catalog (DeepSeek as the second generator, say) — see models → multi-vendor. - Search depth (recall). To let the bot see not only the diff but the whole changed files and the neighbours they import, use
llm.full_file_context: true(it catches problems outside the hunk without inventing them). To have it judge against your stack versions (Node/TS/Angular from the manifests), usellm.environment_context: true(it stops confusing which APIs exist in your version). Both are opt-in and cost more tokens — turn them on when the quality bar is high, preferably together with a judge. - One-click fixes (optional). If you want the bot to offer unambiguous edits as a native suggestion block (the «Apply suggestion» button), set
committable_suggestions: true. A replacement may cover several adjacent diff lines (up to 20). It applies code straight into the branch, so keep it together with a judge (llm.judgeschecks the fix itself and its line range) and try it on trusted repositories first. - Less noise (optional). If small findings distract, use
min_severity: major: below the threshold the bot posts no inline comments and the findings stay as a folded list in the summary (nothing is lost). The threshold does not affect blocking (severity_gate). - Break it in without blocking. Leave
severity_gate: offat the start, look at the findings on a few requests, and only then turn the threshold on.
preset + extend, or none + replace?
- preset (plus an optional extend) — your stack is on the list and the rules are ordinary. A ready base with pinpoint emphasis. The fastest route.
- none + replace — your stack is not among the presets or you have a strong guideline of your own, or ADRs, that you want to control word for word. You write the whole prompt; the bot adds the output format itself.
Your team rules
rules[] is exactly what makes this different from a linter. Every rule has an id (a stable identifier), a description (the agreement in ordinary prose) and a severity. A rule with the same id can override the severity of a finding.
Findings that come from your rules are marked with a 📐 badge — both in the inline comment and in the summary (the «from team standards» line). That makes it visible when a comment rests on your config.yml rather than on the model's general reasoning; the judge of the second pass additionally checks such findings against the text of the rule itself and drops the ones that hold only by a false attribution. A rule with a typo in its fields (id/description) is not applied — the bot says so with a line in the summary and a WARN in the log.
Excluding a single file: a marker in the file
The ignore key is for a class of files — generated code, vendored code, migrations: the class is named by one glob and does not grow. A one-off file is a different story. Put it in the list, and the list only ever gets longer, silently breaks the moment the file is moved, and tempts you into a broad glob that swallows new files for years. For that case put a marker in the file itself:
// reviewgate-ignore-file: generated test cases, a review adds no signalThe marker must start a line and sit behind a comment. Recognised forms cover most languages: the slash family including the Rust doc form, hash, double dash, star, block and JSX comments, HTML, semicolon, percent, apostrophe, the OCaml and Haskell brackets, and the batch forms. It works anywhere in the file, not just at the top. The reason is mandatory: a marker without one is not applied, the file is reviewed as usual, and the summary says so — a year from now nobody would be able to tell why the muzzle was put on. If a line looks like the marker but its form is not recognised, the summary says that too, so a marker never fails in silence.
A file excluded this way does not reach the model: neither its diff, nor its contents, nor even a pull as a neighbouring import of someone else's finding — so its size costs you no tokens. The summary lists every file excluded this way together with its reason, and marks separately the ones whose marker was added in the same request — so that a change and a muzzle for it arriving together do not slip through unnoticed.
A false positive: how to silence the whole class
If the bot systematically flags something that is normal for your team — an internal convention, a deliberate technique — write the assumption into dont_flag as an ordinary sentence. Every stage sees it: the finding generator, the judge of the second pass and the thread replies, so the whole class is silenced rather than one comment at a time. Phrase it narrowly, about the specific technique: an assumption that is too broad («do not flag error handling») will silence real problems too.
dont_flag:
- "Internal links do not get rel=noopener noreferrer — our convention, for referrer analytics"
- "The empty value of our UI kit inputs is '' by convention, not null/undefined"
- "console.log is fine under scripts/ — those are CLI utilities" An exception written into the rule text gives the same effect («…; exception: allowed in tests») — dont_flag is for cases where no rule exists. Even without configuration the built-in discipline does not flag matters of taste outside your standards, micro-optimisations with no measurable effect, deliberate suppressions with an explanation (eslint-disable, a TODO with a ticket), or «while we are here» remarks outside the purpose of the change.
Your own review prompt
The bot reviews against a system prompt — and that prompt is under your control. Out of the box a sensible default applies (what to look for: bugs, resilience, security, performance, typing), and through review_prompt you write a guideline for your own project, domain and stack — in any language, not only the ones on the preset list.
| prompt layer | who owns it |
|---|---|
| the review guideline — what to look for, the tone, the domain | you, in review_prompt (or the sensible default) |
| the output format plus the discipline against false positives | the bot — a machine contract that cannot be overridden |
mode: extend adds your text to the default guideline (to sharpen a few emphases). mode: replace mixes in no default at all and the review runs entirely on your prompt:
review_prompt:
mode: replace # a guideline entirely your own — the default is not mixed in
text: |
You are reviewing an Angular project. What to look at:
— Resilience: HTTP calls without catchError; subscriptions never unsubscribed.
— Security: innerHTML without sanitising, secrets in the code.
— List states: loading / error / empty result.
— Typing: any where a concrete type can be inferred.
Keep the tone short and to the point.
preset: none # your own prompt is self-sufficient — no preset neededMigrating from the 1.x schema
In 2.0 the model roles are written uniformly — as lists of roles that all share the shape {model, backend, effort}. The old keys are removed: the bot does not apply them and answers each one with a visible notice in the summary carrying a ready replacement line (the review still runs, on the canonical settings and the deployment defaults).
| was (1.x) | is now (2.0) |
|---|---|
llm.model: X | llm.generators: [{model: X}] |
llm.effort | effort on the generator role |
llm.validate_model: X | llm.judges: [{model: X}] |
llm.validate_effort | effort on the judge role |
llm.extra_generators | roles in llm.generators (the main one first) |
llm.provider | the vendor is chosen by the role backend (backend: name) |
llm.arbiter with an ensemble (1.x: the judge of the merged findings) | llm.judges: [{model: X}] — in 2.0 the judge plays the «judge and collapse the union» role, while the 2.0 arbiter chairs panel splits |
the arbiter «grew» out of validate_model | no inheritance: the judges panel judges, and the arbiter chairs splits |
You do not have to move anything by hand: reviewgate migrate does it for you — a dry run by default, and --write applies it with a backup left next to the file. The command understands the repository config, the personal CLI config and the deployment .env files; it neither prints nor touches secrets, and it preserves YAML comments. In detail: reviewgate help migrate and the CLI reference.
The layout behaves like a ladder: one generator and no judges is a legitimate economy mode; several generators and no judges gives a union with mechanical deduplication and a notice carrying the recipe (nobody will collapse semantic duplicates); the arbiter is only called on a split of a two-judge panel — with no judges, or with a panel of one, it is never called, and the run says so with a notice. An explicit judges: [] is a deliberate «no validation», and it also silences the deployment default. The recipe for «the deployment's main provider only, no ensemble from the environment» is generators: {backend: default}.
Splits of a two-judge panel are resolved by the chairman; without one the outcome is conservative and depends on the kind of dispute: a dispute about whether a finding is true drops it (precision beats recall), while a dispute about whether it duplicates another finding keeps it (an extra duplicate is cheaper than a lost finding) — though the committable fix of a disputed finding is removed. The exception is when the judges point at each other (the first calls one finding the duplicate, the second calls the other): they agree that there is a duplication and argue only about which one to keep, so one survives. Every outcome is visible in the run diagnostics.
Request variables in prompts
In rules[].description and in review_prompt you can refer to the metadata of the particular request with placeholders of the form {mr:field}. The classic case is a rule about request hygiene:
rules:
- id: mr-description-required
description: "If the request has no description or no linked task — mention {mr:author} and ask for one"
severity: minorThe bot substitutes the real handle: a request with no description gets a comment along the lines of «@ivanov, please add a description: what changes and why». The author receives the platform's ordinary mention notification.
| variable | value |
|---|---|
{mr:author} | the handle of the request author (the bot mentions it with @) |
{mr:title} | the request title |
{mr:description} | the request description (an empty one is seen as «(empty)») |
{mr:source_branch} / {mr:target_branch} | the branches of the request |
{mr:url} | the link to the request |
{mr:project} | the project path (group/repository) |
Severity gate
By default severity_gate: off — the bot blocks nothing and only leaves comments. The review helps a change land rather than standing in its way.
If your team needs a hard threshold, set severity_gate: critical (or major). Then, on findings at that level or above, the bot publishes a failing status — a check run on GitHub, a commit status on GitLab — and with «pipeline must succeed» or a required check that blocks the merge.
The threshold is not counted from the last run alone. If a finding at the threshold level is still open in a thread and the latest run did not re-check its file (which happens with incremental review, when only another file changed), it keeps the status red, and the summary says so plainly. Otherwise an unrelated commit would clear the block while the finding stayed unfixed.
There are two ways to clear the block: fix it — the next run re-checks the file, finds nothing and the status turns green by itself; or resolve the thread — your signal that it is handled or beside the point. You do not need to resolve something you have already fixed.
Which requests the bot reviews
Without integration_branches the bot reviews every request. That is usually too much: internal merges (delivery dev → release → master, back-merges, syncs) need no review and create noise. A list of integration branches switches the filter on:
integration_branches:
- dev
- master
- "release/**"
# A review runs when the source branch is NOT in the list and the target IS (feature/X -> dev, say).
# A request between two branches from the list (dev -> master, a back-merge) is skipped as internal.feature/* is an ordinary working branch for a single task in your flow (merged into dev), do not include it: otherwise the bot takes a feature/… → dev request for an internal one and skips it silently. Add feature/* only if those are aggregate branches that others are merged into.Repeat reviews and resolving
On a new push the bot reviews again, but it does not bury the request in duplicates: findings that are already open are not repeated, and there is one summary, updated in place. If you press Resolve on one of the bot's threads, that is a signal of «handled / not relevant»: on later reviews the bot will not raise that finding again.
A repeat review is incremental: the bot checks only the files that changed since the previous review rather than the whole diff, which saves tokens. That is the default behaviour; to always review the whole request, set incremental_review: false.
Drafts are not reviewed by default — the bot runs once, when the request is marked ready. To review drafts as well, set review_drafts: true.
The cost of a review in the summary
With cost.show: true the bot appends the spend to the summary — how many tokens went out and, if prices are configured, what that cost on your key. Up to three lines: the current run, the bot's replies in threads and the total for the whole request:
⚙️ This review: 46.2K tokens in · 3.8K out · ≈ 0.21 $
💬 Thread replies (3 replies): ≥ 12.1K tokens in · 0.9K out · ≥ 0.05 $
🧾 Total for this pull request (2 runs + 3 replies): ≥ 1.2M tokens in · 12.4K out · ≥ 1.32 $language: en. With language: ru the bot writes the same output in Russian, and the decimal separator becomes a comma: ≈ 0,21 $ instead of ≈ 0.21 $. This applies to the whole output: the cost lines, the «🔬 Run diagnostics» block, the spending cap warnings and the reasons a judge gave for dropping a candidate.The summary is updated in place, so the run line always refers to the latest run, while the total accumulates over the request and survives both repeat reviews and failed runs. The brackets give the scope («2 runs + 3 replies»): accounting starts from the run in which the bot first wrote a total into this request — anything spent before that does not enter the sum.
cost:
show: true # false by default
currency: "$" # the currency label (prices are given in it)
models: # prices per 1 MILLION tokens; the key is the model name (llm block or env)
claude-sonnet-4-6: { input: 3, output: 15 }
claude-opus-4-8: { input: 5, output: 25 }
# prompt cache prices are optional: without them the vendor multipliers are used
# (writes 1.25x at a 5m TTL and 2x at an hour, reads 0.1x of input)
# claude-sonnet-4-6: { input: 3, output: 15, cache_read: 0.3 }Prices are given per 1M tokens in your own currency, from your provider's rates. The bot works offline and requests no exchange rates, so there is no conversion: it counts in whatever you wrote down. Every call of the run is counted — the generator, the extra generators of an ensemble, the judge and the arbiter, each at the price of its own model.
How to fill in the prices — three steps:
- Take the current price list of your provider.
- Bring the prices to «per 1M tokens»: Anthropic, OpenAI and DeepSeek publish them per 1M already — take them as is; Yandex AI Studio publishes per 1K tokens, so multiply by 1000.
- If the provider bills in a currency other than
currency, multiply by the rate you actually pay (a card, an intermediary, a contract) and write the result down. The bot converts nothing by itself.
# The numbers are an illustration; take the current prices from your provider's price list.
# Keep ALL prices in the config in one currency — the one given in currency.
# Anthropic and the OpenAI-compatible vendors publish prices in $ per 1 MILLION tokens — copy them as is:
claude-sonnet-4-6: { input: 3, output: 15 }
# DeepSeek publishes prices in ¥ (CNY) and in $. If you pay in yuan, set currency: "¥"
# and give the prices in ¥ per 1 MILLION tokens:
deepseek-chat: { input: 2, output: 8 }
# Yandex AI Studio bills in ₽ but publishes the price per 1 THOUSAND tokens: multiply by 1000
# (and set currency: "₽"). The model name is the full URI, quoted because of the «://»:
"gpt://<folder>/qwen3-235b-a22b-fp8/latest": { input: 40, output: 120 } # 0.04 / 0.12 ₽ per 1K × 1000
# If you pay in a different currency (a card, an intermediary) — convert at YOUR OWN rate.cost.models must match word for word what the roles use (llm.generators / judges / arbiter) or what is in the environment (LLM_MODEL). For Yandex AI Studio that is the full URI of the form gpt://<folder>/… — quote it in YAML. If it does not match, the bot honestly writes «cost not calculated — no price for: …» instead of a sum.LLM_CACHE_TTL=5m and 2× at an hour, a read is 0.1×. The multipliers are applied to your input automatically; you can override them with the cache_write_5m, cache_write_1h and cache_read fields — they are optional, and configs with only input/output keep working. The run line shows how much of the input came from the cache. With OpenAI-compatible providers the cached tokens are already inside prompt_tokens, so they are not counted twice. After a bot upgrade, the total on requests that are already open may combine runs counted before the cache with newer ones — such totals grow as new runs happen, and past runs are never recalculated retroactively.The total is also marked «≥» when part of the spend is knowably unaccountable: the run failed after the model had answered (the tokens were charged, the amount is unknown), or reply mode is on — when answering, the bot may choose to stay silent, and a silent call is paid for while leaving no trace in the request. The «≥» sign means «no less than this», and never «roughly this much».
Run diagnostics
With diagnostics: true the bot appends a folded 🔬 block to the summary — an explanation of its own decisions right where the developer lives, with no need for access to the bot's server, its logs or an administrator. reviewgate review prints the same block at the end of its report (and in JSON as the diagnostics field, which is also what an agent sees over MCP). Inside are the model calls with their usage and timings, the context that was assembled and, above all, the judge decisions with their reasons, including the findings it dropped — the thing nobody can see without this block:
🔬 Run diagnostics
**Model context**: diff: 14 files · full files: 14 (191K chars) · environment: ✓ · from team standards (12 rules): 2 of 5 findings
**Calls**:
- generator `claude-sonnet-4-6` — 41.3K→2.9K tokens · 38 s · findings: 5
- validator `claude-opus-4-8` — 96K→1.1K tokens · 64 s · dropped: 1 · downgraded: 1
**The judge dropped / downgraded / removed a fix (✂️)**:
- ❌ `cart.service.ts:42` `error-handling` — «catchError is already one line above — the finding is false»
- ⬇️ `api.ts:90` `sql-injection` critical→major — «the input is already parameterised, the risk is indirect» The questions the block answers by itself: «why did the bot not complain about X» — the judge dropped it, and the reason is in the block; «we wrote a rule and it says nothing» — the whole path of the run is visible; «why did the review take two minutes» — the timings per call. If config.yml is not found in the repository, or is invalid, the block honestly says «running on defaults» — the answer to the most common question during setup.
One thing does not wait for diagnostics: true: when the run itself came out shallower than ordered — the model's answer hit the output ceiling and was retried at a lower reasoning effort, the model turned out not to support reasoning at all, an extra generator of the ensemble failed or was never configured — the summary says so on its own, as a folded line with a counter. Two things are worth knowing there: the analysis is not what you asked for, and a truncated answer is paid for twice.
DIAGNOSTICS=true environment variable (handy during onboarding, while config.yml does not exist yet); diagnostics: false in a repository config switches it off for that repository.❓ Questions to the author
The judge demands a proven defect — that is what keeps the review precise. But some of what it rejects is a legitimate engineering doubt with no victim to show today: a clipped container that hides the symptom instead of the cause, a swallowed error on a live path. With questions: true such rejects get a second look by the review's judge (the first of the configured panel) under a different bar — «is the doubt apt», not «is the defect proven» — and the apt ones come back as questions, phrased as questions, each naming what would settle the doubt.
A question is not a finding: it is posted as its own ❓ line thread, never enters the severity gate, the min_severity threshold or the findings counters, and the summary counts it on a separate line. Replying in the thread works as usual (reply mode); once the author resolves it, re-runs do not ask again. On projects that require all threads to be resolved the platform itself will still ask to answer a ❓ thread before merge — the question does not touch the gate, but it does want an answer. In the CLI and MCP report questions arrive as the questions[] array — for an agent that is a direct instruction: verify the doubt against the code and either adjust the change or answer it. Duplicates are not re-asked, the lens is instructed not to question topics from dont_flag, and a failed lens call leaves the findings untouched — the run says so instead of staying silent. Off by default; requires configured judging (llm.judges or the deployment's judge defaults) — with no judge there are no rejects to requalify, and the summary says so.
Reply mode — answers in threads
With reply.enabled: true the bot takes part in the discussion: a developer writes a reply in the thread of a comment («why is this a leak?») and the bot answers in the same thread, leaning on the code and on the team standards from this very config. The answer arrives on its own, usually within tens of seconds — no CI job to trigger and no command from anyone.
reply:
enabled: true # off by default (or the REPLY_ENABLED env default)
max_replies_per_thread: 3 # past the cap the bot stays silent in that thread
# model / effort / backend are optional; by default the generator model answers (llm.model)When the bot answers:
- to replies from people in the threads of its own review — the inline comments and under the summary;
- to an explicit @mention of the bot account in any other thread of the request;
- in the threads of a draft — yes (a reply is an explicit question); in resolved threads — no.
The bot does not answer for the sake of answering: it recognises «fixed» and «ok, thanks» and stays quiet — no tokens are spent on politeness. If it accepts that a finding was false it says so plainly and suggests closing the thread — but a human always resolves the thread: the bot performs no action on code or statuses in response to a reply.
diagnose.sh --project checks this for you.reply.model (and by backend, to move it to another vendor). The max_replies_per_thread cap (3 by default) guards against two bots playing ping-pong and against an endless argument: past the cap the bot stays silent in that thread. It can also be enabled for the whole deployment with REPLY_ENABLED=true; reply.enabled: false in a repository config switches it off for that repository.Stack presets
Presets are ready guideline templates for common stacks: a convenient starting point, so you do not have to describe everything in review_prompt from scratch. They are optional; with review_prompt: replace and preset: none you are entirely on your own prompt.
For JS/TS the presets are layered: a shared language core (types, async/promises, error handling, leaks, security) plus the specifics of the framework on top. That is why react, vue, nestjs and the rest already include the TS core — there is no need to add it separately.
| preset | what it checks · options |
|---|---|
| angular | signals (computed/effect+untracked, resource()/httpResource(), signal queries, reads after await), RxJS (switchMap, toSignal), control flow (@if/@for+track, @defer, @let), DI (inject() outside an injection context, service scope), routing (guards/resolvers/route order), forms (ngModel+name, markAllAsTouched), styles (::ng-deep, encapsulation), OnPush/zoneless, NgOptimizedImage, strict forms and templates, no any, version discipline (Angular N+). Options: signals_naming, prefer, zone_less, a11y (accessibility checks: ARIA state, keyboard, focus — off by default; true enables them) |
| react | hooks (Rules of Hooks, deps/stale closures, infinite renders), races and effect cleanup (AbortController), derived state and key, memoisation, dangerouslySetInnerHTML. Option: style |
| nextjs | the App Router on top of React: "use client" boundaries, server secrets leaking into the bundle, validation and authorisation in server actions, cache/revalidate, next/image |
| vue | reactivity (lost on destructuring, toRefs, ref/reactive), watch cleanup and async races, computed versus a method, v-for keys, v-html |
| svelte | Svelte 5 runes ($state/$derived/$effect, effect cleanup), reactivity lost on destructuring, stores, keys in {#each}, {@html} |
| nestjs | DI/scope (request-scoped inside a singleton), DTOs plus ValidationPipe, exception filters instead of a bare 500, transactions and N+1, guards on mutating endpoints, ClassSerializer |
| express | an async route without a catch → an unhandled rejection, middleware order, input validation plus helmet/CORS/rate limiting, open redirects, streams. Option: framework: express | fastify |
| typescript | the bare JS/TS language core with no framework (types, async/promises, errors, leaks, security) — for Node and scripts |
| django | ORM N+1 (select_related/prefetch_related), queries without pagination, transaction.atomic, form validation, SECRET_KEY/DEBUG/mark_safe, view permissions, data migrations |
| fastapi | Pydantic contracts and response_model, blocking calls inside an async endpoint, Depends/yield for resources, authorisation in dependencies, secrets through Settings |
| flask | the app/request/g context, global state between requests, a blocking handler, input validation, CSRF, debug and secrets in production |
| python | the bare Python core with no framework (mutable defaults, asyncio traps, except and resources, eval/pickle/SQL injection, the GIL) |
| gin | binding plus validation (ShouldBindJSON), c.Abort after an error, c.Copy for goroutines, recovery middleware, authorisation |
| echo | c.Bind plus a validator, returning an error through HTTPErrorHandler, the context not being for goroutines, authorisation middleware |
| fiber | fasthttp: ctx/Locals are invalid after the handler returns (do not hold them, do not pass them to goroutines), BodyParser plus a validator, ErrorHandler |
| go | the bare Go core (goroutine leaks/deadlocks/races, context cancellation, error wrapping %w/errors.Is, defer and resources, typed nil, crypto/rand) |
| spring | Spring Boot/MVC/JPA: @Transactional through the proxy (self-invocation, rollbackFor), JPA N+1 and LazyInit, races in singleton beans, @Valid/DTOs, @PreAuthorize, secrets |
| java | the bare Java core (equals/hashCode, == versus equals and the Integer cache, Optional, try-with-resources, thread safety, SecureRandom) |
| aspnet | ASP.NET Core plus EF Core: captive dependencies (Scoped inside Singleton), DbContext not being thread-safe, EF N+1/AsNoTracking, DTOs and over-posting, [Authorize], secrets |
| csharp | the bare C#/.NET core (async void, .Result deadlocks, ConfigureAwait, IDisposable/HttpClient, nullable references, LINQ multiple enumeration, throw;) |
| php | the PHP 8.3 core: type juggling (==/=== and 0e hashes), unserialize and object injection, XSS/command injection/path traversal, random_bytes, strict_types/readonly, SQL injection, N+1, RabbitMQ/Redis. Option: framework |
| laravel | Eloquent N+1, $fillable and mass assignment, FormRequest validation, Policy/Gate, API Resources and DTOs, Blade {!! !!} XSS, jobs, config()/env |
| symfony | autowiring and private services, Doctrine N+1 and flush inside a loop, constraints plus DTOs, Voters/#[IsGranted]/CSRF, Twig |raw, Messenger |
| yii2 | ActiveRecord scenarios and safe attributes, RBAC, N+1 (with/joinWith), thin controllers, parameterisation and Html::encode |
| kotlin | the bare Kotlin core (coroutines: runBlocking/GlobalScope/Dispatchers/cooperative cancellation, null safety !!/lateinit, exhaustive when, scope functions, val, SecureRandom) |
| android | lifecycleScope/viewModelScope, Context and View leaks, Jetpack Compose (LaunchedEffect/remember/stability, LazyColumn keys), main thread and ANRs |
| ktor | structured coroutines and timeouts, ContentNegotiation validation, StatusPages, reusing HttpClient, Authentication |
| ruby | the bare Ruby core (nil &. and 0 being truthy, rescue StandardError versus Exception, string mutability, proc versus lambda, eval/send/Marshal/YAML.load, command injection) |
| rails | ActiveRecord N+1 (includes/preload), SQL injection (interpolation in where), strong params and mass assignment, Pundit/before_action, html_safe/raw XSS and CSRF, side effects in callbacks, ActiveJob |
| rust | the bare Rust core (panics from unwrap/expect, ?/thiserror, holding a lock across .await, redundant clones, unsafe, as casts and overflow) |
| axum | locks across await and spawn_blocking, validation in extractors, IntoResponse for errors, no unwrap in a handler, auth and timeouts (tower) |
| actix | web::block for blocking work, web::Data for state, validation in extractors, ResponseError, auth and payload limits |
| swift | the bare Swift core (force unwrap !/try!, retain cycles and [weak self], @MainActor/Sendable, struct versus class, try? swallowing errors, Keychain) |
| ios | SwiftUI @StateObject versus @ObservedObject, a pure body and .task, stable ids in ForEach, the main thread and @MainActor, retain cycles, Keychain |
| flutter | iOS and Android: disposing controllers and StreamSubscriptions, BuildContext across an async gap (mounted), setState after dispose, const widgets/ListView.builder/Key, state management (Provider/Riverpod/Bloc) |
| dart | the bare Dart core (null safety !/late, a Future without await, Stream cancel, StreamController close, const/pattern matching, Random.secure) |
review_prompt or through rules: a preset is not required.