# GitLab agents

A GitLab agent wakes from a verified project event, reads the current
project or merge-request state, and writes the result back to GitLab.
`gitlabChannel()` supports the same hooks on GitLab.com and self-managed
GitLab.

## Review a merge request

Opening, reopening, or pushing commits to a merge request starts a
review. The hook reads the latest diff through the project-bound GitLab
client, the model returns a verdict, and the completed answer appears
as a note on the merge request.

```ts
// bot/channels/gitlab.ts
import {
  createGitLabApi,
  defaultGitLabAuth,
  gitlabChannel,
  gitlabChannelStateForEvent,
} from "@grok/agent-sdk/channels/gitlab";
import {
  postMergeRequestNote,
  setPipelineTriageStatus,
} from "../lib/gitlab.js";

export default gitlabChannel({
  projects: ["acme/platform"],
  onMergeRequest: async (ctx, mergeRequest) => {
    if (mergeRequest.draft) {
      return null;
    }
    const hasNewCommits =
      mergeRequest.action === "update" && mergeRequest.oldrev !== undefined;
    if (
      mergeRequest.action !== "open" &&
      mergeRequest.action !== "reopen" &&
      !hasNewCommits
    ) {
      return null;
    }

    const api = createGitLabApi(
      ctx.gitlab,
      ctx.project.pathWithNamespace
    );
    const diffs = await api.listMergeRequestDiffs(mergeRequest.iid);
    return {
      // `auth` starts a model turn running as the event sender.
      auth: defaultGitLabAuth(ctx),
      message: `Review ${ctx.project.pathWithNamespace}!${mergeRequest.iid}.`,
      context: [`<gitlab_diffs>${JSON.stringify(diffs)}</gitlab_diffs>`],
    };
  },
  events: {
    async "message.completed"(event, channel, ctx) {
      if (ctx.session.purpose === "eval") {
        return;
      }
      if (event.data.finishReason === "tool_call" || event.data.text === "") {
        return;
      }
      const mergeRequestIid = channel.state.mergeRequestIid;
      if (
        (channel.state.eventType !== "merge_request" &&
          channel.state.eventType !== "note") ||
        mergeRequestIid === null
      ) {
        return;
      }
      await postMergeRequestNote(
        channel.state.projectPath,
        mergeRequestIid,
        event.data.text
      );
    },
  },
});
```

