GitLab agents
A GitLab agent wakes on merge request, note, and pipeline events from
GitLab.com or a self-managed server, then writes its results back to the
project. Declare onMergeRequest, onNote, or onPipeline on
gitlabChannel() for each event that should wake it.
Connect a project
Name the project hook secret and list the projects that may wake the
agent. Set apiBaseUrl for a self-managed server, and leave it out for
GitLab.com.
// bot/channels/gitlab.tsimport { gitlabChannel } from "@grok/agent-sdk/channels/gitlab";export default gitlabChannel({ projects: ["acme/platform"], webhookSecret: { secretName: "GITLAB_WEBHOOK_SECRET" }, apiBaseUrl: "https://gitlab.example.com/api/v4",});Add a project webhook for
https://<your-host>/<slug>/v1/channels/gitlab with the same secret. On
Cursor hosting, a delivery with the wrong secret gets a 401 and never
reaches your agent. ctx.gitlab calls the API with the token in
GITLAB_TOKEN. To set both secrets on a deployment, see
Add egress and secrets.
For GitLab.com on Cursor hosting, you can skip the project webhook. Set
cursorAccount: { projects: ["acme/platform"] }, and events and
ctx.gitlab calls go through your signed-in Cursor account.
Review a merge request
Read the latest diff, then post the finished review as a note:
// bot/channels/gitlab.tsimport { createGitLabApi, defaultGitLabAuth, gitlabChannel,} from "@grok/agent-sdk/channels/gitlab";import { postMergeRequestNote } 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 helper you write that posts through the GitLab API with GITLAB_TOKEN. It uses the project and merge request stored from the event.
Answer a note
Skip the agent's own notes, and answer a note that mentions the agent:
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, };},Add this property to the same gitlabChannel() as the review handler.
Triage a failed pipeline
When a pipeline fails, the example marks the commit status as running
and starts a triage turn. When the turn finishes, it sets the status to
success or failure.
// bot/channels/gitlab.tsimport { defaultGitLabAuth, gitlabChannel, gitlabChannelStateForEvent,} from "@grok/agent-sdk/channels/gitlab";import { setPipelineTriageStatus } from "../lib/gitlab.js";export default gitlabChannel({ projects: ["acme/platform"], webhookSecret: { secretName: "GITLAB_WEBHOOK_SECRET" }, onPipeline: (ctx, pipeline) => { if (pipeline.status !== "failed" || pipeline.sha === undefined) { return null; } const sha = pipeline.sha; return { // `task` runs host code, and `send` starts the model turn. 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), state: { ...gitlabChannelStateForEvent({ project: ctx.project, headSha: sha, eventType: ctx.eventType, }), }, }); }, }; }, events: { async "message.completed"(event, channel, ctx) { if ( ctx.session.purpose === "eval" || channel.state.eventType !== "pipeline" || channel.state.headSha === null || event.data.finishReason === "tool_call" ) { return; } await setPipelineTriageStatus(channel.state.projectPath, channel.state.headSha, "success"); }, 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"); }, },});setPipelineTriageStatus is a small helper you write in
bot/lib/gitlab.ts. It updates the same cursor/pipeline-triage status
through the GitLab API with GITLAB_TOKEN, using the project and commit
from the session state, so the model never picks where it writes.
Each hook returns { auth }, { task }, or null, as
handler results describes.
Every event and note on one merge request resumes the same session.
Test with replay
Replay a merge request you can read against your local agent, or save the
payloads as fixtures.
gitlab replay lists every
flag.
agent-sdk devagent-sdk gitlab replay acme/platform!42 --events pipeline --conclusion failedagent-sdk gitlab replay acme/platform!42 --events '*' --dry-run --fixtures-dir fixtures/gitlab