---
title: GitLab
description: Respond to @mentions in merge request and issue comment threads.
tagline: Build bots that respond to merge request and issue comment threads on GitLab.com or GitLab Self-Managed. Treats merge requests, issues, and discussions as threads, comments as messages.
package: @chat-adapter/gitlab
---

# GitLab



## Create a new bot

Scaffold a Next.js bot with the GitLab adapter using [`create-chat-sdk`](/docs/create-chat-sdk):

<CreateChatSdk adapters="gitlab memory" />

The generated app uses the [Memory state adapter](/adapters/official/memory) for local development. To add the adapter to an existing app, start at [Install](#install).

## Install

<PackageInstall package="@chat-adapter/gitlab @chat-adapter/state-memory" />

## Quick start

<Callout type="info">
  The adapter auto-detects credentials from `GITLAB_TOKEN` and either `GITLAB_WEBHOOK_SIGNING_TOKEN` or `GITLAB_WEBHOOK_SECRET`. Unless both are configured, it looks up the bot's username and user ID from the token during initialization.
</Callout>

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createGitLabAdapter } from "@chat-adapter/gitlab";
import { createMemoryState } from "@chat-adapter/state-memory";

export const bot = new Chat({
  userName: "my-bot",
  adapters: {
    gitlab: createGitLabAdapter(),
  },
  state: createMemoryState(),
});

bot.onNewMention(async (thread, message) => {
  await thread.post("Hello from GitLab!");
});
```

The memory state adapter keeps subscriptions and locks in process memory, which suits local development. Use [Redis](/adapters/official/redis) or [PostgreSQL](/adapters/official/postgres) in production.

Then create the webhook route:

```typescript title="app/api/webhooks/gitlab/route.ts" lineNumbers
import { after } from "next/server";
import { bot } from "@/lib/bot";

export async function POST(request: Request): Promise<Response> {
  return bot.webhooks.gitlab(request, {
    waitUntil: (task) => after(() => task),
  });
}
```

## Authentication

The adapter calls the GitLab REST API with one access token that has the `api` scope. The token's user is the bot: it authors every comment and reaction, and users @mention its username to get a response.

### Project or group access token

A project or group access token creates a dedicated bot user, so comments don't appear under a person's account.

1. In your project or group, go to **Settings** then **Access tokens** and select **Add new token**.
2. Select the **Developer** role or higher, and the `api` scope.
3. Select **Create project access token** (or **Create group access token**) and copy the token.
4. Set `GITLAB_TOKEN`.

GitLab names the bot user `project_{project_id}_bot_{random_string}` (or `group_{group_id}_bot_{random_string}`), and users mention that username. On GitLab.com, project and group access tokens require a Premium or Ultimate subscription.

### Personal access token

A personal access token suits personal projects, testing, or a dedicated service account.

1. Go to **User settings** then **Access tokens** and select **Add new token**.
2. Select the `api` scope and create the token.
3. Set `GITLAB_TOKEN`.

```typescript
createGitLabAdapter({
  token: process.env.GITLAB_TOKEN!,
});
```

### GitLab Self-Managed and Dedicated

Set `apiUrl`, or the `GITLAB_API_URL` environment variable, to your instance's REST API base URL:

```typescript
createGitLabAdapter({
  apiUrl: "https://gitlab.example.com/api/v4",
});
```

## Configuration

<TypeTable
  type={{
  token: {
    type: "string",
    description:
      "Access token with the `api` scope. Auto-detected from `GITLAB_TOKEN`.",
  },
  webhookSigningToken: {
    type: "string",
    description:
      "Webhook signing token used to verify the `webhook-signature` header. Use the `whsec_` value GitLab shows (the prefix is optional); the adapter throws at construction if the token is malformed. Auto-detected from `GITLAB_WEBHOOK_SIGNING_TOKEN`. Requires GitLab 19.0 or later.",
  },
  webhookSecret: {
    type: "string",
    description:
      "Webhook secret token, compared against the `X-Gitlab-Token` header. Auto-detected from `GITLAB_WEBHOOK_SECRET`.",
  },
  webhookVerifier: {
    type: "(request, body) => unknown | Promise<unknown>",
    description:
      "Custom verifier used in place of `webhookSigningToken` and `webhookSecret`. Takes precedence over both.",
  },
  userName: {
    type: "string",
    description:
      "Bot username for @mention detection. Auto-detected from `GITLAB_BOT_USERNAME`, then from the token's user, then defaults to `gitlab-bot`.",
  },
  botUserId: {
    type: "number",
    description:
      "Numeric ID of the bot user, used to ignore the bot's own comments. Auto-detected from `GITLAB_BOT_USER_ID`, then from the token's user. Recommended on serverless platforms.",
  },
  apiUrl: {
    type: "string",
    default: '"https://gitlab.com/api/v4"',
    description:
      "GitLab REST API base URL. Auto-detected from `GITLAB_API_URL`.",
  },
  logger: {
    type: "Logger",
    description: "Logger instance. Defaults to a console logger.",
  },
}}
/>

`token` is required, plus one of `webhookSigningToken`, `webhookSecret`, or
`webhookVerifier`.

### Environment variables

| Variable                       | Required                   | Description                                                                                                                        |
| ------------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `GITLAB_TOKEN`                 | Yes                        | Personal, project, or group access token with the `api` scope.                                                                     |
| `GITLAB_WEBHOOK_SIGNING_TOKEN` | One webhook token required | Webhook signing token (`whsec_...`) for HMAC signature verification. GitLab 19.0 and later.                                        |
| `GITLAB_WEBHOOK_SECRET`        | One webhook token required | Webhook secret token, compared against the `X-Gitlab-Token` header.                                                                |
| `GITLAB_BOT_USERNAME`          | No                         | Bot username for @mention detection. Defaults to the token user's username.                                                        |
| `GITLAB_BOT_USER_ID`           | No                         | Numeric bot user ID for self-message detection. Defaults to the token user's ID; recommended on serverless to prevent reply loops. |
| `GITLAB_API_URL`               | No                         | REST API base URL for GitLab Self-Managed or Dedicated. Defaults to `https://gitlab.com/api/v4`.                                   |

