team · operations

The bot said nothing: how to find where it stopped

A merge request sits there with no summary, no comments, no gate status — and nothing tells you why. You will not have to guess: almost every outcome leaves a trace. Traces live in three places — the delivery log in GitLab or GitHub, the bot's log and the request itself — and, if the bot keeps its metrics in Postgres, in a fourth one, the metrics table.

One question splits the search in half: has a review ever come on this repository? If a review has never come, the break is between GitLab or GitHub and the bot: the webhook address, its events, the secret, the network. If it used to work, then this event was skipped on purpose, the run failed, or it is still being retried — and each of these shows up as a line in the log or a notice in the merge request itself.

Below: where the traces are and how to read them, what each trace means, when to wait and when to look, and what to check on a repository that has never been reviewed.

Where the traces are

The delivery log in GitLab or GitHub — no server access needed. Every delivery is recorded together with the bot's answer, verbatim. In GitLab it is Settings → Webhooks → Edit → Recent events, for the last two days. The API keeps a week of them, but it needs the Maintainer role — the bot's own token usually will not do:

terminal
GITLAB=https://gitlab.com
PROJECT=reviewgate-group%2Finvoices-api   # url-encoded path, or the numeric project id
HOOK=88628352                             # GET /projects/$PROJECT/hooks lists them

# the webhook API needs the Maintainer role; the events of the last 7 days
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "$GITLAB/api/v4/projects/$PROJECT/hooks/$HOOK/events?per_page=6" \
| jq -r '.[] | "\(.created_at[0:19])  \(.response_status)  \(.response_body)"'
terminal
2026-09-08T13:06:08  202  {"queued":false,"reason":"ignored note action=update"}
2026-09-08T13:06:06  202  {"queued":true}
2026-09-08T13:02:37  202  {"queued":true}
2026-09-08T12:48:48  202  {"queued":false,"reason":"ignored action=update"}
2026-09-08T08:56:21  202  {"queued":true}
2026-09-08T08:55:59  202  {"queued":true}

Read it in two steps. Is the delivery there at all? If your event is missing, the break is before the bot: the hook is not configured, the event type is not ticked, or GitLab has switched the hook off after a run of failures. Then read the body, not the status code. The bot answers 202 to deliberate skips as well, and what tells them apart is queued. "queued":true means a job was created; "queued":false comes with a reason — the bot looked at the event and decided there was nothing to review. Both reason lines above are correct behaviour: one is an update of the merge request without new commits, the other an edit of an existing comment.

On GitHub the same list is under Developer settings → GitHub Apps → Edit → Advanced → Recent deliveries (or Settings → Webhooks for a repository hook), for the last three days. The body has the same shape; only the reasons differ — say, ignored event=push or ignored action=labeled.

The bot's log — a line per outcome. Every ending writes a line, and the log is English whatever language your config sets. Two commands cover it: the first shows what the bot did with the deliveries, the second everything about one merge request — every line of its jobs is tagged [group/project!42 @sha], on GitHub [org/repo#42 @sha]:

terminal
# what the bot did with the deliveries: queued, skipped, rejected
docker compose logs --since 2h app | grep -E "Webhook|webhook|Queued a review"

# everything about one merge request (on GitHub: org/repo#42)
docker compose logs --since 2h app | grep -F 'group/project!42'

With LOG_FORMAT=json there is no tag in the text: filter on the project_path and mr_iid fields instead.

The merge request itself. A failed run usually leaves a notice in it: at once for failures that retrying cannot fix, such as a bad key or a context that did not fit, and after the last attempt for the rest. A run that had no files to look at leaves one too. But a missing notice does not mean the break came earlier: a draft, a closed request, the branch policy, a repeat of the same head, a push that changed only lock files — all of these are skipped quietly, and only the bot's log knows.

The metrics table, if the bot has Postgres: every finished run and every failure gets a row with its status — reviewed, repeat_head, publish_failed, key_error and others. Skips such as a draft or a closed request leave no row.

What each trace means

