aireview
aireview is a local CLI tool that reviews GitLab merge requests with the help
of LLMs. It uses a two-pass review pipeline: the first pass finds candidate
findings, the second one critiques them and drops the weak or invalid ones.
The tool supports self-hosted GitLab and self-hosted Jira only. GitLab.com and Jira Cloud are not supported.
MVP flow:
- Takes a GitLab merge request URL.
- Fetches the MR metadata and changes from GitLab.
- Filters out ignored paths and scrubs secrets from the diffs.
- Optionally enriches the prompt with context from a Jira issue.
- Runs the Generate pass through RubyLLM to get an MR summary and candidate findings.
- Optionally runs the Critique pass, which returns a verdict for every candidate id.
- Renders the final markdown review to stdout or posts it back to the merge request.
Requirements
- Ruby 3.1.3 or newer (CI runs the tests on 3.1, 3.3, 3.4 and 4.0)
- Bundler 2.3.26 for the repository checkout;
gem installneeds no specific Bundler - A GitLab personal access token
- An API key for a remote LLM provider; a local Ollama needs no key
- Optionally, a Jira login and password
Installation
From RubyGems:
gem install aireview
aireview --helpFrom a checkout of the repository:
bundle _2.3.26_ install
bundle _2.3.26_ exec bin/aireview --helpThe examples below use bundle _2.3.26_ exec bin/aireview; with the gem
installed, replace it with plain aireview.
Configuration
Secrets live in environment variables or in a local .env file. The models of
both stages come from the defaults shipped in the Docker image (see "Defaults
shipped in the image"); outside the image they are set in .env or
.aireview.yml:
GITLAB_URL=https://gitlab.company.com
GITLAB_TOKEN=glpat-xxx
JIRA_URL=https://jira.company.com
JIRA_LOGIN=user
JIRA_PASSWORD=xxx
GEMINI_API_KEY=xxx
GEMINI_API_KEYS=xxx,yyy
LLM_PROVIDER=gemini
LLM_TEMPERATURE=0
LLM_TIMEOUT=120
LLM_TIME_BUDGET=1800
LLM_HTTP_PROXY=http://127.0.0.1:8888
LLM_GENERATE_PROVIDER=gemini
LLM_GENERATE_MODEL=gemini-3.7-flash
LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash
LLM_GENERATE_TEMPERATURE=0.3
LLM_CRITIQUE_PROVIDER=gemini
LLM_CRITIQUE_MODEL=gemini-3.8-flash
LLM_CRITIQUE_FALLBACK_MODEL=gemini-3.7-flash
LLM_CRITIQUE_TEMPERATURE=0
REVIEW_LANGUAGE=ru
REVIEW_MODE=updateThe providers are gemini, ollama, openai, anthropic and
openrouter, and any OpenAI-compatible server of your own (vLLM, llama.cpp,
LM Studio) as openai with an api_base. The provider and the model of each
stage are set through LLM_GENERATE_PROVIDER, LLM_GENERATE_MODEL,
LLM_CRITIQUE_PROVIDER and LLM_CRITIQUE_MODEL. LLM_PROVIDER stays the
shared default when a stage has no provider of its own. Gemini and Ollama
are the ones the image defaults and the verified models are about; for the
others run aireview models check before relying on a model.
Each provider takes its keys from its own variables:
GEMINI_API_KEY(S), OPENAI_API_KEY(S), ANTHROPIC_API_KEY(S),
OPENROUTER_API_KEY(S); LLM_API_KEY is the shared fallback.
REVIEW_LANGUAGE (or review_language in .aireview.yml) sets the language
of the review: both the LLM answers and the headings of the rendered report.
en is the default; ru is supported as well.
If only the LLM traffic has to go through a proxy, set LLM_HTTP_PROXY or
llm.http_proxy. That configures RubyLLM only; requests to GitLab and Jira
keep going directly.
Defaults shipped in the image
Models, timeouts and the report language are baked into the image as
config/defaults.yml; inside the image AIREVIEW_DEFAULTS points at it. A
project in an organization has nothing to configure: no .aireview.yml, no
variables with model names. Changing the model for every project is one
edit of that file and a new image release. Outside the image the variable is
unset and the layer does not exist, so local runs are unaffected; point
AIREVIEW_DEFAULTS at any file to get the same layer elsewhere. The
defaults define a shared pool of five Gemini models (see "Shared model
pool"): Generate starts from a mid-range model and goes round the pool,
Critique takes the strongest live model not below the one Generate answered
with; every model of the pool is confirmed by aireview models check on
release.
The configuration layers, weakest first:
- built-in values (
Config::DEFAULTS); - image defaults — the file from
AIREVIEW_DEFAULTS; no variable, no layer; - the project's
.aireview.yml; - environment variables (
LLM_GENERATE_MODELand the rest); - CLI flags (
--generate-model,--critique-model, the temperatures).
Layers merge by key, so a project overrides only what it needs — with two caveats about the pool:
- a
llm.generate.modelof its own (in YAML orLLM_GENERATE_MODEL) takes the stage out of the pool: its chain is that model plus its ownfallbacks, if any; the image models are not picked up for that stage and the critique rank rule does not apply. To change only the starting model while staying in the pool, setllm.generate.start/LLM_GENERATE_START— or replace the whole pool throughllm.models/LLM_MODELS; - every pool model in the image carries an explicit provider (
gemini), soLLM_PROVIDER=ollamaon its own does not move the Gemini pool to Ollama: it only changes the default provider of models without one. A project on Ollama sets its own models — its own pool (LLM_MODELS=ollama/qwen2.5-coder:7b,…) or per-stagemodels.
The shared settings llm.temperature, llm.max_prompt_chars and the default
provider llm.provider (env: LLM_TEMPERATURE, LLM_MAX_PROMPT_CHARS,
LLM_PROVIDER) apply to the stages layer by layer: a stage value from the
image defaults does not beat a shared value from the project or the
environment, while within one layer the stage value still wins. For stages
on their own chains the fallbacks array is replaced as a whole when given
explicitly; fallbacks: [] removes the reserves; a reserve without a
provider of its own inherits the stage provider — if a project overrode the
stage provider (LLM_GENERATE_PROVIDER=ollama) while inherited reserves
without an explicit provider remain in the chain, they silently become
"models" of the new provider. --dry-run warns about that and prints, for
every model, the layer it came from:
=== LLM SETTINGS ===
Config: image defaults /app/config/defaults.yml, .aireview.yml /app/.aireview.yml
Generate: gemini-3.7-flash temperature=0.1 (model from image defaults, provider from image defaults)
fallbacks: gemini/gemini-3.6-flash -> ... (fallbacks from image defaults)
Critique: qwen2.5-coder:7b temperature=0 (model from .aireview.yml, provider from .aireview.yml)
Time budget: 1800s, overloaded quarantine: 120s
In pool mode the model source is the layer that set the stage start or the
llm.models list itself; --critique-model with a pool model shows up as
model from cli.
An AIREVIEW_DEFAULTS that points at a missing file is a configuration
error: such an image was built wrong, and failing at once beats reviewing
with the wrong models. Keys and tokens never live in config/defaults.yml.
Changing the default models or the pool order changes the review key (see "A
single comment per merge request"). What happens to open MRs depends on
review_mode: in update the review re-runs on the next push to the MR, in
once (as in the CI template) only on a job Retry. In update this is a
one-off burst of requests to the provider — better not to ship such a
release at peak hours.
No model is probed before a review: there are no test requests to five
models per MR, availability is learned from the working requests — the first
Generate request is the probe, and what the router learns it remembers until
the end of the run (see "Fallback models and keys"). The separate smoke test
of every model with both schemas (aireview models check) runs on an image
release and on a schedule, not on MRs.
A pool of models from several providers
The critic is best the strongest model you have, and it does not have to be Gemini: the shared pool (see "Shared model pool") takes models of any provider, ordered by strength as you see it — the order is the only measure, names and release dates are not compared.
LLM_MODELS=anthropic/claude-opus-4.5,openai/gpt-5,gemini/gemini-3.8-flash,ollama/qwen2.5-coder:14b
LLM_GENERATE_START=gemini-3.8-flash
ANTHROPIC_API_KEY=xxx
OPENAI_API_KEY=xxx
GEMINI_API_KEY=xxxIn a string the provider goes in front of the model; an OpenRouter model has
a slash of its own, so it is written with the prefix:
openrouter/qwen/qwen3-coder. Servers of your own, one or several, are
listed in .aireview.yml with their address:
llm:
models:
- provider: anthropic
model: claude-opus-4.5
- provider: openai # an OpenAI-compatible server of your own
model: qwen3-coder
api_base: http://gpu1:8000/v1
- provider: ollama
model: qwen2.5-coder:14b
api_base: http://gpu2:11434/v1
generate:
start: qwen2.5-coder:14bapi_base also works for a stage (llm.generate.api_base) and for a reserve
in fallbacks. A model with its own api_base is named with the address
(openai/qwen3-coder@http://gpu1:8000/v1), so two servers with the same model are
two models for quarantine and the review key, and a start may name either.
Such a server never gets a provider's key: it is called with LLM_API_KEY,
or without auth when that is not set, and needs no key to start. Without an
api_base a model goes to its provider's API, or to LLM_API_BASE for
Gemini, OpenAI and OpenRouter as before.
A stage's temperature goes only to the models that take one: OpenAI
reasoning models (o1, o3, gpt-5…) and search models
(gpt-4o-search-preview…) accept no other, so they get none and run with
their own default. The RubyLLM registry decides, and the name
for a model it does not know.
Instead of an LLM the critic can be Jev (see "Critique engine").
Local Ollama
Install Ollama following the
official guide, then pull a local model,
qwen2.5-coder:7b:
ollama pull qwen2.5-coder:7bIf the service did not start on its own, start it separately and leave it running during the review:
ollama serveFor a decent result use a stronger model for Critique than for Generate. For
example, to generate through a local Ollama and critique through Gemini,
configure .env like this:
LLM_GENERATE_PROVIDER=ollama
LLM_GENERATE_MODEL=qwen2.5-coder:7b
LLM_GENERATE_TEMPERATURE=0
LLM_CRITIQUE_PROVIDER=gemini
LLM_CRITIQUE_MODEL=gemini-3.8-flash
LLM_CRITIQUE_TEMPERATURE=0
OLLAMA_API_BASE=http://localhost:11434/v1
GEMINI_API_KEY=xxx
LLM_TIMEOUT=300Ollama models that have been verified:
qwen2.5-coder:7bqwen2.5-coder:14bqwen3:8bqwen3:14bgpt-oss:20b
The context window size is configured on the machine that runs Ollama. For a permanent setting on Linux, open the service configuration:
sudo systemctl edit ollama.serviceAdd the setting:
[Service]
Environment="OLLAMA_CONTEXT_LENGTH=8192"Then apply it and restart Ollama:
sudo systemctl daemon-reload
sudo systemctl restart ollamaWhen the server is started by hand, the context can only be set for the current process:
OLLAMA_CONTEXT_LENGTH=8192 ollama serveA local Ollama needs no API key. The providers can be swapped by changing
LLM_GENERATE_PROVIDER, LLM_CRITIQUE_PROVIDER and the corresponding models.
To run both stages locally, set ollama in both provider variables. The
address with /v1 matches the
Ollama configuration in RubyLLM.
LLM_TIMEOUT sets the timeout of every LLM request in seconds; for a slow
local model it can be raised, but not without limit: a hung request holds
the job for exactly that long while a fallback model sits idle. An
"overloaded" (503) answer from the provider or a timeout does not fail the
run right away: the model gets one short retry (~30 s), then goes into
quarantine for two minutes while the request goes to the next model;
without a fallback model the router waits for the quarantine to end and
tries once more. Three requests per model per stage in total (see "Fallback
models and keys"). Only the failed stage is repeated, not the whole run.
Project rules live in .aireview.yml. The generate.model and
critique.model settings are not inherited from the base llm settings: a
review does not start when a stage has no model in the image defaults, the
YAML or the environment. llm.provider is used as the default when
generate.provider or critique.provider is not set:
ignore_paths:
- db/migrate/**
- vendor/**
- node_modules/**
- "*.lock"
secret_patterns:
- 'api_key\s*=\s*["'\''].*["'\'']'
- 'SECRET_[A-Z_]+'
secret_files:
- .env
- .env.*
- config/secrets.yml
- config/credentials/*.key
- spec/fixtures/cassettes/*.yml
- spec/fixtures/cassettes/**/*.yml
- spec/cassettes/*.yml
- spec/cassettes/**/*.yml
- test/fixtures/cassettes/*.yml
- test/fixtures/cassettes/**/*.yml
review_instructions: |
This is a Rails project. Pay attention to:
- N+1 queries
- strong params
- missing tests for new logic
Ignore style — that is what the linters are for.
ollama_api_base: http://localhost:11434/v1
context:
max_diff_chars: 120000
max_mr_description_chars: 8000
max_jira_description_chars: 8000
max_jira_comment_chars: 2000
llm:
provider: gemini
temperature: 0
timeout: 120
time_budget: 1800
http_proxy: http://127.0.0.1:8888
max_prompt_chars: 400000
generate:
provider: gemini
model: gemini-3.7-flash
temperature: 0.3
fallbacks:
- gemini-3.8-flash
- provider: ollama
model: qwen2.5-coder:7b
max_prompt_chars: 20000
critique:
provider: ollama
model: qwen2.5-coder:7b
temperature: 0
max_prompt_chars: 60000Context budget
The request to each stage is capped by llm.max_prompt_chars (or
LLM_MAX_PROMPT_CHARS; per stage llm.generate.max_prompt_chars /
LLM_GENERATE_MAX_PROMPT_CHARS and the same for critique). The limits are in
characters, not tokens: there is no exact tokenizer for the providers locally,
and the Ollama window is set on the server where the client cannot see it. As
a rule of thumb one token is three to four characters, so for a local model
with OLLAMA_CONTEXT_LENGTH=8192 set the stage limit to about 20 000
characters to leave room for the answer.
The MR and Jira context is assembled once per run and shared by both stages,
so it is sized for the tighter of the two: the Critique stage also has to fit
its system prompt and a reserve for the candidates. The sections are cut to
their own limits first, keeping the beginning: context.max_diff_chars,
context.max_mr_description_chars, context.max_jira_description_chars and
context.max_jira_comment_chars (MAX_DIFF_CHARS, MAX_MR_DESCRIPTION_CHARS,
MAX_JIRA_DESCRIPTION_CHARS, MAX_JIRA_COMMENT_CHARS). Only then is the diff
cut, and only by whole files and whole hunks: files in the order GitLab returns
them, a file that does not fit is shown hunk by hunk, everything after it is
left out, and a single hunk larger than the whole budget is skipped rather
than cut in the middle. Renames, mode changes and other files without text
changes are always listed; files whose diff GitLab did not return (too large,
binary) are listed as well and reported as not reviewed.
Everything that was cut is marked in the prompt, so the model knows that a
missing requirement or missing code may simply be outside the budget. The
review reports it too: the result line gets a Partial review: ... suffix
and a Not reviewed section lists the files and sections concerned. The
result itself (ok / needs attention) is still only about the findings.
When even one hunk cannot fit next to the system prompt, or the candidates
returned by Generate push the Critique request over its limit, the run stops
with an error instead of silently reviewing less. Raise the limits or extend
ignore_paths. --dry-run prints the sizes of every part and the coverage;
--verbose logs them during a real run.
Fallback models and keys
The primary model can sit under load (503) for half a day, and a key can run out of its daily quota. Neither is cured by waiting, so a stage can have a fallback model and a provider can have a fallback key:
-
llm.generate.fallbacks/llm.critique.fallbacksin YAML orLLM_GENERATE_FALLBACK_MODEL/LLM_CRITIQUE_FALLBACK_MODELin the environment. One model or several separated by commas, in the order they are tried. The provider goes before a slash (ollama/qwen2.5-coder:7b); without it the stage provider is used. A fallback model can have its ownmax_prompt_chars: when the assembled request does not fit, that model is skipped, the context is not cut for it. -
GEMINI_API_KEYS=key1,key2: keys in order of preference;GEMINI_API_KEYstill works and means a single key. Google counts quotas per Google Cloud project and per model, so a reserve key only makes sense from another project. Use fallback credentials in compliance with the provider's quota and billing terms: Google's API limitations forbid circumventing its limits regardless of the project's billing. Keys live only in the environment, never in.aireview.yml.
Shared model pool
Instead of two independent chains a single pool can be configured:
llm.models in order of priority (the first is the preferred one for
Critique). Generate goes through the pool from generate.start downwards
and round again; Critique takes the first live model not below the one
that actually answered in Generate (critique.rank: not_below_generate,
the default), with self-critique by the same model as the last permitted
option. "Above" and "below" are positions in the list, they are not derived
from model names:
llm:
provider: gemini
models: # order = priority for Critique
- gemini-3.8-flash
- gemini-3.7-flash
- gemini-3.6-flash
- provider: ollama
model: qwen2.5-coder:7b
max_prompt_chars: 20000
generate:
start: gemini-3.7-flash # Generate starts here, then downwards and round
critique:
rank: not_below_generate # or any — the chains are independent
allow_weaker: false # true — go below when nothing above is aliveEnvironment equivalents: LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash,…,
LLM_GENERATE_START, LLM_CRITIQUE_START, LLM_CRITIQUE_RANK,
LLM_CRITIQUE_ALLOW_WEAKER. The rules:
- no permitted live model for Critique and
allow_weaker: false— the run fails, candidates are not published without a critique; withallow_weaker: trueCritique goes below Generate and the report gets a "Critique ran on a model weaker than Generate" line; - a stage with a
modelof its own (in YAML, the environment or--generate-modelwith a model outside the pool) does not use the pool: its chain is independent and the rank rule does not apply, as withrank: any;--generate-model/--critique-modelwith a model from the pool bring the stage back into the pool and make that model the start (the stage's ownmodelandfallbacksare reset), with a model outside the pool — a single chain without reserves; - a project that replaces the whole pool (
llm.modelsin YAML orLLM_MODELS) need not repeatstart: a start inherited from the image that the new pool does not contain is replaced by the first model of the new pool with a warning in the log. A start set in the same layer as the pool or above it must be in the pool — a typo there stays a configuration error; -
critique.startgoes first only when the rank permits it; a start below the model that answered in Generate is skipped with a warning whenallow_weakeris off — it does not bypass the ban on a weaker critique; - the review key (see "A single comment per merge request") includes the
whole pool together with the critique policy: its order decides which
model checks the findings, not just what to fall back to. Reordering the
pool, changing
start,rankorallow_weaker— a new key, open MRs are re-reviewed on the next push; moving from per-stage chains to the pool — the same, once; -
--dry-runprints the chains of both stages and aCritique rule: …line.
What happens on which error:
| Error | Reaction |
|---|---|
Daily quota (quotaId like …PerDay… in Google's answer) |
No retries: the next key on the same model. The "key + model" pair is remembered until the end of the run so that Critique and the JSON repair do not hit it again. Out of keys: the model is excluded until the end of the run, the request goes to the next one. |
| Per-minute limit (429 with a "retry in N s" hint) | One retry after the hinted delay, then the next key. Out of keys: the model is quarantined for the hinted time, the request goes to the next one. |
| Overloaded (503) or timeout | One short retry (~30 s), then a quarantine of llm.overloaded_quarantine / LLM_OVERLOADED_QUARANTINE (120 s by default) and the next model with the same key. |
| The provider has no such model (the answer text names the model: retired, a typo, not pulled into Ollama) | No retries: the model is excluded until the end of the run, the request goes to the next one. A bare 404 without such text is fatal: a wrong LLM_API_BASE answers the same, and the next model will not help. |
| An invalid result (not JSON, not the schema, foreign ids in the verdicts) — and still invalid after one repair by the same model | The model is excluded for this stage, the stage starts over on the next model with the original request. For Critique the candidates already obtained are kept, Generate is not repeated. |
| Other API errors (context length, auth) | The run fails right away: a fallback model would answer the same. |
The chain is walked round: once every model has been tried, the router returns to those whose quarantine has expired; when all are quarantined, it waits for the nearest release. The last model in the list is nothing special, nobody gets a long retry schedule. Two limits keep the walk finite:
-
three requests per model per stage (
MAX_ATTEMPTS_PER_MODEL). Every request sent counts regardless of its outcome, including the short retry and the JSON repair; the keys of one model share one counter, a quarantine does not reset it. Out of requests — the model leaves the stage; a JSON repair without requests left is not sent, and the result counts as invalid (see the table). When every model of the stage is out or excluded, the stage ends at once, quarantines are not waited for; -
the time budget of the LLM part of the run:
llm.time_budget/LLM_TIME_BUDGET, 1800 seconds by default. A pause or a quarantine wait that does not fit into the remainder is skipped, the request timeout is capped by the remainder; when the time is up, the run fails with an error listing everything that was tried. Because of this keepLLM_TIMEOUTaround 120–300 seconds: one hung request must not eat the whole budget.
A single chain — one model without reserves, typically Ollama on its own
box — behaves differently from earlier versions: instead of seventeen minutes
of waiting (retries after 2, 5, 5 and 5 minutes) the model gets three
requests — the original, a short retry after ~30 s and one more after the
two-minute quarantine; after the third failure the stage fails. A timeout is
a failure too, and a quarantine will not help a slow model: it only
lengthens the pause between attempts. A slow local model needs an
LLM_TIMEOUT with room for the largest request and an LLM_TIME_BUDGET
that fits three such requests with their pauses; LLM_OVERLOADED_QUARANTINE
is about a model that is temporarily overloaded and should come back.
The review key (see "A single comment per merge request") is computed from
the configured primary model, not from the one that answered: a review made
by a fallback model is not rewritten on the next push without changes in the
MR, and adding a fallback model to a per-stage chain does not re-run the
review on every open MR. When a stage went to a fallback model, the report
ends with a Fallback model used: critique — … line; the log names, for
every stage, the model that answered and its place in the chain
(model=gemini/gemini-3.6-flash (2/5)). A key switch stays in the log only,
and key values never reach the log. --dry-run prints the model chains, the
number of keys per provider, the time budget and the quarantine length;
--no-fallbacks leaves one model and one key per stage.
Checking that findings point at the diff
Between the passes every candidate is checked against the diff that actually
went to the model (after ignore_paths, secret scrubbing and the budget
cut). What is checked is the link to the code, not the bug itself:
-
fileis not among the changed files of the MR (renames included): the candidate is dropped before Critique, there is nothing to check it against. A file of the MR that was left out of the context by the budget is a different case, see below. -
linefalls into none of the shown hunks: the report shows the finding without a line number. -
quoted_codeis not found in the shown diff (compared ignoring whitespace, on the new and on the old side): the candidate stays, but the "Where" line of the report gets(quote not found in the diff). Critique cannot fix the quote, so the mark survives its keep. - The file is shown partially, without a diff or not at all: the link cannot be checked, and the coverage block of the report already says so.
Critique receives the result of the check in the candidate's note field
and decides keep/reject with it in mind. With --no-critique the check works
the same way, its marks just go straight to the report.
Critique engine: an LLM or Jev
llm.critique.engine picks who checks the candidates of the first pass:
model (default) |
jev |
|
|---|---|---|
| What checks | the LLM Critique chain or pool (LLM_CRITIQUE_MODEL, critique.rank) |
Jev, a classifier by TypeSafe |
| Context | the whole prompt: MR, Jira, diff | the same, cut to Jev's limits (see below) |
| Wording of findings | refined by the critic | the first pass's own |
| Time and cost | tens of seconds, provider quota | under a second, per input token |
| Depends on Gemini | when the critique model is Gemini | no, with fallback: fail
|
| Non-English reviews | fine | not declared by TypeSafe, check first |
| Code leaves for | the LLM provider | TypeSafe as well |
The default stays model. A stronger critic than the generator is set with
the existing settings (LLM_CRITIQUE_MODEL, or the order of the pool); Jev is
meant for when speed or independence from the LLM quota matters more than
refined wording, and only after its thresholds are chosen from the shadow
logs (see below).
llm:
critique:
engine: jev # LLM_CRITIQUE_ENGINE, --critique-engine jev
jev:
model: jev-1.13.0 # LLM_JEV_MODEL; a pinned version, an alias is an error here
fallback: model # LLM_JEV_FALLBACK: model or fail
keep_above: 0.5 # LLM_JEV_KEEP_ABOVE; enough_context, version_claim, duplicate in YAMLWith engine: jev Jev answers the questions of prompts/jev_questions.yml
about every candidate and decides:
- claims that a version does not exist — reject;
- not enough in the request to judge it, or too large for a Jev request —
unverifiable: with
fallback: modelsuch candidates go to the LLM Critique (only them), withfallback: failthey are rejected; - otherwise keep when
real_issuereacheskeep_above; - duplicates are dropped once, over what Jev and the LLM kept together: the more severe finding stays, on a tie the one the first pass listed first.
When Jev fails (network, 429/529 after one retry, an invalid answer), the
LLM Critique takes over with fallback: model, and the run fails with
fallback: fail. The report says how Jev took part: checked by Jev without
refining the wording, partly by the LLM, or by the LLM because Jev was
unavailable. engine: jev without JEV_API_KEY or with an alias is a
configuration error at start; --no-critique switches off every engine.
What an LLM is still needed for follows the engine: with fallback: fail
there is no LLM Critique at all, so its model and key are not required
(generate on Ollama with Jev needs no Gemini key), the context budget counts
generate only, the pool's critique policy is not used and models check
probes the generate stage only. models check also sends Jev a probe
request: with engine: jev it counts, in shadow mode it is shown but never
fails the check. --dry-run shows the Jev request for the stub candidate
(=== JEV STATE ===, === JEV QUESTIONS ===).
The review key follows the engine too: with engine: jev it includes the Jev
version, every threshold, the fallback and the question templates; the LLM
Critique model counts only while Jev can fall back to it. With the default
engine the key is what it was before Jev, whatever llm.jev says. Without
an LLM Critique (Jev with fallback: fail, or --no-critique) the critique
settings — model, limit, start, rank, allow_weaker — are neither validated
nor part of the key, while the generate pool stays in it.
Jev shadow (experiment)
Jev is a classifier, not a text model: it answers yes/no and choice
questions about a given state with probabilities. Before it decides anything
as the engine, it can run in shadow mode next to the default engine: after
the LLM Critique the same candidates go to Jev, and its decisions are logged
next to the Critique verdicts. The report and the review key do not change;
with engine: jev the shadow is ignored.
llm:
jev:
shadow: true # LLM_JEV_SHADOW=true
model: jev-1.13.0 # LLM_JEV_MODEL; a pinned version, not jev-latest
timeout: 10 # seconds per request
keep_above: 0.5 # provisional thresholds, only for the log line
enough_context: 0.5
version_claim: 0.5
duplicate: 0.5The key is JEV_API_KEY. For every candidate Jev is asked whether it is a
real, well-supported problem (the rules of prompts/critique.txt, asked in
prompts/jev_questions.yml), whether the state holds enough to judge it,
whether it claims that a version does not exist, which other candidate it
duplicates, and how severe it is. The log gets one line per candidate and a
summary:
Jev shadow C1: keep (real issue; real_issue=0.91 enough_context=0.88 version_claim=0.02 duplicate_of=none/0.97 severity=major/0.74), critique: keep
Jev shadow: agrees with critique on 2 of 2 decided candidate(s); keep 1, reject 1, unverifiable 1 (model=jev-1.13.0, requests=1, 0.4s)
The readable lines round the numbers; a third line, Jev shadow data: {…},
carries every decision with the probabilities exactly as Jev returned them,
so thresholds can be chosen from the logs afterwards. The version that
answered is logged too, and a pinned version answering as another one is a
warning. A Jev failure (network, 429/529 after
one retry, an invalid answer, no key) is a warning and never affects the
review. Jev goes out through LLM_HTTP_PROXY like the LLM providers.
What leaves for TypeSafe: the MR title and description, the Jira section,
review_instructions, the diff shown to the models and the candidates, after
the same secret scrubbing as the LLM prompts. Jev limits a request to 32k
tokens for the state plus the longest question and 64k for the state plus all
questions; the state is cut to what the questions leave (the diff first, then
the MR and Jira sections, never the candidates and the hunks they point at),
and when the candidates do not fit together, each goes in a request of its
own. The size is estimated from characters; if Jev still rejects a request
as too large (a 422 that explicitly says it exceeds a limit), its candidates
are asked about one by one, and one that is too large alone stays
unverifiable. Any other 422 is not retried.
Usage
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --post
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --no-jira
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --dry-run --verbose
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --generate-model gemini-3.7-flash --critique-model gemini-3.8-flash
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --generate-model gemini-3.7-flash --generate-temperature 0.3 --critique-model gemini-3.8-flash --critique-temperature 0
bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project/-/merge_requests/123 --no-critique-
--generate-model MODELoverrides the model for the Generate pass only. -
--critique-model MODELoverrides the model for the Critique pass only. -
--generate-temperature VALUEoverrides the temperature for the Generate pass only. -
--critique-temperature VALUEoverrides the temperature for the Critique pass only. -
--config PATHpoints at a specific.aireview.yml. -
--no-jiraturns off the Jira enrichment even when the MR carries an issue key. -
--dry-runprints the LLM settings, the context sizes and coverage, and the Generate prompt, plus the Critique prompt unless--no-critiqueis given. -
--no-critiqueskips the second pass and renders the Generate candidates directly. -
--no-fallbacksuses only the primary model and the first API key of each stage. -
--review-mode MODEsets the behaviour when a review has already been published:updateoronce. -
--forcereviews again even when a review for this state of the MR is already published.
Checking the models
bundle _2.3.26_ exec bin/aireview models check
bundle _2.3.26_ exec bin/aireview models check --config .aireview.yml --strict --verboseEvery model of both stage chains gets two small requests — one with the production Generate schema and one with the Critique schema — on a tiny synthetic MR, with the same system prompts as a review. The answer goes through the same validation as in a run (JSON shape, candidate and verdict ids). The provider's catalog is not consulted: "the model is listed" does not mean "our request with the schema passes on it". There are no reserves, retries or quarantine here — this is a check, not a review; only a per-minute limit gets one retry after the provider's hint.
Checking 5 model(s) with the generate and critique schemas
gemini/gemini-3.7-flash generate ok (4.6s)
gemini/gemini-3.7-flash critique ok (6.9s)
gemini/gemini-3.6-flash generate unverified: This model is currently experiencing high demand. …
gemini/gemini-9.9-nope generate missing: models/gemini-9.9-nope is not found for API version v1beta, …
ollama/qwen2.5-coder:7b generate skipped: Connection refused
Result: 6 ok, 1 unverified, 2 missing, 2 skipped -> FAILED
Statuses: ok — an answer matching the schema; missing — the provider
has no such model (retired, a typo, not pulled into Ollama); invalid — it
answered, but not by the schema; unverified — the provider could not
answer right now (overload, quota, timeout), the model is not at fault but
not confirmed either; failed — other API errors; skipped — Ollama is
unreachable where the check runs (a CI runner has none). The exit code is 0
only when every model is ok or skipped; with --strict — only ok: on
a box where Ollama must be running, an unreachable Ollama is a failed check,
not a skip.
A single comment per merge request
With --post the review is not published as a new note every time; it updates
its own note instead. A hidden marker <!-- aireview:key=... --> is embedded
into the comment body, and the review uses it to find its own note among the
others. The note is looked up across all pages of the discussion and only among
the ones written by the same user the token belongs to. When there is no note
with the marker, the tool picks up its own older note that starts with
**aireview review**. Its key is unknown: on the next allowed run it will be
updated and get a marker. In once mode an old review also counts as existing —
updating it needs a Retry. Duplicates that have already piled up are not
removed automatically.
The key is a hash of the assembled prompts together with the providers, models
and temperatures of both stages. So the diff, the merge request description, the
Jira issue context, review_instructions, ignore_paths and the response
language all end up in it on their own: change any of those and the key differs,
so the review runs again.
The behaviour is set by review_mode (or REVIEW_MODE, or --review-mode):
-
update(the default) — when the key matches the previous review, no requests are sent to the LLM at all; when the diff or the settings have changed, the review runs again and overwrites the previous comment. -
once— the review is done once, and later pushes to the MR do not repeat it. To update the review by hand, press Retry on the job in GitLab: if the key has changed, the LLM runs again and updates the previous comment. If nothing changed, no requests are sent to the LLM. The current state of the MR is checked, including when the job is restarted from an old pipeline. Note that.aireview.yml(models,review_instructions,ignore_paths) is taken from the checkout of the restarted pipeline. The[skip review]check in the example below uses theCI_MERGE_REQUEST_TITLEof that pipeline, not the fresh title from the API.
Detecting a Retry needs CI_PROJECT_ID, CI_JOB_ID and access for the
GITLAB_TOKEN to the
Jobs API.
The tool checks whether an earlier attempt of the same job exists in the same
pipeline. An automatic retry from the GitLab retry: setting counts as a repeat
attempt too. Outside of GitLab CI, once mode keeps skipping an existing
review. The --force flag repeats the review even when nothing has changed.
While the review is running, the merge request can move on. Before publishing,
aireview re-reads it and compares sha, the target branch, diff_refs, the
title and the description: if any of those changed, the result is not published —
a comment must not end up holding a review of a diff that is no longer current.
The review text is printed to stdout before the publishing attempt, so it stays
in the job log. The previous comment is kept until the end of the next run.
GitLab CI
For merge request pipelines aireview can run in a separate CI job, letting
GitLab substitute the current MR URL:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stages:
- review
aireview:
stage: review
image:
name: registry.gitlab.example.com/your-group/aireview:latest
entrypoint: [""]
variables:
MR_URL: "$CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID"
REVIEW_LANGUAGE: "ru"
LLM_PROVIDER: "gemini"
LLM_GENERATE_MODEL: "gemini-3.7-flash"
LLM_CRITIQUE_MODEL: "gemini-3.8-flash"
LLM_TIMEOUT: "60"
LLM_HTTP_PROXY: "http://127.0.0.1:8888"
script:
- bundle _2.3.26_ exec bin/aireview review "$MR_URL" --verbose
timeout: 45m
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failuretimeout: 45m is the job ceiling, not a guarantee: the LLM part of the run
is itself bounded by LLM_TIME_BUDGET (30 minutes by default, pauses and
fallback models included), the rest is reading the MR and Jira and
publishing.
Set secrets such as GITLAB_TOKEN, GEMINI_API_KEY / GEMINI_API_KEYS and
the optional Jira credentials in the GitLab CI/CD variables. If the job should publish the result
back to the merge request, add --post to the review command.
For runners where the LLM provider is only reachable over WireGuard, bring up a
local HTTP proxy (wireproxy, for instance) before aireview starts and point
LLM_HTTP_PROXY at it. That avoids setting global https_proxy/no_proxy, so
GitLab and Jira stay on direct connections while RubyLLM goes through the
tunnel.
Including the job template
Instead of copying the job into every project, it lives in this repository
as templates/review.gitlab-ci.yml. A project's .gitlab-ci.yml keeps only:
include:
- project: your-group/aireview
ref: stable
file: /templates/review.gitlab-ci.ymlThe template is written for shell-executor runners (the image runs through
docker run with secrets passed by name) and carries [skip review],
resource_group, allow_failure and REVIEW_MODE=once. Models come from
the image defaults, .aireview.yml is mounted into the container only when
the project has one. The job runs in the .post stage: it exists in every
pipeline, so a project does not declare it.
GitLab does not start a pipeline in which, after rules and only are
applied, only .pre and .post jobs remain. That happens when a project has
no other merge request jobs (a build that runs on tags only does not count).
In that case add a review stage to the project's existing stages, without
replacing them, and move the job there:
stages:
- review # added to the project's stages
- build
aireview:
stage: reviewTo keep the review from waiting for other stages to finish, add needs: []
to the aireview job.
The template sets REVIEW_MODE as a job variable, and an environment variable
beats .aireview.yml: review_mode in the project file has no effect once
the template is included. Change the mode with a REVIEW_MODE CI/CD variable
of the project or with aireview: {variables: {REVIEW_MODE: update}}.
Every variable the config reads (Config.env_names: models, providers,
reserves, temperatures, limits, OLLAMA_API_BASE and so on) is passed into
the container by name, so a project can override anything through its CI/CD
variables — for instance, swap an unavailable model with LLM_CRITIQUE_MODEL
without waiting for an image release.
Secrets stay CI/CD variables of the project: group variables are readable by any merge request of any project in the group.
The stable branch is managed: the copy of the template there pins
AIREVIEW_IMAGE_TAG to a verified release. Updating or rolling back every
project that includes the template is one edit of stable. A project can
pin another version by overriding AIREVIEW_IMAGE_TAG in its variables.
A release of the image is: tag → aireview models check on the image
defaults (an API error or an answer off the schema for any model stops the
pipeline, the image is not built, the previous version stays in the
registry) → build and push → set the new tag in stable. The same check on
a schedule (once a day) is the early signal that the provider retired a
model — otherwise the first to learn about it is a live MR.
Docker
docker build -t aireview .
docker run --rm --env-file .env -v "$PWD/.aireview.yml:/app/.aireview.yml:ro" aireview \
review https://gitlab.company.com/team/project/-/merge_requests/123Testing
bundle _2.3.26_ exec rspec
bundle _2.3.26_ exec rspec spec/config_spec.rb
bundle _2.3.26_ exec rspec spec/secret_scrubber_spec.rbNotes
- The reviewer does not check whether the specified versions of dependencies and images exist: the model's knowledge of releases is outdated, and that is what CI is for. Syntax errors and contradictions with the MR/Jira requirements are checked as usual.
- The CLI looks for
.aireview.ymland.envwalking up from the current working directory, so the project config can be kept in the repository root even when the tool is run fromaireview/. - How Ollama behaves when a request is still larger than its context window is up to the server, not to
aireview: check theollama servelog for truncation messages on your setup and sizemax_prompt_charsso it does not happen.
Releasing
Releases are published to RubyGems.org by the Release workflow through
trusted publishing, so no
API key is stored anywhere. To cut a release:
-
Bump
Aireview::VERSIONinlib/aireview/version.rband move theUnreleasedsection ofCHANGELOG.mdunder the new version. -
Commit, then tag the commit with the same version and push the tag:
git tag v0.2.0 git push github main v0.2.0
The workflow refuses to run when the tag does not match Aireview::VERSION,
runs the test suite, builds the gem, pushes it and then creates a GitHub
release for the tag with the matching CHANGELOG.md section as its notes and
the built .gem attached.
Changelog
See CHANGELOG.md.
Contributors
See CONTRIBUTORS.md.