mr-review for AI agents¶
One page holding everything a coding assistant needs to deploy, configure and operate mr-review correctly, plus a map of where the rest of the documentation keeps the details it leaves out. Give an agent this page rather than the whole site.
| What it is | A self-hosted merge request review tool: it pulls an MR diff from a VCS host, sends it to an AI model, lets you edit the comments, and posts them back |
| Images | ghcr.io/bedrock-python/mr-review/all-in-one, .../api, .../web-app — each tagged with the exact version and latest |
| Compose | deploy/all-in-one/docker-compose.yml (one container) · deploy/standard/docker-compose.yml (API and UI apart) |
| Requires | Docker with the Compose plugin. No Python, no Node, no database, no accounts |
| Ports | all-in-one 17240 → 8000 · standard UI 17242 → 8080 (proxies /api/ to the API), API 17241 → 8000 — all published on 127.0.0.1 |
| State | one host directory mounted at /data: hosts.yaml, ai_providers.yaml, review_presets.yaml, reviews/<uuid>.yaml |
| Env prefix | MR_REVIEW__, __ between levels — MR_REVIEW__SERVER__PORT |
| AI providers | claude (Anthropic Messages API) · openai and openai_compat (any OpenAI chat-completions endpoint) |
| VCS hosts | gitlab, github, gitea, forgejo, bitbucket |
| Source | https://github.com/bedrock-python/mr-review |
How to read this page¶
Every page of this site is also served as raw Markdown at its own URL with .md in place
of the trailing slash — this page is /agents.md, the configuration guide is
/getting-started/configuration.md — so anything the map below points at can be fetched
as plain text rather than scraped out of HTML. The Copy page control at the top of a
page does the same thing for a human with a chat window open.
There is no generated API reference on this site. The HTTP API is described by the running
application: GET /system/openapi.json is the schema, /system/docs and /system/redoc
are the two renderings of it. Everything below was read out of deploy/, .github/ and
services/mr-review/mr_review/; where a guide page and the source disagree, the source is
what runs.
Top to bottom before writing a compose file. Rules that break a deployment is the section correctness lives in — those are the things the application will not save you from.
Scope¶
It does run on one machine as one or two containers, connect to any number of VCS hosts with a personal access token each, list repositories and open merge requests, fetch a diff (or an arbitrary two-ref diff), build a prompt out of the diff plus whatever context you enable, stream a review back from a model, let you edit and dismiss individual comments, and post the survivors to the merge request as inline notes. It keeps hosts, providers, review presets and review history in YAML files under one directory, and can export and import that state as a single JSON file with the tokens encrypted under a passphrase, left out, or — only when asked for explicitly — in plain text.
It does not authenticate anyone — there is no login, no user model and no token on any route. It has no scheduler, no webhook receiver and no CI mode: a review starts because a person clicked. It runs no model of its own; the diff goes to whatever endpoint the provider points at. It has no database, no migrations and no multi-process story. It does not create the access tokens, and it does not merge, approve or close anything.
Mental model¶
Four nouns, and each of them is a row in a YAML file under the data directory — plus the review presets users save, a fifth file with no bearing on anything else.
- A host is one VCS instance: a name, a
type(gitlab,github,gitea,forgejo,bitbucket), abase_urland a token. Adding one and testing it are two separate calls; the test hits the host's "who am I" endpoint and returns username, name and email. - An AI provider is one endpoint: a
type(claude, oropenai/openai_compat, which are the same backend), an API key, an optionalbase_url, a list of models, an SSL toggle, a timeout and an optional per-provider concurrency cap. - A review binds a host, a
repo_pathand a source — eitherMRSource(mr_iid=…)orBranchDiffSource(base_ref=…, head_ref=…). A review is created empty. - An iteration is one pass over that source: a
brief_config, the provider and model it was dispatched to, and the comments that came back. A review is a list of them. - A review preset (
review_presets.yaml) is a saved review intent: a name, a description,instructionsthat open the prompt in place of a built-in preset's text, and an optional partialbrief_configthe Brief applies when the preset is picked. A brief points at one bycustom_preset_id; when it is deleted, the brief's built-inpresetstands in.
The pipeline is the iteration's stage, and it only moves forward:
brief → dispatch → polish → post
- brief —
BriefConfigdecides what the model is asked and what goes into the prompt: a preset (thorough,security,style,performance, or a saved one), focus areas, the comment language, a minimum severity and a comment cap, the context toggles, path filters and a size budget — see What goes into the prompt.POST /reviews/{id}/promptreturns the exact prompt as text without calling anything;POST /reviews/{id}/prompt/previewreturns it with a size breakdown. - dispatch —
POST /reviews/{id}/dispatchstreams the model's output back over SSE, announces each comment as soon as its JSON object is complete, and, when the stream closes, parses the whole answer and stores its comments with the raw answer on the iteration — unless the answer is unusable, see rule 14. Comments below the brief'smin_severity, and beyond itsmax_comments(the most severe are kept), are dropped before they are stored — on dispatch (salvaged comments included), import and reparse alike. The provider's fence caps how many dispatches to that provider can be in flight at once. - polish —
PATCH /reviews/{id}edits comment bodies, severities,status(kept/dismissed) and the anchor:fileandlinecount only when present, and an explicit"file": nullturns the comment into a general note.POSTandDELETEon/reviews/{id}/iterations/{iteration_id}/commentsadd a hand-written comment or remove one. - post —
POST /reviews/{id}/postsends thekeptcomments that are not on the merge request yet and stores, on each comment, what became of it (see below). The iteration moves topostonce any comment is on the MR and getscompleted_atonce all of them are; a completed iteration answers 409 unless the request setsforce.
Each comment's post field (absent or null until the comment was first sent) records the
latest attempt:
| Field | |
|---|---|
outcome |
inline (on its line), general_note (an MR-level note: no line, or the line could not be anchored), or failed |
at |
when it was posted, or when the attempt failed |
note_id, url |
the host's id for the note, discussion note, review comment or issue comment, and a link to it when known |
reason |
for failed, the host's error; for general_note, why a comment with a line did not go inline |
failure_kind |
for failed: position_rejected (the host refused the comment's place in the diff; nothing was posted), rejected (refused for another reason, or never sent; nothing was posted), ambiguous (a timeout, a 5xx or a dropped connection: it may be on the MR after all) or blocked (not sent on purpose, see Gitea below) |
A post sends only comments without a successful record, so retrying after a partial post or
a dropped connection never duplicates what already landed. Only a position_rejected
comment can fall back to a general note; an ambiguous or blocked one never does, and an
ambiguous one is not sent again unless the request sets resend_ambiguous (the UI asks
first). Every outcome is written as soon as the host answers, and the post runs to the end
even if the client disconnects. While a post of a review runs, another post of it (any
iteration) and any comment change — PATCH /reviews/{id}, adding or deleting a comment —
answer 409, since each write replaces the stored review. The request body takes
iteration_id, fallback_to_general_note (default true), severity_label (bold —
**Major** · …, tag — [major] …, or off), resend_ambiguous and force; the
answer is {posted, failed, skipped, held_back, completed, results: [{comment_id, post}],
review}, held_back counting the ambiguous comments left alone.
Inline comments are anchored only to lines the MR diff shows (new-file line numbers). Diffs
are parsed by their @@ counts, the backend and the web app alike: \ No newline at end of
file is not a line, a content line starting with -- or ++ stays a content line, and
deleted (+++ /dev/null), binary and rename-only files are kept, without hunks. GitHub
and Gitea/Forgejo get one review (event: COMMENT) with every inline comment. When GitHub
refuses it with 422 the comments are posted one by one so a bad position fails alone; any
other failure of the review leaves every comment failed. When Gitea refuses it with 400 or
422, the pending review it left behind is deleted and the comments go one per review; after
a 5xx the post first looks for a review of the user's at that commit that it created anyway
(and records its comments as posted), and only when there is none deletes the leftovers and
goes one by one — if that cannot be told, the comments are ambiguous. Gitea will not post
inline at all while the token's user has a review of their own pending on the pull request
(blocked). GitLab gets one discussion per comment, its position naming old_path/new_path
and new_line, plus old_line for an unchanged line; a 400 naming the position is a
position refusal. Bitbucket gets one comment per request with inline.to. GitLab's draft
notes are not used: bulk_publish publishes every pending draft of the user, and silently
drops one whose position does not persist.
The model is asked for a bare JSON array of {file, line, severity, body} objects,
severity being one of critical, major, minor, suggestion — or, with structured output
on, is constrained to the object {"comments": [...]} holding them, through Claude's
output_config.format or OpenAI's strict response_format: json_schema. An endpoint that
rejects structured output fails the dispatch with a hint to turn it off; it is not retried
without. The parser does not rely on any of that: it drops <think> reasoning blocks, finds the JSON inside markdown fences or
prose, accepts a wrapper object ({"comments": [...]}, also review, issues,
findings, items — every such key is read), a single comment object or one object per
line, merges comments split across several fences or arrays, repairs trailing commas,
comments, smart or single quotes, bare keys and raw newlines, and keeps every complete
comment of an answer that was cut off at the token limit. The answer proper — the fences,
or the JSON the answer opens with — always wins over JSON quoted in prose, so an echoed
format example never becomes a comment and a fenced [] means "no findings". Common key
aliases (path, line_number, message, level, …) and severity synonyms (blocker,
high, warning, nit, …) are mapped — a negated one such as non-blocking reads as
minor — and a line reference such as "L42" or "12-15" becomes its first number.
Parsing takes linear time whatever the answer holds. The raw answer the iteration's
comments came from stays on it (up to 512 000 characters; a longer one keeps its head and
tail): GET .../iterations/{iteration_id}/raw-response returns it as text and
POST .../iterations/{iteration_id}/reparse parses it again into fresh comments.
The dispatch stream sends these SSE events, every data line being single-line JSON:
| Event | data |
|---|---|
chunk |
the next piece of model text, as a JSON string |
comment |
{index, file, line, severity, body} — a comment that just completed; a preview without an id |
done |
{iteration_id, comments, errors, json_error, truncated, kept_previous, filtered} — once, after the iteration is written |
error |
{message} — the stream ends here and no done follows |
: ping comment lines may appear in between. comments counts what the iteration holds
now. truncated is true when the provider reports that it stopped at the output limit or the
context window (Claude max_tokens / model_context_window_exceeded, OpenAI
finish_reason: length), or when the answer stops inside the JSON it consists of or inside a
reasoning block. kept_previous is true when the answer was not used (rule 14): the comments
counted are the ones the iteration already had, and the answer exists only in the streamed
chunk text. A refusal — Claude's stop_reason: refusal, OpenAI's refusal under structured
output, or an endpoint's content filter — ends the stream with error instead, saying so, and
is settled like any other failed run. filtered (newer than the other keys; read it as 0
when absent) counts the parsed comments the brief's minimum severity or comment cap left out
of what was written. The import-response and reparse answers carry the same filtered
next to imported.
Wiring¶
The smallest compose file that runs, and every line in it is load-bearing:
services:
mr-review:
image: ghcr.io/bedrock-python/mr-review/all-in-one:latest
ports:
- "127.0.0.1:17240:8000" # the app has no auth — do not publish this
volumes:
- ./data:/data # the image keeps all state in /data
The image already sets MR_REVIEW__STATIC_DIR=/app/static, which is what makes it
all-in-one: the same server that answers /api/v1/... also serves the built UI, and any
unmatched path falls back to index.html. It also sets MR_REVIEW__DATA_DIR=/data; tags
built before that default existed keep state in ~/.mr-review inside the container, so a
pinned older tag needs MR_REVIEW__DATA_DIR: "/data" as well — the shipped compose files
always set it. The bind address defaults to 0.0.0.0 and the port to 8000, so neither
needs setting inside the container; who can reach it is decided by the published port.
The container starts as root, gives /data to PUID:PGID (1000:1000 by default) and
runs the application as that user, so a bind-mount directory Docker created as root on
Linux still works — see rule 19.
Everything else — hosts, tokens, providers, models — is added in the UI and written to
/data. There are no environment variables for them.
The shipped deploy/all-in-one/docker-compose.yml adds restart: unless-stopped,
MR_REVIEW__LOGGING__USE_JSON: "true", PUID/PGID, the MR_REVIEW_BIND switch for the
published address, and MR_REVIEW__HOST_DATA_DIR, which is display only: the UI shows that
path instead of the container's, and hides the "open in file manager" button when it is
set.
The standard deployment splits the API and the UI. The web container's nginx serves the UI
and forwards every /api/ request to the API container, so the browser sees one origin —
no CORS, no API URL for the browser to resolve, and the UI works under any hostname:
services:
api:
image: ghcr.io/bedrock-python/mr-review/api:latest
volumes: ["./data:/data"]
web:
image: ghcr.io/bedrock-python/mr-review/web-app:latest
ports: ["127.0.0.1:17242:8080"]
environment:
API_UPSTREAM: "http://api:8000" # the default: the api service, resolved on the compose network
depends_on: [api]
The shipped deploy/standard/docker-compose.yml also publishes the API itself on
127.0.0.1:17241, for scripts and /system/docs; the UI does not use it.
The configuration surface¶
Application environment variables¶
Read by pydantic-settings with prefix MR_REVIEW__, __ between nesting levels, case
insensitive, unknown keys ignored. They belong in the compose file's environment: block.
| Variable | Default | What it does |
|---|---|---|
MR_REVIEW__DATA_DIR |
/data in the images, ~/.mr-review outside them |
Where hosts, providers and reviews are written. Mount /data |
MR_REVIEW__STATIC_DIR |
unset | Serve the built UI from this directory. The all-in-one image sets /app/static; leaving it unset is what makes an API-only container |
MR_REVIEW__HOST_DATA_DIR |
unset | Display only — the host path the UI shows in place of the container's |
MR_REVIEW__VCS_TIMEOUT |
60.0 |
HTTP timeout in seconds for every VCS call |
MR_REVIEW__SERVER__HOST |
0.0.0.0 |
Bind address inside the container. Leave it; limit exposure with the published port instead |
MR_REVIEW__SERVER__PORT |
8000 |
Port inside the container |
MR_REVIEW__SERVER__WORKERS |
1 |
Leave it at 1 — see rule 4 |
MR_REVIEW__SERVER__ACCESS_LOG |
false |
uvicorn access log |
MR_REVIEW__SERVER__PROXY_HEADERS |
true |
Trust X-Forwarded-* |
MR_REVIEW__SERVER__FORWARDED_ALLOW_IPS |
* |
Which proxies are trusted to set them |
MR_REVIEW__SERVER__TIMEOUT_KEEP_ALIVE |
5 |
Seconds |
MR_REVIEW__SERVER__TIMEOUT_GRACEFUL_SHUTDOWN |
10 |
Seconds |
MR_REVIEW__ALLOWED_HOSTS |
localhost,127.0.0.1,::1,api |
Host names (no port) the server answers; any other Host header gets a 400 naming it — the guard against DNS rebinding. Comma-separated or a JSON array, *.example.com for subdomains, * to switch the check off. Setting it replaces the list, but localhost, 127.0.0.1 and ::1 are always accepted. Reaching mr-review by a LAN address, a domain or through a proxy needs that name added — see rule 21 |
MR_REVIEW__CORS__ALLOW_ORIGINS |
["http://localhost:5173","http://localhost:3000"] |
JSON array. The dev defaults. Both shipped deployments are same-origin and never need it; only an API_BASE_URL on another origin does |
MR_REVIEW__CORS__ALLOW_CREDENTIALS |
true |
|
MR_REVIEW__CORS__ALLOW_METHODS |
["*"] |
JSON array |
MR_REVIEW__CORS__ALLOW_HEADERS |
["*"] |
JSON array |
MR_REVIEW__LOGGING__LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
MR_REVIEW__LOGGING__USE_JSON |
false |
Both compose files set it to true |
MR_REVIEW__AI_THROTTLE__DEFAULT_MAX_CONCURRENT |
4 |
In-flight dispatches per provider, unless the provider overrides it |
MR_REVIEW__API_BASE_URL |
"" |
All-in-one only, and read straight from the environment rather than from settings: what the served config.js tells the browser. Empty means same origin, which is what you want |
API and all-in-one container entrypoint¶
Read by the entrypoint of the api and all-in-one images before the application starts.
| Variable | Default | What it does |
|---|---|---|
PUID |
1000 |
User the application runs as. Started as root, the entrypoint gives the data directory to PUID:PGID and drops to that user |
PGID |
1000 |
Its group |
Started as a non-root user instead (user:, --user, runAsUser), the entrypoint ignores
both, changes no ownership, and exits with a message if any file or directory in the data
directory (lost+found aside) is not readable and writable by that user. Whatever the
application writes there is created mode 600.
Starting as root needs the CHOWN, SETUID and SETGID capabilities, which Docker grants
by default. With cap_drop: [ALL], add those three back with cap_add, or set user:.
Without SETUID/SETGID the container exits with a message instead of running the
application as root; without only CHOWN it skips the ownership fix.
Web-app container environment variables¶
The UI image is nginx on port 8080 with an entrypoint that writes config.js and the nginx
config at start-up. These have no MR_REVIEW__ prefix.
| Variable | Default | What it does |
|---|---|---|
API_UPSTREAM |
http://api:8000 |
Where nginx forwards /api/. Resolved inside the container network per request, so the API may start later or move. scheme://host:port, no path |
API_BASE_URL |
"" |
Where the browser sends API calls. Empty is the UI's own origin, through API_UPSTREAM. An origin, without /api — the UI appends /api/v1/... |
API_URL |
the origin of API_BASE_URL |
Extra origin allowed by the CSP connect-src; empty when API_BASE_URL is |
NGINX_RESOLVER |
the container's nameservers | DNS server nginx resolves API_UPSTREAM through — Docker's 127.0.0.11 on a compose network |
HSTS_MAX_AGE |
"" |
Seconds for a Strict-Transport-Security header; empty sends none. HSTS belongs to the proxy that terminates TLS |
HSTS_INCLUDE_SUBDOMAINS |
false |
true adds includeSubDomains to it |
APP_ENV |
production |
Written into config.js for the UI. It no longer turns on HSTS |
APP_VERSION |
unknown |
Shown in the UI |
VITE_USE_MOCKS |
false |
Compose-level variables¶
Substituted by Docker Compose into the shipped files. These do belong in a .env next to
the compose file.
| Variable | Default | Used by |
|---|---|---|
MR_REVIEW_BIND |
127.0.0.1 |
both — host address every port is published on. 0.0.0.0 exposes it to the network; see rule 1 |
PORT |
17240 |
all-in-one — host port mapped to container 8000 |
WEB_PORT |
17242 |
standard — host port for the UI, which also carries the API under /api/ |
API_PORT |
17241 |
standard — host port for the API itself |
DATA_DIR |
./data |
both — host path bound to /data |
PUID / PGID |
1000 / 1000 |
both — passed to the API container's entrypoint |
VCS hosts¶
base_url is the instance's web URL. Each provider appends its own API path.
| Type | API base derived from base_url |
Token |
|---|---|---|
gitlab |
<base_url>/api/v4 |
Personal access token, api scope |
github |
github.com, www.github.com, api.github.com (any path) or empty → https://api.github.com; anything else → <base_url>/api/v3 |
Classic token, repo scope |
gitea, forgejo |
<base_url>/api/v1 |
Personal access token |
bitbucket |
base_url is ignored — always https://api.bitbucket.org/2.0 |
username:app_password for Basic auth; a token with no colon is sent as Bearer |
A repo is identified by its repo_path. POST /api/v1/hosts/{id}/repos/add-by-url accepts
a full URL, a host/owner/repo string or a plain owner/repo slug, strips a trailing
.git, validates it against the host and pins it as a favourite — which is how a public
repository the token is not a member of becomes visible, since GitLab is listed with
membership=true and GitHub through the user's own repositories. Only GitLab accepts more
than two path segments.
AI providers¶
| Field | Default | Notes |
|---|---|---|
type |
— | claude uses the Anthropic Messages API; openai and openai_compat both use the OpenAI chat-completions client |
api_key |
— | Stored in the data directory |
base_url |
"" |
Empty means the backend's default endpoint. For claude, a gateway in front of Anthropic (LiteLLM, a proxy); for the OpenAI types, Ollama (http://host.docker.internal:11434/v1), Groq, LM Studio, a gateway |
models |
[] |
The first entry is the model used when a dispatch names none. There is no built-in fallback: with an empty list such a dispatch answers 422 |
ssl_verify |
true |
false disables verification entirely — for a corporate CA, install it in the system trust store instead, which is what the client already reads |
timeout |
60 |
Seconds the client waits on the endpoint — to connect, and between pieces of the streamed answer |
max_concurrent |
unset | Per-provider in-flight dispatch cap; unset falls back to MR_REVIEW__AI_THROTTLE__DEFAULT_MAX_CONCURRENT |
GET /api/v1/ai-providers/{id}/models asks the endpoint itself for its model list with the
saved settings; POST /api/v1/ai-providers/preview/models does the same with settings that are
not saved yet (provider_id fills in what is left out, a blank api_key keeps the saved key —
for the saved base URL and type only; a changed endpoint without a key answers 422) —
it is what the settings form's "Fetch models" calls. An endpoint that rejects the key answers
401, one that times out 504, anything else upstream 502, each with the endpoint's message.
A dispatch may also carry, all optional: model, temperature (0–2), reasoning_effort
(none, minimal, low, medium, high, xhigh, max), reasoning_budget (thinking
tokens), max_output_tokens (256–128 000, thinking included), structured_output (null:
off for unknown OpenAI ids and whenever base_url is not the vendor's own API, otherwise
on for claude and openai models that support it, off for openai_compat) and
system_prompt (replaces the built-in one). Before the call each setting is fitted to the
model, because a rejected parameter fails the whole request with a 400:
- current Claude models (Opus 4.6+, Sonnet 4.6+, Fable, Mythos, and any Claude id the table
does not know) get adaptive thinking with
output_config.effort; only the models before 4.6 get abudget_tokensthinking budget, kept at least 4 096 tokens belowmax_tokens; - an effort level the model lacks becomes the nearest one below it; a budget becomes an effort on effort-only models and the other way round;
temperaturegoes only to models that take it (none from Opus 4.7 / Sonnet 5 on, no OpenAI reasoning model), only while reasoning is off, capped at 1 on Claude;- the output limit is capped at the model's maximum; unset, Claude gets 32 000 tokens (64 000
at
xhigh/max, budget + 16 000 with a budget), the OpenAI types the endpoint's default. It is sent asmax_tokensto Claude and toopenai_compat, asmax_completion_tokensto OpenAI.
GET /api/v1/ai-providers/{id}/capabilities?model=… returns what a model accepts — thinking
(always, optional, none), reasoning modes, effort levels, whether it takes a temperature,
its output cap, structured output support and default — decided from the id, without calling
the provider. The full table is in AI providers.
HTTP API¶
Prefix /api/v1 unless shown otherwise. There is no trailing-slash redirect.
| Route | Method | What it does |
|---|---|---|
/system/health/livez, /system/health/readyz |
GET | Liveness and readiness. Note the /system prefix — not /health |
/system/openapi.json, /system/docs, /system/redoc |
GET | The schema and its two renderings |
/api/v1/system/info |
GET | Data directory, versions, deployment_mode (all-in-one or standard) |
/api/v1/hosts |
GET, POST | List, create |
/api/v1/hosts/{id} |
PATCH, DELETE | Update, delete |
/api/v1/hosts/{id}/test |
GET | Verify the token against the host |
/api/v1/hosts/{id}/repos/add-by-url |
POST | Resolve a URL or slug, verify it, pin it |
/api/v1/hosts/{id}/favourite-repos/{repo_path} |
POST | Toggle a pin |
/api/v1/hosts/{id}/cache/invalidate |
POST | Forget cached VCS responses: the whole host, or with repo_path only that repository's MRs, diffs, files, directories and commits, plus the personal inbox scopes (authored, assigned, review_requested) that list its MRs. 204 |
/api/v1/hosts/{id}/repos |
GET | One page of repositories, most recently active first — q, page, per_page (default 50) |
/api/v1/hosts/{id}/repos/{repo_path}/mrs |
GET | One page of merge requests, most recently updated first — state (opened by default, merged, closed, all), q (title), page, per_page (default 30) |
/api/v1/hosts/{id}/repos/{repo_path}/mrs/{iid} |
GET | One merge request |
/api/v1/hosts/{id}/repos/{repo_path}/mrs/{iid}/diff |
GET | Its parsed diff |
/api/v1/hosts/{id}/inbox |
GET | One page of open MRs — scope (all by default, authored, assigned, review_requested), page, per_page (default 30) |
/api/v1/ai-providers |
GET, POST | List, create |
/api/v1/ai-providers/{id} |
PATCH, DELETE | Update, delete |
/api/v1/ai-providers/{id}/models |
GET | The endpoint's model list, with the saved settings |
/api/v1/ai-providers/preview/models |
POST | The same with unsaved settings — the settings form's "Fetch models" |
/api/v1/ai-providers/{id}/capabilities |
GET | What model (default: the provider's first) accepts in a dispatch |
/api/v1/reviews |
GET, POST | List, create from an MR |
/api/v1/reviews/code |
POST | Create from a base_ref/head_ref diff |
/api/v1/reviews/{id} |
GET, PATCH, DELETE | Read, edit brief and comments, delete |
/api/v1/reviews/{id}/iterations |
POST | Start another pass |
/api/v1/reviews/{id}/diff, /api/v1/reviews/{id}/context |
GET | The diff and the collected context as text |
/api/v1/reviews/{id}/prompt |
POST | The exact prompt as text, without calling the model — for an unsaved brief_config too |
/api/v1/reviews/{id}/prompt/preview |
POST | The same prompt as JSON with total_chars, estimated_tokens (characters ÷ 4, an estimate), budget_chars, per-part sections (characters, files included, cut short, left out, binary skipped), excluded_files and preset_missing |
/api/v1/reviews/{id}/excluded-files |
POST | Which changed files a brief_config's path filters leave out, and why — the diff only, no context fetched |
/api/v1/reviews/{id}/dispatch |
POST | Run the review, streamed as SSE (chunk, comment, done or error); 409 once posted |
/api/v1/reviews/{id}/import-response |
POST | Paste a model's answer in by hand |
/api/v1/reviews/{id}/iterations/{iteration_id}/raw-response |
GET | The stored model answer as text; 404 if none |
/api/v1/reviews/{id}/iterations/{iteration_id}/reparse |
POST | Parse the stored answer again, replacing the comments; 409 once posted |
/api/v1/reviews/{id}/post |
POST | Post the kept comments not yet on the merge request; per-comment results and the updated review. 409 once completed (unless force) or while another post of it runs |
/api/v1/reviews/{id}/iterations/{iteration_id}/comments |
POST | Add a comment (file, line, severity, body); the server assigns the id and answers 201 with the review |
/api/v1/reviews/{id}/iterations/{iteration_id}/comments/{comment_id} |
DELETE | Remove one comment and return the review |
/api/v1/review-presets |
GET, POST | Saved review presets, oldest first; create one (name, description, instructions, brief_config). A name taken by another preset, ignoring case, is a 409; an unknown or invalid brief_config field is a 422 |
/api/v1/review-presets/builtin |
GET | The four built-in presets with the instructions each puts in the prompt (read-only) |
/api/v1/review-presets/{id} |
GET, PATCH, DELETE | Read, change (omitted fields stay; brief_config is replaced whole), delete |
/api/v1/data/export, /api/v1/data/import |
POST | The whole store — hosts, AI providers, review presets, reviews — as one JSON file; secrets encrypted, left out, or plain on explicit opt-in |
/api/v1/data/import/preview |
POST | What an export file holds and how much of it already exists here; writes nothing |
Pagination¶
The three list routes above answer one page at a time, and every page costs a single request to
the host (the all inbox scope costs one per repository in its batch, see below) — nothing is
fetched ahead.
pageis 1-based;per_pageis 1–100. Anything outside those bounds, an unknownstateor an unknownscopeis a 422 before the host is called.has_moreis the host's own next-page signal — GitHub'sLink: rel="next", GitLab'sX-Next-Page, Gitea'sX-HasMoreorLink, Bitbucket'snext— and only when a host sends none of them, "the page came back full". Keep paging while it istrue: a page can be shorter thanper_page, even empty, and still have more after it.- Hosts cap the page size on their side — Gitea at its
MAX_RESPONSE_ITEMS(50 by default), Bitbucket at 50 pull requests or 100 repositories — so a largerper_pagegives shorter pages, never skipped items. - In MR listings
additions,deletionsandfile_countarenullwhen the host does not report them in that view (GitHub, GitLab and Bitbucket lists never do). The single-MR route fills what the host provides — GitLab only ever reportsfile_count. GitLab's list items also leavepipelinenull.
Repositories: pinned favourites the host's listing does not return are fetched and put in front of
page 1 (filtered by q when one is given), and left out of later pages, so each appears once.
On GitHub, q goes through repository search scoped to the token's user and the organisations
listed by /user/orgs (user:<login> org:<org> …), not all of GitHub. A token that may not list
organisations searches the user's own repositories only; repositories the user merely collaborates
on in someone else's account are not searched. On Gitea and Forgejo the listing (and so the all
inbox) covers the repositories the token's user owns or contributes to (uid=<their id>), not every
repository the instance shows — which on Codeberg would be all of it.
Merge requests per host:
| Host | opened / all |
merged / closed |
q |
|---|---|---|---|
| GitLab | merge request listing | same, by state | host-side title search |
| GitHub | pulls API | issue search (is:merged; is:closed is:unmerged) |
issue search, in:title |
| Gitea, Forgejo | pulls API | pulls API closed, split per item |
issue search (/issues?type=pulls&q=): titles, bodies and comments, in Gitea's order |
| Bitbucket | pull request listing | same (closed is DECLINED + SUPERSEDED) |
host-side title search |
GitHub's issue search returns no branch names, so those items have empty source_branch and
target_branch, and it allows 30 requests a minute per user. Gitea's issue search has no branch
names either. Gitea has no merged filter on any endpoint, so its merged/closed split is made on each
fetched page — the one case where pages can come back short, or empty, while more remain.
The inbox (all scopes list open MRs only, newest update first within a page):
| Scope | GitLab | GitHub | Gitea, Forgejo | Bitbucket |
|---|---|---|---|---|
authored |
scope=created_by_me |
author:@me |
created=true |
/pullrequests/{user} |
assigned |
scope=assigned_to_me |
assignee:@me |
assigned=true |
always empty — no assignees |
review_requested |
reviewer_username=<you> |
review-requested:@me |
review_requested=true |
always empty — no such listing |
authored, assigned and review_requested are each one host request per page. GitHub and Gitea
answer them from issue search, so those items have no branch names either. all walks the
repositories the token sees, most recently active first: page N takes the N-th batch of 10
repositories and the newest min(per_page, 10) open MRs of each (five repositories at a time), merged
newest-first; has_more means more repositories remain. Pinned favourites join page 1. A
repository whose MRs cannot be fetched is skipped with a warning rather than failing the page.
The inbox envelope has one more key, truncated_repos: the repositories on that page that had more
open MRs than it took — their own MR list has the rest. It is always [] for the personal scopes.
Order is newest-first within a page only; a later page can hold a more recently updated MR.
{"items": [...], "page": 1, "per_page": 30, "has_more": true, "truncated_repos": ["group/busy-repo"]}
VCS connections and caching¶
Every VCS call goes through one pooled HTTP client that lives as long as the process, with
keep-alive connections reused across requests and hosts; MR_REVIEW__VCS_TIMEOUT is its timeout.
Read-only responses are cached in memory per host: repository list pages for 15 minutes, everything
else — repository searches, MR pages, single MRs, diffs, files — for 5 minutes, in bounded
least-recently-used stores. Diffs, trees and file bodies are also capped by size, about 128 MB per
host; a single response larger than that is served but not kept.
Lists are sorted by activity, so a push moves an item from a later page to page 1, and pages fetched at different times can each miss it. A later page is therefore only cached together with the page 1 it was fetched with — within 10 seconds of it, and only if none of page 1's items turn up on it again (which is what a move to the top looks like). Otherwise the cached page 1 is dropped, so the next load of the list starts from a fresh one.
Concurrent identical requests share one call to the host, and errors are never cached. Editing or
deleting a host drops its cache, and POST /api/v1/hosts/{id}/cache/invalidate drops it on demand
— for one repository with ?repo_path=, which is what to call after a push the cache has not seen
yet. GitHub and Gitea can only list a directory by returning the repository's whole tree, so that
tree is fetched once per repository and commit and every directory the context collectors ask for is
answered from it. Gitea hands the tree out 1000 entries a page; up to 100 pages are read.
What goes into the prompt¶
The BriefConfig fields, with the caps the collectors enforce. Every field has a default,
so a brief stored by an older version loads unchanged, the newer options at their defaults; a
stored field that no longer validates falls back to its default instead of making the review
unreadable.
| Field | Default | What it does |
|---|---|---|
preset |
thorough |
also security, style, performance — the instructions that open the prompt |
custom_preset_id |
null |
a saved review preset whose instructions replace the built-in preset's; empty instructions or a deleted preset fall back to preset |
custom_instructions |
"" |
appended verbatim under "Additional Instructions" |
focus_areas |
[] |
a checklist the model must go through explicitly (at most 30, 200 characters each) |
output_language |
"" |
the language for comment bodies; empty asks for nothing, so the model follows the code and the MR (at most 64 characters) |
min_severity |
suggestion |
the model is asked for nothing less severe, and anything less severe in the answer is dropped when it is stored |
max_comments |
null |
1–200: the model is asked for at most this many, and only the most severe this many are stored (ties keep the earlier one; the answer's order is kept) |
include_diff |
true |
the diff itself |
include_description |
true |
title and description |
include_context |
true |
the files named in context_files (or the usual convention files when empty), at most 20 |
include_full_files |
false |
whole contents of the first 15 changed files that were not deleted — including files whose diff the host sent empty (too large, binary, rename-only); binary content is then dropped |
include_test_context |
false |
at most 20 test files beside the changed ones, those named after a changed file first |
include_related_code |
false |
at most 20 files that the first 30 changed Python and JS/TS files import (relative imports only) |
include_commit_history |
false |
the last 8 commits touching each of the first 50 changed files |
include_paths |
[] |
glob patterns; when non-empty only matching changed files are reviewed |
exclude_paths |
[] |
glob patterns of changed files to leave out, read after the defaults; !pattern takes a file back in (!/go.sum), the last match deciding |
use_default_excludes |
true |
also leave out lockfiles (*.lock, package-lock.json, pnpm-lock.yaml, go.sum, …), *.min.js, *.min.css, *.map, vendor/, node_modules/, dist/, *_pb2.py, *.pb.go and binary assets |
annotate_line_numbers |
true |
every added and unchanged diff line reads <marker><new-file line> \| <code>, and the model is told line must be one of those numbers |
include_previous_comments |
true |
from iteration 2 on, the previous iteration's kept comments are listed as already reported |
prompt_budget_chars |
600000 |
20 000–4 000 000; the prompt's size cap — sized for a ~200k-token model |
Path patterns work like .gitignore (checked against real git): a pattern with no slash, or
only a trailing one, matches at any depth (*.snap, docs, gen/); any other slash ties it
to the repository root (src/generated matches src/generated/a.py, not
tools/src/generated/b.py); a pattern that matches a directory covers everything in it, so
include_paths: ["services/api"] reviews that directory; a trailing / matches directories
only; *, ? and [...] stay within a path segment, ** as a whole segment spans them;
\ escapes the next character, # starts a comment; the last matching pattern decides. Two
deliberate differences from git: a leading ./ means the root, and !pattern takes a file
back in even when its directory is excluded (!/vendor/keep.go against the default
vendor/). Your patterns are case-sensitive, as git is on Linux; the built-in defaults ignore
case (*.png covers Shot.PNG). Excluded files are left out of the diff sent to the model
and of every context collector. When a change has files but the patterns leave none,
/prompt, /prompt/preview and /dispatch answer 422 instead of building an empty review.
The budget: the output format always goes in; the task text — the preset's or saved
instructions, the additional instructions, the focus areas — takes at most a quarter of the
budget and is cut from the end beyond that, so the diff always keeps most of the room. The
rest is added in priority order — the diff, the MR description, the previous comments,
project context, full files, tests, related code, commit history — file by file while it
fits. The first file that does not fit is cut at a line break (when at least 2 000 characters
of it fit) with a … [cut: N of M characters shown] marker, the rest of that part is left
out with a [Left out to fit the prompt budget: …] note naming the files, and the parts
below it get whatever room remains. Every file is also capped at 50 000 characters, and
file content that looks binary — NUL bytes, or more than 30% U+FFFD from bytes that are not
UTF-8 — is skipped; source in a legacy 8-bit encoding (cp1252, cp1251), which comes back
with a few replacement characters, is kept. Hunk headers in the prompt, and in
GET /reviews/{id}/diff, count the lines actually written under them. The preview reports
all of it per part.
Context fetches share one semaphore, five calls wide, and each starts as soon as a slot frees up. Every one of them is an API call against the VCS host, so the optional toggles cost wall-clock time before the model is called at all.
For a merge request, files are read at its head commit, which the target repository has even when the MR comes from a fork or its source branch has been deleted. The branch name is used only when the host does not report the commit.
Rules that break a deployment¶
- There is no authentication. Not one route requires a credential. Anyone who can
reach the port can read every host token and every API key through
POST /api/v1/data/exportwithinclude_plain_secrets, and can post comments to your repositories under your token. Bind the published port to127.0.0.1— the shipped compose files do, unlessMR_REVIEW_BINDsays otherwise — or put an authenticating proxy in front. Never expose it to a network you do not control. - Mount
/data. The images keep all state there (MR_REVIEW__DATA_DIR=/data); without a volume it goes when the container is recreated. Older tags default to~/.mr-reviewinstead — setMR_REVIEW__DATA_DIRexplicitly when pinning one. - The data directory is a secret. Host tokens and provider API keys are stored in
plain text in
hosts.yamlandai_providers.yaml. The passphrase on export encrypts the export file, not the store. The application creates the directory0700and its files0600; a bind-mounted directory that already exists keeps the permissions you gave it. - Run one worker.
MR_REVIEW__SERVER__WORKERSabove 1 gives each process its own VCS cache and its own AI concurrency fence, so the cap becomes workers × cap, and the YAML store's write locks are per process: writes stay atomic, but two workers changing the same record can overwrite each other. - A provider's concurrency cap is fixed at first use. The semaphore is created on the
first dispatch to that provider and kept for the life of the process; changing
max_concurrentafterwards takes effect on restart. MR_REVIEW__*in a compose.envdoes nothing on its own. Compose reads.envto substitute${...}in the compose file; it does not pass those names into the container. The shipped compose files substitute onlyMR_REVIEW_BIND,PORT,API_PORT,WEB_PORT,DATA_DIR,PUIDandPGID. To change an application setting, add it to theenvironment:block or pointenv_file:at the file.- Leave
API_BASE_URLempty. The UI then calls its own origin and the web container's nginx forwards/api/toAPI_UPSTREAM, which is resolved inside the Docker network.API_BASE_URLis the opposite: written verbatim intoconfig.jsand resolved by the browser, sohttp://api:8000there resolves nowhere the user is. It is an origin — the UI appends/api/v1/...— and it is only for an API served from another origin. In all-in-one mode leaveMR_REVIEW__API_BASE_URLempty for the same reason. - Point the UI at another origin and you own CORS. With
API_BASE_URLset, the API's defaultallow_origins(the two dev-server ports) rejects the UI, andMR_REVIEW__CORS__ALLOW_ORIGINShas to list the UI's origin or every request fails in the browser and succeeds incurl. Neither shipped deployment needs it. - The all-in-one image is
linux/amd64only. Theapiandweb-appimages are built foramd64andarm64; the combined one is not. On Apple Silicon it runs emulated — use the standard deployment if that matters. base_urlis the web URL, not the API URL. GitHub Enterprise gets/api/v3appended for you, GitLab/api/v4, Gitea and Forgejo/api/v1. Only the GitHub provider notices that its suffix is already there; give GitLab, Gitea or Forgejo an API URL and the suffix is appended a second time.- Bitbucket is Bitbucket Cloud.
base_urlis ignored, and the token field must holdusername:app_password— a value with no colon is sent as a Bearer token instead. - Only GitLab has nested groups. For the other host types a repo path must be exactly
owner/repo; anything deeper is rejected before a request is made. - A branch-diff review cannot be posted.
POST /reviews/{id}/postanswers 409 for a review whose source kind isbranch_diff— there is no merge request to comment on. - Only a complete, readable answer replaces comments. Re-dispatching keeps the
iteration's comments until the new answer is stored, and replaces them only when the
stream finished and the answer parsed in full —
[]included, as "no findings". An answer that is unreadable, empty or cut off at the token limit, and a dispatch that fails or whose client disconnects, leave the comments, stage, provider and raw answer as they were (donesayskept_previous). Only an iteration without comments takes such an answer: its complete comments, or, for an unreadable answer, the text as one general comment. An iteration that is completed, or already inpost, refuses to be re-dispatched or re-parsed at all — start a new iteration instead. - Only
keptcomments are posted, each at most once. A comment whose line the diff does not show, or whose position the host refuses, is posted as a general note headed withpath:lineunlessfallback_to_general_noteisfalse, in which case it is recorded as failed. A timeout or a 5xx never becomes a general note: the comment is recorded asambiguousand is sent again only withresend_ambiguous— check the MR first. The iteration completes only when every kept comment is on the MR; a failed post leaves it open for a retry, which sends only what is missing. Once any comment is on the MR the iteration's comment list is frozen: adding or deleting a comment answers 409, thoughPATCHstill edits them (an edit does not change what was posted). - Local storage is not local inference. The diff, whatever context you enabled and your custom instructions go to whatever endpoint the provider names. Only a provider pointed at a model on your own machine keeps the code on it.
- Pin the tag on a server.
latestmoves whenever a release is published;docker compose pullwill then change the application under you. - Health checks live under
/system.livezandreadyzare/system/health/livezand/system/health/readyz. A probe on/healthis answered by the SPA fallback in the all-in-one image, so it returns 200 whatever the state of the application, and 404s in the API-only image. - The API containers start as root and drop to
PUID:PGID. That is how a root-owned bind mount becomes writable, and it needs theCHOWN,SETUIDandSETGIDcapabilities: a compose file withcap_drop: [ALL]mustcap_addthose three or setuser:, or the container exits at start. Withuser:everything in the data directory must already belong to that user — the container exits with achownhint if it does not.PUID=0keeps the application running as root: do not, except under rootless Docker or Podman, where container root is the invoking user on the host andPUID=0/PGID=0is what keeps the files owned by that user. API_UPSTREAMmust resolve as written. nginx looks it up per request throughNGINX_RESOLVERand applies noresolv.confsearch domains: a compose service name works, a Kubernetes short name does not — usehttp://<service>.<namespace>.svc.cluster.local:8000there.- Any host name but loopback has to be allowed. The server answers 400 ("Invalid host
header: '
' is not an allowed host name") to a Hostit does not know, so a web page cannot rebind its own domain to 127.0.0.1 and read the API. Reaching mr-review from another machine therefore takes two settings:MR_REVIEW_BIND=0.0.0.0(or the LAN address) so the port is published beyond loopback — see Opening it from another machine — and that machine's address or name inMR_REVIEW__ALLOWED_HOSTSon the API (all-in-one: the only) container, e.g.MR_REVIEW__ALLOWED_HOSTS: "localhost,192.168.1.10". Behind a reverse proxy add the domain it serves when it forwards the browser'sHost(proxy_set_header Host $host); one that does not sends its upstream's name instead —proxy_pass http://mr-review:8000arrives asmr-review— and that name is what has to be allowed. The standard deployment's web container forwards the browser'sHost.
Upgrading from earlier images¶
What changed after api 0.2.1 / web-app 0.2.2 that an existing deployment can notice, and the setting that brings the old behaviour back:
| Change | Symptom after upgrading | Old behaviour back |
|---|---|---|
Compose ports publish on 127.0.0.1 |
Unreachable from other machines | MR_REVIEW_BIND=0.0.0.0 in .env (rule 1 first) |
web-app API_BASE_URL defaults to "" and nginx proxies /api/ to http://api:8000 |
A web-app container run alone next to an API on host port 8000 answers /api/ with 502 |
API_BASE_URL=http://localhost:8000 (the API's CORS must list the UI's origin), or API_UPSTREAM set to an address the container can reach |
API images start as root, chown the data directory, drop to 1000:1000 |
cap_drop: [ALL] → exits at start; user: with files it cannot read → exits at start |
cap_add: [CHOWN, SETUID, SETGID], or user: with chown -R on the host directory |
| HSTS is opt-in | No Strict-Transport-Security from the web container |
HSTS_MAX_AGE=31536000, plus HSTS_INCLUDE_SUBDOMAINS=true for the old includeSubDomains |
The API answers only the host names in MR_REVIEW__ALLOWED_HOSTS (loopback and api by default) |
400 Invalid host header: '<name>' … when opened by a LAN address, a domain or through a proxy that rewrites Host |
Add that name to MR_REVIEW__ALLOWED_HOSTS (rule 21); * switches the check off |
The data written by older images (uid 100) is taken over automatically on the first start as root.
Common mistakes¶
# WRONG — reachable by anyone who can route to the host, with no login in front of it
ports:
- "8000:8000"
# RIGHT — loopback only, or a proxy that authenticates
ports:
- "127.0.0.1:17240:8000"
# WRONG — the app never sees these; .env only feeds ${...} substitution
# .env
MR_REVIEW__LOGGING__LEVEL=DEBUG
MR_REVIEW__AI_THROTTLE__DEFAULT_MAX_CONCURRENT=1
# RIGHT — in the compose file's environment block
environment:
MR_REVIEW__LOGGING__LEVEL: "DEBUG"
MR_REVIEW__AI_THROTTLE__DEFAULT_MAX_CONCURRENT: "1"
# WRONG — a service name the browser cannot resolve
web:
environment:
API_BASE_URL: "http://api:8000"
# RIGHT — leave it out: the browser calls the UI's own origin and nginx forwards /api/
# to API_UPSTREAM (default http://api:8000), which Docker resolves
web:
image: ghcr.io/bedrock-python/mr-review/web-app:latest
# RIGHT, only when the API really lives on another origin — an origin, no /api suffix,
# and the API told to accept the UI's origin
web:
environment:
API_BASE_URL: "https://api.mr-review.example.com"
api:
environment:
MR_REVIEW__CORS__ALLOW_ORIGINS: '["https://mr-review.example.com"]'
# WRONG — state written inside the container, gone on the next pull
services:
mr-review:
image: ghcr.io/bedrock-python/mr-review/all-in-one:latest
ports: ["127.0.0.1:17240:8000"]
# RIGHT
services:
mr-review:
image: ghcr.io/bedrock-python/mr-review/all-in-one:0.2.1
ports: ["127.0.0.1:17240:8000"]
environment:
MR_REVIEW__DATA_DIR: "/data"
volumes: ["/opt/mr-review/data:/data"]
# WRONG — a GitLab host given its API URL, which becomes .../api/v4/api/v4/...
base_url: https://gitlab.example.com/api/v4
# WRONG — Ollama on the host, addressed as if the container were the host
base_url: http://localhost:11434/v1
# RIGHT
base_url: https://gitlab.example.com
base_url: http://host.docker.internal:11434/v1
What failure looks like¶
The API answers with a status and a detail string; the UI shows it as-is. A failed call to a
VCS host is translated the same way on every route — listings, diffs, context, prompt, and
dispatch before its stream starts — so a host problem never surfaces as a bare 500.
| Status | When |
|---|---|
| 400 | Repo path must include at least 'owner/repo', or a host type given a nested path |
| 400, 422 | VCS rejected the request (<status>): <host's message> — the host refused what was asked (a bad ref, an unsupported search); retrying will not help |
| 400 | An import of an encrypted file without its passphrase, with a wrong one, or with a damaged secret — nothing is written |
| 401 | VCS authentication failed — check your token — the host rejected the token |
| 403 | VCS access denied — insufficient permissions, or Host token cannot access repository when adding one by URL — the token is valid but not entitled |
| 404 | A host, review, iteration or comment id that does not exist, or Not found on the VCS host: <path> — no such repository, merge request or ref there |
| 401 | Claude rejected the API key (401): … — listing or previewing an AI provider's models with a key the endpoint refuses |
| 409 | Posting a review whose source is a branch diff, posting an iteration that was already posted (without force), posting or changing the comments of a review (PATCH, add, delete) while a post of it runs, dispatching, re-parsing, adding or deleting comments on an iteration that was posted, or a review preset name that is already taken |
| 422 | A blank comment body, a line below 1, or a line without a file; a dispatch setting out of range; a dispatch with no model to a provider with no models; previewing models at a changed endpoint without the key; a brief field out of range, or an unknown field in a preset's brief_config; a prompt or dispatch whose path filters exclude every changed file; an export file that does not validate — for export and import the details name the field but never echo the value |
| 429 | VCS rate limit reached — try again shortly — the host is throttling the token: a 429, or GitHub's 403 for a spent quota or a secondary rate limit. GitHub's issue search allows 30 requests a minute. Retry-After carries the host's wait when it gave one |
| 502 | VCS returned <status>, VCS request failed (<status>) or Failed to post comments — the host answered, badly. For a post this means it could not start (the MR or its diff could not be read); a comment the host refuses is reported in the answer's results, not as an error |
| 502 | VCS host unreachable (<error>) — no answer at all: DNS, refused connection, TLS |
| 504 | VCS host timed out (<error>) — the host took longer than MR_REVIEW__VCS_TIMEOUT |
| 502, 504 | Could not reach …, … answered <status>: …, … did not answer in time — an AI provider's endpoint failing while its models are listed |
Two failures do not surface as a status code:
- The dispatch stream fails mid-flight. SSE has already answered 200, so the error
arrives as an
event: errorframe on the stream, with nodoneafter it. The iteration keeps its comments; one that had none takes the complete comments that arrived. The same happens when the client disconnects — the server writes this once it notices, so a client that stopped the stream should re-read the review until no iteration is indispatch. - The model did not answer with JSON.
donereports it injson_error. An iteration that already had comments keeps them (kept_previous); one without gets the answer as a singlesuggestioncomment with no file or line. Either way nothing is lost, and once the cause is fixed,reparsereads the stored answer again.import-responsestores nothing in that case and reports the error.
A review file that cannot be parsed is skipped with a warning and does not appear in the list — the rest of the store keeps working.
Documentation map¶
Fetch a page when the task is the one named beside it.
| Page | Read it when |
|---|---|
| Home | a one-screen description of what the tool is for |
| Installation | picking all-in-one or standard, the docker run one-liner, opening it to a network, updating and what an upgrade changes, data persistence and file ownership |
| Quick start | adding the first provider and host through the UI |
| Configuration | the environment variables as a user meets them, reverse proxy and TLS notes |
| Review pipeline | what each stage does from the UI's side |
| VCS hosts | creating a token with the right scope, verifying a connection |
| AI providers | choosing a model, pointing at Ollama or another compatible endpoint |
| Export and import | moving data between instances, backups, what each merge strategy does to existing records |
| vitest and expect-type | the frontend test suite refuses to start |
| Changelog | what changed between versions |
Two planning documents are not part of this site: ROADMAP.md and
specs/inline-fix-suggestions.md in the repository. They are drafts, they are written in
Russian, and they describe intent rather than what is deployed — read the pages above for
the tool as it is.