## Webhooks

Add a project or group webhook. You need the Maintainer or Owner role on the project, or the Owner role on the group.

1. Go to **Settings** then **Webhooks** and select **Add new webhook**.
2. Set **URL** to `https://your-domain.com/api/webhooks/gitlab`.
3. Select **Generate signing token** and set `GITLAB_WEBHOOK_SIGNING_TOKEN` to the `whsec_` value. GitLab shows the token only once, so copy it before you leave the page. Signing tokens are available from GitLab 19.0. On earlier versions, or on a 19.0 instance with the `webhook_signing_token` feature flag turned off, enter a **Secret token** instead and set `GITLAB_WEBHOOK_SECRET` to the same value.
4. Under **Trigger**, select **Comments**. Select **Emoji events** too if your bot handles reactions with `onReaction`.
5. Select **Add webhook**.

<Callout type="info">
  We recommend a signing token over a secret token. GitLab sends a secret token as plain text with every request, but it never sends the signing token. It uses the signing token to sign each payload, so the adapter can confirm that a request came from GitLab and wasn't modified.
</Callout>

GitLab sends a `webhook-signature` header when the webhook has a signing token, and an `X-Gitlab-Token` header when it has a secret token. If you configure both, it sends both headers, which lets you move from a secret token to a signing token without downtime. When a request has a `webhook-signature` header and the adapter has a signing token, the adapter verifies the signature and doesn't fall back to the secret token if the signature fails. Otherwise, it compares `X-Gitlab-Token` with the secret token, and rejects the request if no secret token is configured. Signed requests with a `webhook-timestamp` more than five minutes from the current time are rejected.

The adapter dispatches newly created comments on merge requests and issues. It ignores comment edits, system notes, and comments on commits and snippets. It also ignores internal notes and comments on confidential issues, so a public reply can't expose their content.

A comment counts as a mention when it contains `@` followed by the bot's username outside inline code, code blocks, and block quotes.

### Ignoring the bot's own comments

