configuration

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.

.reviewgate/config.yml
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
it works without a configIf the file is missing or the YAML is broken, the bot does not fall over — it falls back to sensible defaults and says so with a line in the summary. The config only refines the behaviour.

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.

set the 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

fieldwhat 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.

a provider key does not work in this filellm.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.

.reviewgate/config.yml — the first line
# yaml-language-server: $schema=https://reviewgate.dev/config.schema.json
version: 1
# … the rest of the config

The 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).

.reviewgate/config.yml — the full reference
# 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 prompt

Writing a config from scratch

An order that produces a working config without guesswork:

  1. 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 in review_prompt.
  2. Rules come from what already exists. Walk through your linters (.eslintrc, phpcs.xml…) and your team canon (ADRs, CONTRIBUTING, the wiki). Put into rules[]only what the linter does NOT catch — the team's agreements — and cite the source inside the rule text. Duplicating a linter is noise.
  3. Branches. Set integration_branches (see the section below), otherwise the bot reviews every request in a row, internal merges included.
  4. Tone for your domain. If you need an emphasis, add review_prompt (extend on top of the preset, or replace entirely).
  5. 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.
  6. Maximum quality (an ensemble, optional). For «perfect code by your rules», use several independent generators: llm.generators lists 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 by llm.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 with backend: name from the deployment catalog (DeepSeek as the second generator, say) — see models → multi-vendor.
  7. 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), use llm.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.
  8. 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.judges checks the fix itself and its line range) and try it on trusted repositories first.
  9. 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).
  10. Break it in without blocking. Leave severity_gate: off at 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:

any file — the marker works anywhere in it
// reviewgate-ignore-file: generated test cases, a review adds no signal

The 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.

documentation is not scannedThe marker is not looked for in .md, .rst, .txt and similar files. There a hash is a heading, a star is a bullet, and an example of the marker inside a code fence is a legitimate illustration — a page explaining the marker to your team would otherwise exclude itself. The price is accepted knowingly: documentation cannot be taken off review this way, only with ignore.

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.

.reviewgate/config.yml
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 layerwho 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:

.reviewgate/config.yml — an entirely custom 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 needed
the format is not your concernThe structure of the answer (severity, line number, file) and the rule «do not invent problems, rely only on the diff you were shown» are appended by the bot on top of your text. There is no need to write about the format or the discipline in your own prompt — only about what to check.

Migrating 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: Xllm.generators: [{model: X}]
llm.efforteffort on the generator role
llm.validate_model: Xllm.judges: [{model: X}]
llm.validate_efforteffort 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:

.reviewgate/config.yml — a rule with a variable
rules:
  - id: mr-description-required
    description: "If the request has no description or no linked task — mention {mr:author} and ask for one"
    severity: minor

The 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.

variablevalue
{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)
the bot always sees the metadataThe author, the title, the description and the link are added to the context of every review even without placeholders — rules such as «the request has no description, ask for one» work even when no variable appears in the text. A placeholder is for when you want to control exactly where the value lands inside the comment.

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.

turn it on deliberatelyBlocking a merge is a strong instrument. We recommend running the review on a few requests without the gate first, and turning the threshold on once the team trusts the findings.

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:

the ruleA request is reviewed when its source branch is NOT in the list and its target IS (a working branch → an integration branch). If both branches are in the list (delivery or a back-merge), the review is skipped.
.reviewgate/config.yml
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.
the feature/ trapPut only real integration branches on the list. If 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.

how to clear a findingResolve the thread (or fix the code). The bot goes by the resolved status, which is why iterative edits never turn the review into a repeating wall of comments.

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:

the lines in the summary
⚙️ 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 and number formatThe examples on this page are shown for the default 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.

.reviewgate/config.yml
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:

  1. Take the current price list of your provider.
  2. 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.
  3. 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.
cost.models — examples for different providers
# 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.
the price keyThe model name in 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.
the prompt cache in the billWith Anthropic, cache writes and reads are billed separately and come on top of the ordinary input, so the bot counts them in both tokens and money: a write is 1.25× the input price at 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.
an honest billIf a price is missing for some of the models used, the bot shows the tokens and, instead of a sum, a note naming the model without a price: an understated figure is worse than none. If the provider did not report the usage of some calls, the sums are marked with «≥»; if it reported none at all, there is no line. Anthropic, Yandex AI Studio, Ollama and DeepSeek report usage in the response; some strict OpenAI-compatible servers do not report it while streaming.

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:

the block in the summary (folded by default)
🔬 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.

there is no code in the blockDiagnostics are metadata only: the models, the numbers, the judge's reasons. Neither the diff nor file contents reach the block. It can also be enabled for the whole deployment with the 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.

.reviewgate/config.yml
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.

the Comments trigger is requiredA comment event only reaches the bot when the project webhook has the Comments trigger (note events) enabled — see connecting GitLab. On webhooks created earlier, turn the flag on in Settings → Webhooks → Edit; diagnose.sh --project checks this for you.
a predictable priceThe model that answers is the one that reviews (the first generator) — with no second pass and no ensemble; it is overridden by 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.

presetwhat 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)
any language, your own promptThe core is language-agnostic. For a stack with no preset, describe the review in review_prompt or through rules: a preset is not required.