# GitLab Webhook Integration

Go-Claw agents can automatically process and respond to GitLab events such as push, pipeline, and merge request triggers.

There are three ways to connect a project:

- **Ask the agent** (`gitlab_hook`): an admin describes it in a conversation, and the agent
  creates the project webhook in GitLab and the route in GoClaw, each event running a SOP. See
  [Asking the agent for a hook](#asking-the-agent-for-a-hook).
- **The `goclaw gitlab hook setup` wizard**: five questions in a terminal configure both sides.
- **By hand**: see [Alternative Manual Setup](#alternative-manual-setup).

---

## Table of Contents

1. [Asking the agent for a hook](#asking-the-agent-for-a-hook)
2. [Prerequisites](#prerequisites)
3. [1-Minute Connection (CLI Wizard)](#1-minute-connection-cli-wizard)
4. [Command Reference](#command-reference)
5. [How It Works](#how-it-works)
6. [Events](#events)
7. [Usage Examples](#usage-examples)
8. [Security](#security)
9. [Troubleshooting](#troubleshooting)
10. [Alternative Manual Setup](#alternative-manual-setup)

---

## Asking the agent for a hook

An admin asks in a conversation, and the agent's `gitlab_hook` tool does the rest: it creates a
webhook in the GitLab project for the events you name, and the route `/goclaw/hook/gitlab/<key>`
in GoClaw. Each of those events then runs a SOP with the event as its input. Removing the hook
removes both.

```mermaid
flowchart LR
    GL["<b>GitLab Project Webhook</b><br/>Only the events asked for<br/>X-Gitlab-Token = route secret"] -->|"merge request / pipeline / push …"| GW["<b>/goclaw/hook/gitlab/&lt;key&gt;</b>"]
    GW --> SOP["<b>SOP Run on the Agent</b>"]
```

### Before you start

| What | Where |
| :--- | :--- |
| The GitLab instance, with `allow_write: true` | `tools.gitlab` (or `tools.gitlabs.<name>`): the **Integrations** page or `config.yaml` |
| A token with the **Maintainer** (or Owner) role on the project and the `api` scope | the instance's `token` |
| An address GitLab can reach the gateway at | `gateway.ingress.url`, and a listener there |
| A SOP for the events to run | the SOPs page, or the agent's `sop_save` tool |

The GitLab host needs no entry on `tools.http.allow`: the GitLab tools may reach the instance you
configured.

**GitLab and local addresses.** GitLab refuses a webhook to a loopback or private address
(`10.x`, `192.168.x`, a name without dots) unless its administrator allows it: **Admin › Settings ›
Network › Outbound requests › Allow requests to the local network from webhooks and
integrations**, or the host on the allowlist below it. The plan says so when the gateway's
address is such an address; GitLab's refusal is passed on with the same advice.

### What you can say

```
Make merge requests in group/backend run the mr-review SOP.
Pipelines on group/backend should run pipeline-triage. Call the hook "Backend pipelines".
Run release-notes on pushes to release/* in group/app.
```

The agent answers with a plan, and nothing is created until you confirm it in your next message:

```
Plan 3f9a1c20 — nothing is created yet.

GitLab "default" (version 17.5.1-ee), project group/backend, gets a webhook "Backend MR review":
- on merge_request events
- GitLab calls https://bot.example.com/goclaw/hook/gitlab/backend-mr-review, with a new secret only this route knows (X-Gitlab-Token)
GoClaw: each event runs SOP "mr-review" (MR review) as agent main.
```

Events: `merge_request`, `pipeline`, `push`, `tag_push`, `issue`, `note`, `job`, `deployment`,
`release`, `wiki`. `branch_filter` (a wildcard such as `main` or `release/*`) narrows push events.
Confidential issues and comments are never sent.

The plan is refused, with the reason, when the project does not exist for the token, the
token's role is below Maintainer, the project already has a webhook to that route, the route key
is taken, or the instance does not allow writes.

### Listing, testing, removing

- **list**: every hook this gateway registered, the project, its events, whether GitLab still has
  it and whether GitLab has disabled it after failed deliveries, and how many events arrived.
- **test** (GitLab 16.11 or later): GitLab sends a test delivery. GitLab's test is a real-looking
  event, so it is sent for an event the hook does not run on: the route counts it and runs
  nothing. On older GitLab, use **Test** in the project's **Settings › Webhooks**.
- **delete**: planned and confirmed like a create; removes the GitLab webhook and the route.
  Deleting the route on the **Webhooks** page removes the GitLab webhook too.

Only a human admin can do this, in a conversation of their own: never a SOP, cron, webhook or
subagent run, whose inputs are text anyone can write. The `gitlab` tool itself only reads hooks
(`hooks_list`, `hook_get`).

---

## Prerequisites

For the wizard to work, the following **3 items** must be configured in `config.yaml`:

```yaml
# 1. Gateway public HTTPS URL — destination address for GitLab webhooks
gateway:
  ingress:
    type: "proxy"                   # or "nginx" / "ngrok"
    url: "https://bot.example.com"

# 2. GitLab host + PAT (api scope) — used by the wizard to call the GitLab API
tools:
  gitlab:
    url: "https://gitlab.com"       # or your self-hosted instance
    token: "${secret:GITLAB_PAT}"
    allow_write: true               # only for the agent's own writes (comment_add, …); the wizard does not need it

# 3. Default agent (router/main) — receives events
```

Store the PAT in the vault:

```bash
$ goclaw secret set GITLAB_PAT glpat-xxxxxxxxxxxxxxxxxxxx
```

---

## 1-Minute Connection (CLI Wizard)

```bash
$ goclaw gitlab hook setup
```

The wizard will ask 5 questions and perform the **following actions automatically**:

- ✅ Generates a random 16-byte secret (no human input required)
- ✅ Registers the webhook via the GitLab REST API
- ✅ Registers the inbound route in the Go-Claw database
- ✅ Stores the secret in the encrypted vault
- ✅ Automatically rolls back changes if any step fails

### Wizard Flow

```
  ╔════════════════════════════════════════════╗
  ║   GoClaw GitLab Webhook Setup Wizard       ║
  ╚════════════════════════════════════════════╝

  ── Step 1: Gateway Public URL ──
  Gateway public URL [https://bot.example.com]: ↵

  ── Step 2: GitLab Instance ──
  GitLab host [https://gitlab.com]: ↵
  ✓ Using PAT from tools.gitlab.token
  ✓ Connected to GitLab 16.9

  ── Step 3: Project ──
  Project path (e.g. group/repo): group/payments-api
  ✓ Project found: group/payments-api (ID: 42)

  ── Step 4: Events ──
  Select events (space-separated numbers, or 'all'):
    [1] push          [2] tag_push     [3] merge_request
    [4] pipeline      [5] issue        [6] note
    [7] wiki          [8] deployment   [9] release
  Choice [1 3 4]: ↵
  ✓ Selected: push, merge_request, pipeline

  ── Step 5: Target Agent ──
  Which agent should receive events? [main]: ↵

  ── Step 6: Creating Webhook ──
  ⚙ Webhook URL: https://bot.example.com/goclaw/hook/gitlab/group-payments-api
  ⚙ Secret: glsec_a7b3…  (auto-generated)
  ✓ Webhook created in GitLab (hook_id=123)
  ✓ Route registered in GoClaw (db_id=5)
  ✓ Secret stored in vault as GITLAB_WH_GROUP_PAYMENTS_API

  ✅ Setup complete!
```

**Notes:**

- Pressing Enter accepts the default value in brackets.
- `[1 3 4]` corresponds to the numeric options `push, merge_request, pipeline`. You can type `all` to select all events.
- The secret is **not shown in plain text**. `goclaw secret get GITLAB_WH_<KEY>` prints it later (here `goclaw secret get GITLAB_WH_GROUP_PAYMENTS_API`); `goclaw secret list` shows it masked.

---

## Command Reference

```bash
# Register a new webhook (wizard)
goclaw gitlab hook setup

# List all GitLab webhooks in a table
goclaw gitlab hook list

# Send a test event via GitLab API
goclaw gitlab hook test <key>                  # default: push_events
goclaw gitlab hook test <key> --trigger issues_events

# Remove
goclaw gitlab hook remove <key>                # deletes from both sides
goclaw gitlab hook remove <key> --keep-route   # deletes only from GitLab
goclaw gitlab hook remove <key> --keep-gitlab  # deletes only from Go-Claw DB
```

### `list` Output Example

```
KEY                 AGENT   EVENTS                       ENABLED  HITS  LAST HIT              ERROR
group-payments-api  main    push,merge_request,pipeline  yes      42    2026-04-15 14:23 UTC  —
backend-api         devops  pipeline                     yes      8     2026-04-15 09:10 UTC  —
frontend-web        main    push                         no       0     —                     —
```

---

## How It Works

```mermaid
flowchart TD
    GL["<b>GitLab Project</b>"] -->|POST| EP["<b>/goclaw/hook/gitlab/&lt;key&gt;</b><br/>GoClaw gateway"]
    EP --> RT["<b>Webhook Router</b><br/>1. Verify X-Gitlab-Token<br/>2. Check if event matches filters<br/>3. Add summary to agent session<br/>4. Return 200 OK once the event is taken"]
    RT --> BG["<b>Agent Processes in Background</b>"]
    BG --> SESS["<b>Saved in Session</b><br/>webhook:&lt;agent&gt;:gitlab:&lt;object&gt;"]
```

The events of one object share a session: `webhook:<agent>:gitlab:<object_kind>-<id>`
(for example `merge_request-1234`, with the object's ID, not its `!42` number), or
`proj-<project id>` for an event about no single object, such as a push. A comment belongs
to what it hangs off: a note on an issue is in `issue-<issue id>`, a note on a merge request
in `merge_request-<id>`, and a work item's events are in `issue-<id>` too, so an issue's edits
and its comments share one session. A route that runs a SOP logs what started its runs in
`webhook-sop:<agent>:gitlab:<sop>:<object>` instead, and the runs of one object take turns
(see [Runs started by a webhook](sop.md#runs-started-by-a-webhook)).

### What the agent is told

An agent driven by a webhook sees a summary, not the raw payload. When the route
is bound to a SOP, each step of its run is shown the JSON as data from outside —
in a section marked untrusted, never as the SOP's own context — up to 16 KB of
each event and 48 KB in all. The summary names the object, so a skill does not
have to go looking for it:

```
🔔 Webhook received: type=gitlab key=group-payments-api event=merge_request
Project: group/payments-api
MR: !42
Title: Add rate limiting to the refresh endpoint
Action: update
State: opened
Draft: yes
Branches: feat/rate-limit → main
SHA: a1b2c3d4
URL: https://gitlab.example.com/group/payments-api/-/merge_requests/42
Labels: Status::Review required, Type::Feature
```

| Event | What the summary carries |
| :--- | :--- |
| `merge_request` | MR iid, title, action, state, draft flag, source → target branch, last commit SHA, URL, labels |
| `issue` | issue iid, title, action, state, labels |
| `note` | which MR or issue the comment hangs off, and its author |
| `pipeline` | pipeline id, status, ref, SHA, and the MR iid when the pipeline belongs to one |
| `push` / `tag_push` | ref, checkout SHA, commit count, who pushed |

The SHA is shortened to 8 characters — the form used in comment markers and in
`git log --oneline`. An event whose `object_kind` is not modelled falls back to
printing `ref` and `message`.

**Route Lookup Precedence:**

1. A route for the exact key comes before a catch-all (a route of the type without a key).
2. Among those, a stored route (registered via the Web UI or the CLI wizard) comes before a
   `config.yaml` `webhooks:` route.

A paused route keeps its deliveries: they get 404, not the catch-all. Every key a route has
(`sop_id`, `batch_mode`, `token`, …) is in [Webhook Routes](../configuration.md#webhook-routes).

**What the sender is told:**

| Status | When |
| :--- | :--- |
| `200` | The event was taken: a SOP run started (or queued, for a bulk route), or the agent will answer it. `{"status":"filtered"}` when the route's event filter leaves it out, `{"status":"duplicate"}` when the route took this delivery before. |
| `401` | The token or signature does not match the route's secret. |
| `403` | The sender's address is not in `gateway.ingress.allow_ips` (see [Security](#security)). |
| `404` | No route for the type and key, or the route is paused. |
| `409` | The route's SOP cannot run: it was deleted or disabled, or it is overdue for review. On a route without a SOP, the route's agent is not running on this gateway: it is not in `agents.list`, was added since the gateway started, or its model failed to start. |
| `500` | The SOP run could not be started; send it again. |
| `413` | The payload is larger than 4 MB. |
| `429` | With `gateway.rate_limit` on, the sender's address made too many requests in a minute (`Retry-After: 60`). |
| `503` | Too many deliveries are in progress (`Retry-After: 30`), another request is taking this same delivery right now, or the delivery's ID could not be recorded (both `Retry-After: 10`). |

**A delivery sent again runs once.** For a week, the route remembers the ID the sender gave
each delivery it took:
- GitLab's `Idempotency-Key` (GitLab 17.4 and later; it stays the same when GitLab retries),
  else its `X-Gitlab-Event-UUID`;
- GitHub's `X-GitHub-Delivery`;
- a Graylog event's ID;
- an `Idempotency-Key` header from any other sender.

A delivery with an ID the route took before is answered `{"status":"duplicate"}` and runs
nothing. A delivery the route did not take (401, 409, 500, 503) runs when it is sent again (a
503 for a delivery another request was taking is a repeat or runs, as that request ended), and
so does one it took and then dropped without running it, as the route shows on the Webhooks
page (no agent came free in time, a bulk batch whose SOP refused to run). On a route to every
agent, a chat delivery runs again only if no agent ran it, and a bulk one does not.

On a route that runs a SOP, a restart does not change that. A delivery counts as taken only once
what it started is on record: its SOP run, or its events on a bulk route's queue. One the gateway
was taking when it stopped is settled when it starts again: a resend runs, unless its SOP run had
begun a step (or its events are queued); on a route for every agent, the resend runs only the
agents whose run had not begun. A bulk batch is marked with its run before the run starts and
stays on the queue until the run begins a step, so a batch whose run had begun is not run a
second time after a restart, and one whose run had not — still waiting for a free SOP slot when
the gateway stopped, or until the wait timed out — runs. A single delivery whose SOP run was
still waiting for a slot was answered `200`, so its sender does not send it again, and it is
lost. A route without a SOP hands the delivery to the agent's turn, which is not on record:
a turn still waiting or running when the gateway stops is lost.

Every refusal a route answers, and an event dropped after it was taken (no worker came free in
time), is shown next to the route on the Webhooks page, except the 503 for a delivery another
request is taking. A 403, 413 or 429 is answered before any route is looked up, a 404 for an
unknown key has no route to show it on, and a route in `config.yaml` records nothing there:
look for those in the gateway log. Deleting a route set up by `goclaw gitlab hook` there
also removes its webhook in the GitLab project, or says why it could not.

---

## Events

CLI event name → GitLab API field → Incoming `X-Gitlab-Event` header. A route's event filter
can use any of the three spellings: they are compared as the CLI name, so `merge request`,
`Merge Request Hook` and `merge_requests_events` all mean `merge_request`.

| CLI Name | GitLab API Field | X-Gitlab-Event Header |
|----------|------------------|-----------------------|
| `push` | `push_events` | `Push Hook` |
| `tag_push` | `tag_push_events` | `Tag Push Hook` |
| `merge_request` | `merge_requests_events` | `Merge Request Hook` |
| `pipeline` | `pipeline_events` | `Pipeline Hook` |
| `issue` | `issues_events` | `Issue Hook` |
| `note` | `note_events` | `Note Hook` |
| `wiki` | `wiki_page_events` | `Wiki Page Hook` |
| `deployment` | `deployment_events` | `Deployment Hook` |
| `release` | `releases_events` | `Release Hook` |

---

## Usage Examples

### 1. Notify on Pipeline Failure

`goclaw gitlab hook setup` → select only the `pipeline` event. Add the following to the agent's system prompt:

> "When a pipeline event is received from the webhook: if `status=failed`, immediately raise an alert, include the assignee's name, and summarize the failed job's logs."

### 2. Code Review on Push Events

`push` + `merge_request` events. The agent, with the `gitlab` tool:
- Reads diffs with the `mr_changes` action
- Submits feedback as comments with the `comment_add` action (needs `allow_write: true` on the GitLab instance)

### 3. Multiple Projects — Single Agent

Run `setup` once for each project. They will be registered in the database as follows:

```
KEY                AGENT    EVENTS
backend-api        devops   pipeline
frontend-web       devops   pipeline
infra-terraform    devops   merge_request,pipeline
```

All events route to the same agent. If you set `agent=*`, they will be broadcast to all agents.

---

## Security

**1. Random secret + vault.** The wizard generates a 16-byte `glsec_*` secret and stores it in the encrypted vault. No plain-text secrets are stored in the configuration file.

**2. HTTPS enforced.** The wizard rejects any URL that does not start with `https://`. By default, GitLab also rejects plain HTTP webhooks.

**3. Event filtering.** By excluding unnecessary events from the database `events` field, you:
- Save tokens
- Prevent agent sessions from cluttering
- Focus strictly on relevant events

**4. GitLab IP allowlist (optional).** Restrict requests by adding GitLab.com's IP range to `gateway.ingress.allow_ips`:

```yaml
gateway:
  ingress:
    allow_ips:
      - "34.74.90.64/28"   # GitLab.com SaaS
      - "10.0.0.0/8"       # On-prem
```

`allow_ips` replaces `allow_preset`: with a list here, a preset such as `microsoft-teams` no
longer applies, so add the addresses of every other sender that reaches the gateway this way.
Requests from the gateway's own machine (loopback) are always let through.

**5. Secret rotation (every 3-6 months):**

```bash
$ goclaw gitlab hook remove group-payments-api   # clean up the old one
$ goclaw gitlab hook setup                        # recreate with a new secret
```

---

## Troubleshooting

Start where GitLab shows the answer: **Settings → Webhooks → Recent events**, then the
request's **Response body**. GoClaw says there why it refused a delivery, and records the
same reason on the route: the **Health** column of **Automation → Webhooks** shows it.

### 401 Unauthorized

The response names the cause:

| Response says | Cause | Fix |
|---|---|---|
| `the X-Gitlab-Token header is missing` | The GitLab webhook has no **Secret token**. | Edit the webhook in GitLab and enter the route's secret as its Secret token. |
| `X-Gitlab-Token does not match this route's secret` | GitLab sends a different secret. | Enter the route's secret in GitLab, or set the route's secret to what GitLab has. |
| `this route has no secret` | The route was saved without one. | Edit the route and enter a secret (or create it again: an empty secret is generated). |
| `…a ${secret:…} reference with no value in the vault` | The route's secret points at a vault key that does not exist. | Add the key with `goclaw secret set`, or enter the secret itself. |

One route answers one secret. A route with no **key** answers every
`/goclaw/hook/gitlab/<anything>` URL, so every project that uses it must send that same
secret. To give projects their own secrets, create a route per project with its key.

### 404 Not Found

| Response says | Cause | Fix |
|---|---|---|
| `no active webhook route for gitlab/<key>` | A route matches, but it is paused. | Enable it on the Webhooks page (▶). |
| `no webhook route for gitlab/<key>` | No route has that key. | Check the key in the URL; the gateway log lists the keys that exist. |
| An HTML page, or nothing from GoClaw | The request reached the Web UI port, not the webhook listener. | When `gateway.ingress.port` is set, webhooks are served only on that port. The Webhooks page shows both ports. |

`webhook_secret_slug` only applies to channel webhooks (`/hook/<slug>/msteams`), not to
`/goclaw/hook/…` routes.

### "no public URL available"

The wizard stops if `gateway.ingress.url` is missing. Add it to the config and try again.

### Hit count remains 0

- Is the route paused? The Webhooks page shows it, and a delivery to it is answered 404.
- Do the route's **Events** include what GitLab sends? A filtered event is answered
  `200 {"status":"filtered"}` and not counted.
- Check **Recent events** in GitLab for the status and response body.

### No events received from GitLab

1. GitLab Settings → Webhooks → Recent events.
2. The status of recent calls:
   - `200` ✓
   - `404` → see 404 above
   - `401` → see 401 above
   - `5xx` → check lines matching `[webhook]` in the GoClaw log

---

## Alternative Manual Setup

If you cannot run the CLI wizard (e.g., headless server without SSH access):

**A. Register the route on the Go-Claw side** — via the WebUI or in config.yaml:

WebUI → **Automation → Webhooks** → **+ New Webhook** (Type: gitlab, Key, Agent, Events). Leave
**Secret** empty to have one generated: after saving, the page shows the URL and the secret to
enter in GitLab, and the secret is not shown again. When you edit a route later, an empty
Secret field keeps the current one.

Or in `config.yaml`:

```yaml
webhooks:
  - type: gitlab
    key: myproject
    secret: "${secret:GITLAB_WEBHOOK_MYPROJECT}"
    agent: main
    events: [push, pipeline, merge_request]
```

The gateway reads the change when it reloads `config.yaml`; no restart is needed. Save the secret using the `goclaw secret set` command.

Every route key — `sop_id` to run a SOP per event, `batch_mode: bulk` and `batch_window` to run one
for a window of events, `token` — is described in [Webhook Routes](../configuration.md#webhook-routes).
Routes for other senders work the same way; for Graylog alerts, see
[Graylog Alerts That Run a SOP](graylog-alerts.md).

**B. Register the webhook on the GitLab side** — from the GitLab UI:

GitLab Project → **Settings → Webhooks**:

| Field | Value |
|--------|------|
| URL | `https://bot.example.com/goclaw/hook/gitlab/myproject` |
| Secret Token | (Must **exactly match** the secret configured on the Go-Claw side) |
| Trigger | Push / Merge / Pipeline (select what you need) |
| Enable SSL verification | ✅ |

> ⚠️ The secret must be **exactly the same** on both sides. A mismatch of even a single character will cause a 401 response for incoming calls from GitLab. The CLI wizard handles this alignment automatically.

---

## Related Documents

- [Configuration](../configuration.md) — all config.yaml options; [Webhook Routes](../configuration.md#webhook-routes) for the `webhooks:` keys
- [Graylog Alerts That Run a SOP](graylog-alerts.md) — the same receiver, called by Graylog alerts
- [Gateway Endpoints](../../api-reference/gateway_endpoints.md#webhook-routes-api) — `/api/webhooks` and the receiver's status codes
- [Ingress](../ingress.md) — Public URL, proxy, and ngrok configuration
- [Tools & MCP](../tools_and_mcp.md) — `gitlab` tool actions
- [SOP Automation](sop.md) — Map events to multi-step workflows
