Skip to content

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