Skip to main content

Command Palette

Search for a command to run...

BDK

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.

// bot/channels/gitlab.tsimport {  createGitLabApi,  defaultGitLabAuth,  gitlabChannel,  gitlabChannelStateForEvent,} from "@cursor/bdk/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. 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.

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.

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:

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:

ReturnWhat happens
{ auth }Start a model turn as the GitLab event sender
{ task }Acknowledge the webhook, then run host code that may also call send
nullSkip the event

The Channels reference 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:

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:

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 bdk validate rejects a literal or () => process.env.X getter there; set the value with bdk secrets 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.

bdk devbdk 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.