`postMergeRequestNote` is a small wrapper around the GitLab REST API.
Configure it with the same API token and base described under
[Connect](#connect). It uses the project and MR IID stored from the
verified event, so the model never chooses where its review is posted.

## Answer a note

Mentioning `@cursor-reviewer` in an MR note resumes that merge
request's conversation, and the reply sink from the first example posts
the answer back to the same MR. The author guard drops notes written by
the agent itself, preventing each reply from starting another turn.

```ts
const AGENT_USERNAME = "cursor-reviewer";

onNote: (ctx, note) => {
  if (note.author?.username === AGENT_USERNAME) {
    return null;
  }
  if (!note.body.includes(`@${AGENT_USERNAME}`)) {
    return null;
  }
  return {
    auth: defaultGitLabAuth(ctx),
    message: note.body,
  };
},
```

`onNote` receives merge-request notes by default. Add this property to
the same `gitlabChannel()` declaration as the review handler.

## Triage a failed pipeline

When a pipeline fails, GitLab immediately shows a running
`cursor/pipeline-triage` status on the commit. The webhook is
acknowledged while the model investigates, then the status changes to
success or failure based on whether that triage turn completed.

```ts
onPipeline: (ctx, pipeline) => {
  if (pipeline.status !== "failed" || pipeline.sha === undefined) {
    return null;
  }
  const sha = pipeline.sha;

  return {
    // `task` runs this host sequence after the webhook is acknowledged.
    task: async ({ send }) => {
      await ctx.gitlab.setCommitStatus({
        sha,
        state: "running",
        name: "cursor/pipeline-triage",
        description: "Investigating failed pipeline",
      });

      await send(
        `Triage failed GitLab pipeline ${pipeline.id}.`,
        {
          auth: defaultGitLabAuth(ctx),
          title: `GitLab pipeline ${pipeline.id}`,
          state: {
            ...gitlabChannelStateForEvent({
              project: ctx.project,
              headSha: sha,
              eventType: ctx.eventType,
            }),
          },
        }
      );
    },
  };
},
```

Extend the `events` map so the turn's terminal event concludes the
status through the same provider wrapper:

```ts
async "message.completed"(event, channel, ctx) {
  if (
    ctx.session.purpose !== "eval" &&
    channel.state.eventType === "pipeline" &&
    channel.state.headSha !== null &&
    event.data.finishReason !== "tool_call"
  ) {
    await setPipelineTriageStatus(
      channel.state.projectPath,
      channel.state.headSha,
      "success",
      "Pipeline triage complete"
    );
    return;
  }

  // Continue with the merge-request reply branch from the first example.
},
async "turn.failed"(_event, channel, ctx) {
  if (
    ctx.session.purpose === "eval" ||
    channel.state.eventType !== "pipeline" ||
    channel.state.headSha === null
  ) {
    return;
  }
  await setPipelineTriageStatus(
    channel.state.projectPath,
    channel.state.headSha,
    "failed",
    "Pipeline triage failed"
  );
},
```

The status reports whether triage completed; it does not change the
failed pipeline's own result.

## Events

Use `onMergeRequest`, `onNote`, or `onPipeline` for the events shown
above. Each hook returns one of three decisions:

| Return | What happens |
| --- | --- |
| `{ auth }` | Start a model turn as the GitLab event sender |
| `{ task }` | Acknowledge the webhook, then run host code that may also call `send` |
| `null` | Skip the event |

The [Channels reference](/docs/reference/channels.md) covers the remaining
hook and session behavior.

## Merge request affinity

Every merge-request event and note for the same project path and MR IID
resumes one agent session. An open, a new commit, and a later mention
therefore share the same conversation.

## Call GitLab through `ctx.gitlab`

Each hook receives a GitLab client already bound to the project from
the verified delivery. Use `ctx.gitlab.request()` for REST calls,
`setCommitStatus()` for one status update, or `createCheck()` when you
need a status handle that changes throughout longer work.

The project does not come from model output. Keep API calls on this
bound client so the event decides where reads and writes go.

## Connect

On Cursor-managed hosting, use the signed-in Cursor account and list
the GitLab.com projects that may wake the agent:

```ts
export default gitlabChannel({
  cursorAccount: {
    projects: ["acme/platform"],
  },
});
```

`cursorAccount` covers event delivery and API calls made through
`ctx.gitlab` inside hooks. The `postMergeRequestNote` and
`setPipelineTriageStatus` wrappers are separate REST clients, so
configure their GitLab API credential too.

For direct webhooks or self-managed GitLab, configure the project hook
secret, API token, and allowed projects. Add the API base for a
self-managed host:

```ts
export default gitlabChannel({
  projects: ["acme/platform"],
  webhookSecret: { secretName: "GITLAB_WEBHOOK_SECRET" },
  token: () => process.env.GITLAB_TOKEN ?? "",
  apiBaseUrl: "https://gitlab.example.com/api/v4",
});
```

Without `webhookSecret`, the direct production route stays
loopback-only. Setting it admits deliveries that carry the matching
project-hook secret; `token` authorizes the API reads and writes in the
examples.

Name the secret rather than reading it. Locally the channel reads that
environment variable, and on Cursor hosting the platform compares
`X-Gitlab-Token` with the deployment secret of that name at ingestion,
so a delivery with the wrong token gets a `401` before any compute
starts. `architecture: "v2"` requires the named form and `agent-sdk validate`
rejects a literal or `() => process.env.X` getter there; set the value
with `agent-sdk secret set <slug> GITLAB_WEBHOOK_SECRET`.

Register the project webhook at
`https://<your-host>/<slug>/v1/channels/gitlab` with the same secret.
For GitLab.com, omit `apiBaseUrl`.

## Test locally

Replay lets you exercise the same channel hooks with a merge request
you can read before registering a live project webhook.

```text
agent-sdk dev
agent-sdk gitlab replay \
  https://gitlab.example.com/acme/platform/-/merge_requests/42 \
  --events '*'
```

Use `--dry-run --out fixtures/gitlab` to save the payloads instead of
posting them. `GITLAB_TOKEN` needs read access to the merge request.

## Related

- [Channels reference](/docs/reference/channels.md): channel hooks,
  dispatch decisions, and session behavior
- [Webhooks and custom channels](/docs/guides/webhooks.md): direct webhook auth
  and reply delivery
- [Evals](/docs/evals.md): turn saved GitLab payloads into regression
  fixtures