In the delivery logWhat happenedWhat to do
no delivery at all the hook is not configured, the event type is not ticked, or GitLab has switched the hook off after a run of failures Settings → Webhooks: an address ending in /api/webhooks/gitlab, Merge request events ticked (plus Comments if you want replies in threads). A disabled hook comes back on with the Test button
401 the secret does not match GITLAB_WEBHOOK_SECRET, or the vendor is not configured on this installation the bot's log says which: Webhook rejected: invalid X-Gitlab-Token or GitLab webhook rejected: the vendor is not configured…. On GitHub it is GitHub webhook rejected: invalid X-Hub-Signature-256, and the secret is GITHUB_WEBHOOK_SECRET
5xx, or a timeout the bot did not answer, or failed on the event itself: the container is down, the port is closed, a proxy is in the way curl -s https://<bot>/api/health — a live bot answers "status":"ok" with its version. If it is alive, look for 500 on POST in its log
202 {"queued":true}a job was created; the break is further alongread the bot's log — the second table
202 …"reason":"ignored object_kind=push" the wrong event type is ticked; on GitHub the same reads ignored event=push tick Merge request events (and Comments for replies)
202 …"reason":"ignored action=update" an update with no new commits: the description, labels, all threads resolved. An approval comes as its own actions, approval and approvednothing — this is by design
Line in the logWhat happenedWhat to do
⏭ Skipped: draft, review_drafts=falsethe merge request is a draft mark it Ready, or set review_drafts: true in .reviewgate/config.yml of the request's own branch
⏭ Skipped MR !N: state closed the request was closed or merged before the queue got to it nothing
⏭ Skipped: head … is already reviewed this head was reviewed already: a duplicate of a stalled job, or a repeated delivery nothing; the model was not called, so it cost nothing
⏭ Skipped: dev → release — not a feature-into-integration review the integration_branches policy: only a request from a working branch into an integration branch is reviewed, and here the source is on the list itself, or the target is not fix the integration_branches list
Incremental: no changed files to review — skipping
Skipped: the scope contains only lock files (…)
nothing new to review: no file changed since the last run, or only lock files did nothing; the summary of the previous run stays in place
No files left to review (hidden by ignore: N, excluded by marker: N, empty diff: N) no file was left after filtering; the counters in brackets say where they went the notice is already in the request; the exact counters by cause are in this line of the log
✖ Review failed: …an attempt failed, retries are aheadwait — the next section says how long
LLM provider unavailable (key/balance), … a failure the bot recognised: the key, the balance, the model address, the context, a refusal, an overloaded provider, the network the notice in the request names the cause and the cure
The review did not start (… failed, attempts exhausted) the request or its files could not be read: token permissions, the network to GitLab or GitHub the cause is in this same line. The bot tries to leave a notice, but a token without rights will not publish it either
The review did not finish (unexpected error, attempts exhausted) the run failed and the error class was not recognised the request gets a generic notice with no cause; the raw message is in this line of the log
The summary was not published (…) the run went through and was paid for, but the summary did not land: the token cannot comment, the network broke, the request was deleted give the token its rights back; the next push runs the review again

The bot recognises eight kinds of failure — a bad key or no balance, a wrong model address, a context that did not fit, a truncated answer, LLM_MAX_TOKENS above the model's ceiling, a refusal, an overloaded provider and a network that would not come up — and each arrives as a notice naming the cause and the cure. Everything else gets a generic notice: the run failed, look in the log. If the log line names something we should have recognised, that is worth telling us.

Where the bot is genuinely silent

Give it time first. A failure that may pass is retried: five attempts, with pauses of 30 s, 1 min, 2 min and 4 min between them. That is seven and a half minutes of pauses plus the attempts themselves, and a model call that hangs lives up to LLM_TIMEOUT_MS, ten minutes by default. The network, an overloaded provider, an unrecognised error and any failure to read the request are retried; a bad key, a context that did not fit or a refusal are not. Meanwhile the log shows ✖ Review failed with try N/5 in the tag.

The job was created and nobody took it. The delivery says "queued":true, the log says Queued a review of MR !N, and nothing follows. Either the worker is not draining the queue — it is not running, cannot reach Redis, or every slot is busy — or the job is waiting on purpose: another run is reviewing the same request (the log says deferring for 30 s, and this can go on for about ten minutes), or a new push replaced it. GET /api/health shows the slot limits and what is running in this process, but it sees neither the queue nor Redis: whether Redis is alive, docker compose ps will tell.

The run finished and the summary did not land. The review happened and was paid for, but publishing failed: the token lost the right to comment, the network to GitLab or GitHub broke, the request was deleted. The run is not repeated. The log says The summary was not published, the metrics table records publish_failed, and in the request you may find inline threads and the gate status without a summary — they are published before it.

And the trap: after a failure, «Resend» does nothing. A job is identified by project, request and head commit. A finished job — a skip included — is kept for an hour, a failed one for at least a day, and re-delivering the same event within that time creates no new job: the delivery log shows "queued":false with a reason saying the job already exists, and the bot's log records the same. What works is a new head or a new state: a push, Draft → Ready, a change of the target branch. Closing and reopening the request does not help.

The first request got nothing

If reviews have never come on this repository, check these six things in this order; the first three are visible in the delivery log.

  1. The address. GitLab delivers to /api/webhooks/gitlab, GitHub to /api/webhooks/github: everything the bot serves lives under /api.
  2. The events.
    • for reviews: Merge request events in GitLab, or Pull request in a GitHub App. Push events will not do — the bot answers them with ignored object_kind=push;
    • for the bot's replies in threads, if you want them: also Comments in GitLab, or Issue comment, Pull request review comment and Pull request review in GitHub, plus replies turned on in the bot itself — REPLY_ENABLED=true or reply.enabled: true, as they are off by default;
    • turning replies on in the bot does not change the hook: in a GitLab hook that already exists you tick Comments by hand, and a GitHub App's new permissions have to be accepted by every installation — until then GitHub keeps sending the old set of events.
  3. The secret. Secret token in GitLab equals GITLAB_WEBHOOK_SECRET on the bot, the App's webhook secret equals GITHUB_WEBHOOK_SECRET. A mismatch is the 401 from the table above; after changing .env, restart the bot's container.
  4. GitLab or GitHub can reach the bot. A self-hosted GitLab refuses requests to local addresses until an administrator allows them: Admin → Settings → Network → Outbound requests. The refusal usually shows as soon as you save the hook. And github.com delivers events only to a public address: a bot behind the corporate firewall will not get them.
  5. The request is not a draft. Drafts are skipped by default: mark it Ready, or set review_drafts: true in .reviewgate/config.yml — the bot reads it from the request's own branch.
  6. The bot's account can read the project and comment on it. Without read rights the run breaks at the start, and after five attempts the log says The review did not start (change set read failed…). Without the right to comment it is worse: the review goes through and is paid for, and it is publishing that fails — The summary was not published.

Next