Skip to content

Review Merge Requests on GitLab

lgtmaybe posts reviews to GitLab as well as GitHub. The review itself is identical, because none of it depends on the host. It runs the same lenses, the same reflection pass, and produces the same findings. Only the adapter that fetches the diff and posts the comments differs.

Set It Up

A merge request pipeline runs the CI configuration from the merge request's own source branch. So where the job lives decides who can read its secrets. Read Security: Who Can Change the Pipeline before choosing where it goes.

  1. Create a token. In your project, go to Settings → Access Tokens. Create a project access token with the api scope, the Developer role and an expiry date. Add it under Settings → CI/CD → Variables as GITLAB_TOKEN. Make it masked, with Environment scope set to lgtmaybe.

  2. Add a provider key. Add the API key for whichever model you want as a second masked variable scoped to the lgtmaybe environment, for example ANTHROPIC_API_KEY. Use a key with a spend limit.

  3. Add the job. Copy examples/gitlab/.gitlab-ci.yml into your repository, or merge its lgtmaybe job into your existing pipeline. If not everyone who can push a branch is trusted with the secrets, put it in a separate configuration project instead. See Keep the Pipeline Configuration Outside the Project.

The job names the environment lgtmaybe with action: access. That lets it read the variables scoped to that environment without recording a deployment. The pipeline's other jobs never receive them.

The job runs python -m lgtmaybe gitlab-ci. It finds the merge request from these GitLab predefined variables:

  • CI_SERVER_URL or CI_SERVER_HOST;
  • CI_MERGE_REQUEST_PROJECT_PATH or CI_PROJECT_PATH;
  • CI_MERGE_REQUEST_IID.

It must therefore run on merge request pipelines:

rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"

A branch pipeline has no merge request to review. There, lgtmaybe fails with an error naming the missing CI_MERGE_REQUEST_IID rather than guessing.

A GitLab served under a sub-path is not supported. Its CI_SERVER_URL looks like https://example.com/gitlab, which the job cannot tell apart from a nested group, so it fails with an error naming the sub-path. Serve GitLab at the host root instead.

The example job does not clone the repository (GIT_STRATEGY: none), so there is no .lgtmaybe.yml to read. Configure the review with the command's flags: --provider, --model, --fallback-model, --api-key, --api-base, --config and --no-incremental.

Try It Locally First

Reviewing a merge request by URL happens in CI, where the token and the MR context live. Locally, lgtmaybe review reviews the branch you have checked out against the remote primary branch. It prints the findings instead of posting them:

git switch my-feature-branch
export ANTHROPIC_API_KEY=...
lgtmaybe review --provider anthropic --model claude-sonnet-4-6

It runs the same review. Use it to see what a model will say about your change before you wire up the pipeline.

How It Differs from GitHub

GitLab has no batched review object, so lgtmaybe posts each finding as its own discussion. The merge request's diff refs and the old or new file line anchor it. The summary is a note that updates in place on each run.

Resolve-on-fix works here: GitLab discussions resolve over plain REST, where GitHub needs GraphQL. lgtmaybe resolves a finding's discussion only once the follow-up validation pass on an incremental run has confirmed it is fixed. It never resolves one merely because the finding stopped being reported.

GitHub also requires the thread to be marked outdated (the code under it changed). GitLab's API has no such flag, so lgtmaybe works it out. A discussion counts as outdated when the diff since the commit it was posted on touches the hunk around its line.

The GitLab adapter also supports PR labels and the fail_on merge gate (reported as a commit status). Both are config-file settings, so they need a job that provides a file for --config.

Incremental Re-review

The first pipeline on a merge request reviews the whole change. Every later pipeline reviews only the commits pushed since the last completed review. It reads where to start from a hidden watermark in lgtmaybe's summary note. lgtmaybe trusts that watermark only in a note written by its own token's user. Another user's note therefore cannot skip a review.

