AI code review in GitLab CI: a merge request comment via the API

A GitLab CI job takes the merge request diff, sends it to Claude through Zukko and leaves a comment with findings in the MR, updating it on every push.

5 min · · development · автоматизация

AI code review in GitLab CI doesn't replace a reviewer, but it takes some routine off their plate: the model spots unhandled errors, missing None checks and suspicious SQL queries before a human opens the MR. Below is a job for .gitlab-ci.yml and a Python script: it takes the merge request diff, sends it to Claude through Zukko and leaves a single comment in the MR that is updated on every new push.

How it works

  1. On a merge request event, GitLab runs a pipeline with the ai-review job.
  2. The script computes the diff between the MR base (CI_MERGE_REQUEST_DIFF_BASE_SHA) and the current commit.
  3. The diff goes to the model through Zukko's OpenAI-compatible API (https://api.zukko.pro/v1).
  4. The answer is posted as a comment via the GitLab Notes API: POST /projects/:id/merge_requests/:merge_request_iid/notes. If a comment from a previous run exists, it is updated with PUT .../notes/:note_id so the discussion doesn't get cluttered.

GitLab passes all the identifiers to the job itself via predefined variables:

VariableContents
CI_API_V4_URLGitLab API v4 address (works on self-hosted too)
CI_PROJECT_IDproject ID
CI_MERGE_REQUEST_IIDMR number within the project
CI_MERGE_REQUEST_DIFF_BASE_SHAbase commit of the merge request diff
CI_MERGE_REQUEST_TITLEMR title
CI_MERGE_REQUEST_DRAFTtrue if the MR is a draft (GitLab 17.10+)

CI_MERGE_REQUEST_* variables exist only in merge request pipelines, which is why the job runs on the rule CI_PIPELINE_SOURCE == "merge_request_event".

Tokens and CI/CD variables

You need two secrets.

A GitLab token for comments. The most convenient is a project access token (project settings → Access tokens) with the api scope and a role allowed to comment on MRs, for example Developer. Comments will come from the project's bot user rather than your account.

A Zukko key of the form fetch_..., created in the dashboard. Create a separate key for CI with daily and monthly dollar limits and a list of allowed models, so a pipeline bug can't spend more than the limit. More in key limits.

Add both values under Settings → CI/CD → Variables as GITLAB_REVIEW_TOKEN and ZUKKO_API_KEY, with the Masked flag so they don't show up in logs. With the Protected flag, a variable is visible only to pipelines on protected branches, and MR branches are usually unprotected, so the job wouldn't get it. Keep the flip side in mind: an unprotected variable can be read by anyone who can push branches to the project, so give the tokens minimal rights.

One more nuance: pipelines for MRs from forks run in the fork project by default, and the main project's variables are not available there.

The job in .gitlab-ci.yml

bash
ai-review:
  stage: test
  image: python:3.12
  variables:
    GIT_DEPTH: 0
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT != "true"
  script:
    - pip install --quiet openai requests
    - python ci/ai_review.py
  allow_failure: true
  timeout: 10m

What matters here:

  • GIT_DEPTH: 0 fetches the full history; otherwise the MR base commit may be missing from the clone and git diff fails.
  • The python:3.12 image (not slim) already includes git.
  • allow_failure: true means the MR can still be merged if the API is down or the balance runs out.
  • Draft MRs are skipped until the author removes the Draft status.

The ai_review.py script

Put the file at ci/ai_review.py:

bash
import os
import subprocess

import requests
from openai import OpenAI

API = os.environ["CI_API_V4_URL"]
PROJECT = os.environ["CI_PROJECT_ID"]
MR_IID = os.environ["CI_MERGE_REQUEST_IID"]
BASE_SHA = os.environ["CI_MERGE_REQUEST_DIFF_BASE_SHA"]
GITLAB = {"PRIVATE-TOKEN": os.environ["GITLAB_REVIEW_TOKEN"]}

MODEL = os.getenv("REVIEW_MODEL", "claude-sonnet-5")
MAX_DIFF_CHARS = int(os.getenv("REVIEW_MAX_DIFF_CHARS", "60000"))

The reasoning behind the code:

  • Exclusions. Lock files and minified code almost never need review, and they eat a lot of tokens. EXCLUDE is a list of ordinary git pathspecs; extend it for your project.
  • Size cap. MAX_DIFF_CHARS protects against huge MRs (mass renames, generated code). For MRs that still don't fit, split the diff by file and send several requests.
  • One comment. The <!-- ai-review --> marker at the start of the comment is invisible in the UI, but the script uses it to find and update its previous comment.
  • Errors. If Zukko returns an error (401 key, 402 balance, 403 model not allowed for the key, 429 rate), the SDK raises an exception, the job fails with a clear message in the log, and allow_failure keeps the MR unblocked.

Cost and model choice

Every push to the MR branch reruns the review, so count by pushes. Example: a diff of 8,000 input tokens and an answer of 800 output tokens on claude-sonnet-5 ($0.30 / $0.30 per 1M tokens in Zukko):

(8,000 + 800) × $0.30 / 1,000,000 = $0.00264 per run. At 200 runs a month, 200 × $0.00264 = $0.528.

On glm-5.3-flash ($0.04 / $0.04) the same volume costs 200 × 8,800 × $0.04 / 1,000,000 = $0.0704 a month. The model is switched with the REVIEW_MODEL variable, no script changes; the current list is at GET https://api.zukko.pro/v1/models or on the "Models" page in the dashboard. Test which model finds more real problems in your code on your own tasks: run 10–15 already closed MRs with known bugs through two models and compare what each one found.

FAQ

The job fails with "fatal: bad object" on git diff

The MR base commit is missing from the clone. Check that the job sets GIT_DEPTH: 0 and that the project settings (CI/CD → General pipelines) don't set a small clone depth that overrides it.

The comment doesn't appear, the log shows 401 or 403 from GitLab

The token is missing, expired or lacks the api scope. For a project access token, check the role too: Guest may not be allowed to comment on MRs. If the variable is marked Protected, it simply isn't present in a pipeline for an unprotected branch.

Can it comment on specific lines?

The Notes API creates a general MR comment. For line comments GitLab has the Discussions API with a position in the diff, which is harder: you need to map line numbers from the model's answer to diff versions. Start with a general comment.

Is it safe to send code to an external API?

That's your company's call: the diff goes to the model through Zukko. If the repository sometimes contains secrets, add config files and .env to EXCLUDE, or better, keep secrets out of the repository entirely. API formats and authentication are covered in the Zukko documentation.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

AI code review in GitLab CI via an LLM API · Zukko