Appearance
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.
ts
// bot/channels/gitlab.ts
import { 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:
ts
// bot/channels/gitlab.ts
import {
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:
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,
};
},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.
ts
// bot/channels/gitlab.ts
import {
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.
text
agent-sdk dev
agent-sdk gitlab replay acme/platform!42 --events pipeline --conclusion failed
agent-sdk gitlab replay acme/platform!42 --events '*' --dry-run --fixtures-dir fixtures/gitlab