It falls back to a full review whenever the increment can't be trusted:

  • the branch was rebased or force-pushed (the new commits don't build on the last reviewed one);
  • the new commits include a merge;
  • GitLab no longer knows the last reviewed commit, timed out, or left part of the diff out because it is too large;
  • any API call fails.

A failed or partial run leaves the watermark where it was. The next run then still covers the commits it missed. Running a pipeline again on a head that was already reviewed completely posts nothing.

GitLab has no /review full. To force a full re-review, do one of these:

  • run the pipeline with the CI/CD variable LGTMAYBE_INCREMENTAL set to false;
  • pass --no-incremental;
  • set incremental: false in the config file.

👎 Feedback

Award a 👎 (thumbsdown) emoji to the first note of one of lgtmaybe's finding discussions. Later runs then stop raising that finding on the merge request. Only a 👎 from a member with the Developer role or above counts, because anyone can award an emoji on a public project. A high or critical security finding is never hidden this way. Set learn_feedback: false to turn it off.

These are not available:

Not available Why What happens instead
Slash commands (/review, /ask, …) gitlab-ci reviews the merge request; nothing routes MR comments to it Re-run the pipeline to review again
Change overview comment gitlab-ci runs the review only No description or diagram comment is posted
Symbol resolution No base-branch clone in the GitLab adapter Reflection judges cross-file findings without the symbol's definition
Keyless cloud auth The OIDC/WIF exchange is built into the GitHub composite action Use an API key, or ollama

Security: Who Can Change the Pipeline

A merge request pipeline runs the .gitlab-ci.yml from the merge request's source branch, not from the target branch. This is the opposite of GitHub's pull_request_target, where the base branch's workflow runs. GitLab has no equivalent.

So anyone who can push a branch to the project (the Developer role and above) can edit the lgtmaybe job. They can keep its variables and send the model key and GITLAB_TOKEN to a server of their choosing. A branch pipeline gives them the same access without opening a merge request at all. GIT_STRATEGY: none does not help: the YAML itself is the part they control.

A job in the reviewed project's own CI configuration is only safe when everyone who can push a branch there is already trusted with both secrets. For a team where that holds, the setup above is enough. Otherwise, choose one of the setups below. Each has a cost, and none of them is as simple as pull_request_target.

Scoping the variables to the lgtmaybe environment is worth doing either way, but it is not a defence against this. It keeps the secrets away from jobs that never ask for them, such as a compromised test tool. A job the merge request controls can still name the environment.

Merge Requests from Forks

A merge request from a fork is safe by default. Its pipeline runs in the fork, with the fork's CI configuration and the fork's variables. It never receives the parent project's variables. Keep it that way:

  • Do not use Run pipeline on a fork's merge request in the parent project. That runs the fork's CI configuration in the parent project with all of the parent's variables, which hands the fork's author your secrets. GitLab shows a warning before it does this.
  • To remove the option, disable ci_allow_fork_pipelines_to_run_in_parent_project with the projects API.

The cost is that lgtmaybe does not review fork merge requests: their pipelines have no key.

Protected Variables

Mark GITLAB_TOKEN and the model key Protected. That keeps them out of every pipeline on an unprotected branch, so pushing a branch no longer reveals them. A merge request pipeline receives protected variables only when all of these hold (the setting below arrived in GitLab 18.1):

  • the project allows it, under Settings → CI/CD → Variables → Access protected resources in merge request pipelines;
  • both the source and target branches are protected, and in the same project;
  • the user who starts the pipeline can push or merge to the target branch.

A merge request pipeline from a fork never receives them.

The cost: lgtmaybe then reviews only merge requests between protected branches, opened by people who could already change the pipeline there. A merge request from an ordinary feature branch gets no token and the job fails. To skip the job there instead, extend its rule:

rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED == "true"

Keep the Pipeline Configuration Outside the Project

This works on every GitLab tier. In the reviewed project, set Settings → CI/CD → General pipelines → CI/CD configuration file to a file in a separate project. For example, .gitlab-ci.yml@my-group/ci-config (append :branch-name to pin a branch). Every pipeline in the reviewed project then runs that file, merge request and branch pipelines alike. No commit to the reviewed project can change a job. Changing the setting needs the Maintainer role. Protect the configuration project's default branch, and give write access only to people trusted with the secrets.

The variables stay unprotected, so merge requests from feature branches are reviewed. What it costs:

  • That file is now the project's whole pipeline, so the project's build and test jobs must live there too. Do not include the reviewed project's own CI files from it. A file read at the merge request's commit is the merge request's to edit, and its jobs could name the lgtmaybe environment.
  • Those build and test jobs still run the merge request's code. Keep both variables scoped to the lgtmaybe environment so those jobs never receive them. Keep that environment out of every job but the review.
  • Do not let a job that runs merge request code share a shell-executor runner with the review job. There, jobs run as the same user on the same host, and one can read another's environment.
  • Keep Settings → CI/CD → Variables → Minimum role to use pipeline variables at Maintainer or above (the self-managed default), or no_one_allowed (the default for new projects on GitLab.com). A pipeline variable outranks a project variable, so anyone who can set one can change the review job's environment. AZURE_API_BASE, for example, tells lgtmaybe where to send the key when the job passes no --api-base.

Pipeline Execution Policies

A pipeline execution policy (GitLab Ultimate) can add the lgtmaybe job to every pipeline from a separate security policy project. A merge request then cannot change that job. On its own it does not keep the secrets safe.

If the policy does not replace the project's configuration entirely (override_project_ci), the project's own .gitlab-ci.yml still runs from the merge request's branch. Its jobs receive the same project and group variables, or can name the lgtmaybe environment to get them. A policy that does replace the configuration has the same costs as the previous section.

Tokens and Keys

Whichever setup you choose, assume a secret can leak and limit what it is worth:

  • Make GITLAB_TOKEN a project access token on this project only, with:

    • the api scope (posting discussions needs it; read_api cannot);
    • the Developer role and nothing higher;
    • a short expiry.

    On GitLab.com, project access tokens need Premium or Ultimate. On the Free tier, use a personal access token of a dedicated account that is a Developer of this project alone. A leaked token can post as lgtmaybe. That includes a summary note whose watermark makes the next incremental run skip the commits before it. - Use a model key with a spend limit, so a leaked key has a ceiling.

lgtmaybe itself never checks out or executes merge request code. It reads the diff through the API and treats every byte of it as untrusted input. That is why the example job sets GIT_STRATEGY: none and does not clone the repository at all.