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:
| 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 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.
Related
- Channels reference: channel hooks, dispatch decisions, and session behavior
- Webhooks and custom channels: direct webhook auth and reply delivery
- Evals: turn saved GitLab payloads into regression fixtures