The adapter recognizes its own comments by the bot's numeric user ID, which comes from `botUserId`, `GITLAB_BOT_USER_ID`, or a `GET /user` call during initialization. If that call failed, the adapter retries it when a comment arrives, at most once every 30 seconds. While the ID is still unknown, the adapter compares usernames if the adapter's `userName` option or `GITLAB_BOT_USERNAME` is set. The `userName` passed to `Chat` doesn't count. If neither is known, it ignores the comment and logs a warning rather than risk replying to itself.

On serverless platforms, set `GITLAB_BOT_USER_ID` so self-detection doesn't depend on the `GET /user` lookup.

## Thread IDs

Every comment belongs to a GitLab discussion, including top-level comments, so every incoming comment maps to its discussion thread. The bot replies inside that discussion. Replying to a top-level comment turns it into a thread in GitLab's UI, and the reply lands in the same Chat SDK thread as the comment.

| Type          | Thread ID format                                      |
| ------------- | ----------------------------------------------------- |
| Discussion    | `gitlab:{projectId}:{mr\|issue}:{iid}:{discussionId}` |
| Merge request | `gitlab:{projectId}:mr:{iid}`                         |
| Issue         | `gitlab:{projectId}:issue:{iid}`                      |

Webhooks never produce merge request or issue thread IDs. You get them from `listThreads`, which returns the project's open merge requests, or by building one yourself. Posting to one creates a new top-level comment, and `fetchMessages` on it returns every comment on the merge request or issue, paged with a cursor. On a discussion thread, `fetchMessages` returns that discussion's comments. Both leave out system notes and internal notes, and `fetchMessage` returns `null` for an internal note, so a handler that replies from history can't repeat internal content in a public comment.

This model has two consequences:

* Subscribing to a thread started by a top-level comment doesn't catch other top-level comments on the same merge request. Each one starts its own thread.
* When the bot posts to a merge request or issue thread and someone replies to that comment, the reply arrives in a new discussion thread, not the thread the bot posted to.

The root message of a `listThreads` result is the merge request description (or its title when the description is empty), not a comment. Its ID is `mr-{iid}`, so reactions, edits, and deletes on it throw `ValidationError`, and `fetchMessage` returns `null`. `listThreads` pages through results with a cursor and returns at most 100 threads per page.

The channel ID for a project is `gitlab:{projectId}`.

## Messages

Strings and markdown, including streamed text, are posted as written apart from emoji placeholders, so GitLab references such as `~label`, `!12`, and `#12` render as links. Card and AST content is converted to GitLab Flavored Markdown.

Streaming is buffered: the adapter collects the whole stream and posts it as one comment. An empty stream throws a `ValidationError` because GitLab rejects empty comments.

`message.metadata.edited` is always `false`. GitLab updates a comment's `updated_at` when its discussion is resolved or turned into a thread, and the API doesn't expose when a comment was last edited.

GitLab API errors map to Chat SDK errors: 400 and 422 to `ValidationError` with GitLab's reason, 401 to `AuthenticationError`, 403 to `PermissionError`, 404 to `ResourceNotFoundError`, 429 to `AdapterRateLimitError`, and any other status to `NetworkError`.

## Reactions

The adapter maps SDK emoji to GitLab's canonical award emoji names. Most match Slack-style shortcodes, such as `rocket`, `eyes`, and `tada` for `party`. Incoming reactions are mapped back to SDK emoji the same way:

| SDK emoji     | GitLab emoji |
| ------------- | ------------ |
| `thumbs_up`   | `thumbsup`   |
| `thumbs_down` | `thumbsdown` |
| `heart`       | `heart`      |
| `party`       | `tada`       |
| `rocket`      | `rocket`     |
| `eyes`        | `eyes`       |

With **Emoji events** enabled on the webhook, reactions on comments reach `onReaction` handlers.

## REST API helper

Call any GitLab REST endpoint with the adapter's credentials through `request`:

```typescript
const gitlab = bot.getAdapter("gitlab");
const pipelines = await gitlab.request("GET", "/projects/42/pipelines", {
  query: { ref: "main" },
});
```

## Feature support

<FeatureSupport />
