GitLab
A practical runbook for the target system GitLab (github.com/benjaminLedel/covey-plugin-pack/gitlab).
Structure and data flow follow the Zammad adapter
(ops-zammad.md) — the unit of work here is the
issue instead of the ticket.
Short version: token auth against the REST API (
/api/v4). The agent finds its issues itself (list_issues), driven by aHEARTBEAT.mdentry — GitLab takes up work purely by polling, there is no webhook (deliberately: with several agents per project a webhook setup is laborious and error-prone). It answers bug reports code-based:checkoutfetches the source into the sandbox, and a report is confirmed only with a location in the code (section 4). These are configuration steps, not a rebuild.
1. Overview of the data flow
Intake exclusively by heartbeat (polling). A HEARTBEAT.md entry puts the
task "review issues" into the agent's backlog periodically. The agent finds its
working set itself through the discovery actions:
covey ──(heartbeat tick)──► backlog task "review GitLab issues"
│
▼
agent (sandbox, Claude Code)
│ actions through the action proxy
GitLab ◄──(REST /api/v4)────────┘ list_issues → get_issue/list_notes → checkout → comment/set_state/escalate
No inbound traffic, no public URL, no webhook secret — it works even when covey runs behind NAT/a firewall.
No webhook for GitLab. Unlike Zammad, the GitLab plugin has no webhook intake (
/api/webhooks/gitlab/…answers with 404). The reason: a webhook would have to be set up per target project and would address a single agent — with several agents on the same projects that quickly becomes ambiguous and hard to maintain. GitLab is therefore operated entirely by polling; the review loop (section 2.7) likewise runs through the heartbeat instead ofblocked+wake. (The generic per-agent trigger/api/trigger/<token>for third-party systems is unaffected by this — it is not a target-system webhook.)
Auth (outbound only, covey → GitLab): REST with a brokered API token
(the secret gitlab_token) that is never persisted in the sandbox.
2. Step-by-step instructions
2.1 In GitLab: create a user + token
- Create a user of its own for the covey agent (e.g.
covey-bot) — do not use a person's token. All the agent's comments and commits run through this user; bylist_notesit recognises its own last state at the next run and does not work on anything twice. - Add the user to the target projects with the Reporter role (enough for comments including internal notes; for closing issues, Developer depending on the project setup).
- As that user create a personal access token with the scope
api— or a project access token per project (least privilege). Note the token down (it is shown only once).
2.2 In covey: deposit the secrets
Set per agent in the SecretStore (UI: agent page → Secrets, or via the API):
| Secret | Value | Purpose |
|---|---|---|
gitlab_url |
https://gitlab.example.com |
without /api/v4 — the client appends that |
gitlab_token |
the token from 2.1 | outbound auth (the PRIVATE-TOKEN header) |
anthropic_api_key or claude_code_oauth_token |
an API key or claude setup-token |
the runtime in the sandbox |
2.3 In covey: enable the target system
The target system gitlab has to be enabled for the org (UI: Target
systems). In addition the agent has to be allowed to access gitlab according
to its ACCESS.md, and the guard rails must not forbid gitlab /
gitlab:comment_external.
2.4 Setting up the intake by heartbeat
Create two separate entries in the agent's HEARTBEAT.md — one job each
(issue triage, MR review), each gated on its own:
- alle: 15m nur-wenn: gitlab:issues titel: GitLab-Issues sichten aufgabe: Finde offene Issues (list_issues state=opened), bearbeite neue und prüfe per list_notes, ob auf deine Rückfragen geantwortet wurde. Bei Bugs: Code per checkout holen und die Behauptung am Quelltext verifizieren.
- alle: 15m nur-wenn: gitlab:mr titel: Merge Requests betreuen aufgabe: Prüfe deine offenen Merge Requests (list_merge_requests state=opened) auf neues Review-Feedback (list_mr_notes), arbeite es ein und reagiere auf Merge bzw. Close.
Why two heartbeats with a sub-scope instead of one. Because the agent does
not block after create_merge_request but ends with done, a heartbeat has
to pick its open MRs back up by polling — the review loop is a job of its own.
The sub-scope after the colon (gitlab:issues or gitlab:mr) ensures that each
of the two heartbeats fires only for its own work:
nur-wenn: gitlab:issues→ only when an open issue in the intake scope is waiting for a reaction (see "edge instead of level" below).nur-wenn: gitlab:issues:assigned→ the same, but only for the issues assigned to the bot user (scope=assigned_to_me) — for agents whose playbook works exclusively on their own issues.nur-wenn: gitlab:mr→ only when one of the open merge requests opened by the bot itself has unanswered review feedback (the last non-system comment in the thread is not from the bot).nur-wenn: gitlab:review→ only when an MR in which the bot is entered as a reviewer is waiting for its review.
Without a sub-scope (nur-wenn: gitlab) a heartbeat checks both together —
but then two such heartbeats would each fire for the other's work as well (the
MR task would run on pure issue work and vice versa). The sub-scope avoids
exactly that waste; use nur-wenn: gitlab only when you deliberately want to
bundle both jobs into one task.
The merge completion needs no trigger of its own: if the associated issue is still open, it wakes through the issue heartbeat; if it was closed automatically on merge, there is nothing left to do.
Edge instead of level — and what that demands of the agent. An open issue or an open MR is not permanently "work". An item counts as work only as long as the last non-system comment is not from the bot (or there is none yet — then the first triage is outstanding). If the bot wrote last, the item rests until someone answers.
From that follows a contract the playbook has to keep: whoever has worked on
an issue comments there. A silent run leaves no edge, counts as unworked again
at the next interval and wakes the agent anew — at alle: 2m that is 30 runs
per hour on the same long-finished item. Exactly this level trigger was the
cause of an endless loop in practice, see
heartbeat intervals.
The advance check is cheap (a few REST calls: open issues or your own open MRs and their notes) — negligible compared with an LLM turn.
The agent then discovers its working set itself: list_projects delivers the
projects the bot user is a member of, list_issues the open issues (without
project_id: all the ones the token may see). So that recurring runs do not
work on anything twice, the agent checks by list_notes whether its own
comment is already the last state — the plugin's prompt documentation points it
at that.
An optional project filter (it takes effect for list_issues/list_projects
and the nur-wenn: advance check):
COVEY_GITLAB_INTAKE_PROJECTS="group/support" # empty = all projects
2.5 Choosing realistic intervals
The interval has to fit the duration of a run, not the desired reaction
time. Working an issue end to end (clone the repo, read the code, fix, MR)
takes minutes to quarter-hours; alle: 2m then does not mean "faster" but
"the next run begins before the previous one has understood what it is about".
15m for issue triage, 15m for the MR loop are a good starting value;
nothing that touches code should be below 5m.
Two built-in brakes dampen this but do not replace a sensible interval:
- A heartbeat does not fire while the task from its last run is still open (no stacking).
- The
nur-wenn:condition checks the edge, not the level (see above).
What neither catches: a run that ends without a result at the turn limit.
covey recognises that (max_turns), has the run summarise its own interim
state and creates a follow-up task from it that continues the session —
instead of letting the next heartbeat start from zero. After several
continuations in a row the task escalates to the manager instead of continuing.
If that happens regularly, the assignment is cut too large or max_turns is too
small.
2.6 Testing
- Create an issue in the target project → at the next heartbeat run the agent takes it up.
- If the agent replies, its comment has to appear under the
covey-botuser. - On a follow-up question the agent does not go
blocked: it puts the question as a comment, closes its run withdoneand checks at the next heartbeat bylist_noteswhether an answer is there.
2.7 The review loop: the agent as a developer
If the agent fixes a bug itself, it works like a flesh-and-blood developer — but
waiting for the review runs by polling, not through blocked:
checkoutof the project into the sandbox, set the project up (install dependencies, run the build and tests once in the initial state — the necessary package registries are released by the egress through the built-in templates, e.g. npm/PyPI/Go).- Develop the fix, run the tests, push onto a feature branch by
commit,create_merge_requestto the manager, comment the link in the issue. - The agent ends its run with
done— noblocked. (Without a webhook, ablockedwould never be woken and would occupy the heartbeat task permanently, so that no new "review issues" runs would arise.) - At the next heartbeat run the agent checks its open MRs
(
list_merge_requests state=opened→list_mr_notes) for new review feedback. If it demands changes, it checks the source branch out again, works the points in, runs the tests, pushes onto the same branch and answers bycomment_mr— thendoneagain. - If an MR has been merged (
list_merge_requests state=merged/get_merge_request), the agent comments the result in the issue; if it was closed without a merge, it checks bylist_mr_noteswhy and escalates if that is unclear.
For this loop to run reliably, the MR heartbeat
(nur-wenn: gitlab:mr, section 2.4) belongs in the HEARTBEAT.md. It wakes the
agent exactly when one of its open MRs has unanswered review feedback —
regardless of whether an issue happens to be open.
2.8 The QA/test agent: testing other people's MRs end to end
The review loop from 2.7 waits by default for a human (the manager, who is
entered as the MR assignee). But the review can also be given to a second
agent — a QA/test agent that accepts the feature and gives the developer agent
feedback. Both are normal covey agents; they work together through GitLab
(covey knows no direct agent-to-agent task handover — the collaboration runs
through the shared target system). The trick: the developer agent already has
the MR review loop (2.7) — if the QA agent comments defects on the MR, the
developer picks them up automatically at its next gitlab:mr run. So only
the QA agent's intake side is needed.
Sequence:
- The developer agent finds the QA agent itself — it does not have to know
a user name. At dispatch time its prompt contains the section
"Team (AI colleagues)" with all the organisation's other agents, their
GitLab identifier, responsibility and department; colleagues from its
team (the same department) are marked as
YOUR TEAM. It picks from that the colleague responsible for testing — preferably from its own team — and enters them as therevieweroncreate_merge_request(the manager stays theassignee):create_merge_request {"project_id":N,"source_branch":"fix/…","title":"…","assignee":"leaddev","reviewer":"covey-qa"}. It hands an existing MR over withset_reviewer {"project_id":N,"mr_iid":N,"username":"covey-qa"}and explains the handover in acomment_mr. If there is no QA colleague, the previous behaviour stands (the manager as both assignee and reviewer). - The QA agent finds its review queue through the sub-scope
nur-wenn: gitlab:review: it fires only when an MR in which the QA bot is entered as a reviewer is waiting for its review (no comment, or the last non-system comment is not from the QA bot). A freshly handed-over MR without a comment does count as work here — it is waiting for the first review (unlike in the developer loop, where a fresh MR waits for the reviewer). - The QA agent brings its working tree for the project to the source branch
(one tree per project, kept across acceptances — not one per MR), starts
the application and plays the feature through end to end in the browser
(not just reading the diff) and supports states and defects with screenshots
it attaches to the MR. The full test suite runs as a job (
dev start): its result is recorded in the sandbox home and stays readable in the next run even if this one ends at the turn limit. - The result by
comment_mr: on defects concretely with file:line and a reproduction (the developer agent works them in through itsgitlab:mrloop); if everything is green, the QA agent says so explicitly and releases it withapprove_mr. - The merge, where the tool
merge_mris assigned to the agent: it merges only its own acceptance. The action checks fail-closed beforehand that the MR is open and free of conflicts, every blocking discussion is resolved, the pipeline of the head commit is green and the agent's own approval is on record — and merges exactly the commit it saw. Withoutmerge_mrthe approval stays its last word and the merging is done by the human.
Setting up the QA agent. A ready-made example sits under
examples/qa-agent.bundle.json (SOUL/CAPABILITIES/PLAYBOOKS/ACCESS/HEARTBEAT
including nur-wenn: gitlab:review). For the automatic assignment from step 1
to take hold, five steps are needed — the bundle supplies the first two, the
remaining three are master data the bundle deliberately does not carry
(profile identifiers and department are never exported):
- Create the agent: import the bundle
(
POST /api/v1/agents/import, or "Agent from bundle" in the UI) — it creates the agentcovey-qawith all its config files. - Assign secrets: deposit
gitlab_token+gitlab_urlas in 2.2 and assign them to the QA agent; enable the GitLab anddevtarget systems for it. - Set the GitLab identity: enter the GitLab identifier in the QA agent's
profile (e.g.
gitlab: covey-qa) — only then does it appear in the other agents' "Team (AI colleagues)" directory with a user name the developer can enter asreviewer. Without a GitLab identity it is not addressable by the colleagues. - Set the responsibility: in the profile,
Responsibilities= "tests merge requests / QA" or similar — that is the criterion by which the developer agent recognises the QA colleague (a job title "QA agent" helps too). - Put it in the same team: assign the QA agent to the same department
as the developer agents (org chart → department). It is then marked as
YOUR TEAMin their prompt and preferred. (If it is in no department or a different one, it is still found organisation-wide by responsibility — just not preferentially.)
Its own bot user has to exist in GitLab (e.g. covey-qa, the Reporter role is
enough for commenting; for approve_mr Reporter suffices too, provided the
project allows approvals). For merge_mr it needs at least developer, and
maintainer on protected target branches. Its token sits in the QA agent's
gitlab_token — so the developer and QA agents write under different GitLab
users, and the review loop distinguishes "author" from "reviewer" cleanly. That
separation is what carries the merge gate: GitLab lets nobody approve their own
merge request, so a developer agent can never merge its own work, whatever its
tool assignment says.
Whoever does not want an autonomous merge takes the tool merge_mr out of the
QA agent's ACCESS.md (then only approve_mr remains) or denies the subject
gitlab:merge_mr organisation-wide by a guard rail — with the decision
ask the merge lands on the Approvals page and a human clicks it through.
Egress: for the QA agent to really be able to start the application, its sandbox needs the same package registries as the developer agent (npm/PyPI/Go through the built-in egress templates) — see
operations/deployment.md.
2.9 The delivery lead: leading a whole undertaking
Developer and QA agents work ticket by ticket. If the work hangs off a
milestone with a deadline — a tender, a release — a level above them comes
in: the delivery lead (examples/delivery-lead.bundle.json).
It makes tickets implementable (read the requirement in the original, comment
checkable acceptance criteria, name the affected places by
list_tree/read_file), holds dependent tickets back until their foundation is
merged, and hands out work according to a WIP limit.
Four things distinguish its setup from the others:
- The developer agents have to be on
nur-wenn: gitlab:issues:assigned(that is how the template is cut). If they reach freely for open issues, the lead's preparation comment already wakes them — the WIP limit and the order are then ineffective, and the lead is decoration. - The lead's
ACCESS.mdcarries atools:allowlist. Its role is defined by a long list of prohibitions (do not commit, no MRs, do not merge, do not close tickets); that is enforceable only centrally, not in the prompt (spec: guard rails fail-closed outside the runtime).commit,create_merge_request,approve_mr,set_stateandcheckoutare therefore not unlocked, even thoughscope: writewould allow them. - Nothing undertaking-specific sits in its config. Project, milestone, target branch, deadline, requirements path, WIP limit, dependencies and the report ticket sit in a wiki page, the undertaking profile (2.9.1). One lead leads exactly one undertaking — for a second one, a second lead.
- Its craft sits in skills, not in
PLAYBOOKS.md. The four procedures —ruecklaeufer,arbeit-vergeben,ticket-aufbereiten,tagesbericht— sit as skills on the agent (tab Tools & skills → Skills); the playbook only says in what order they come up. The reason is the prompt arithmetic: the config sits in the prompt on EVERY run, and the lead runs every 30 minutes, mostly without finding anything. This way an empty run costs around 3,000 instead of 5,100 tokens, and a procedure's full text is only read when it is needed. So whoever adapts the procedures does it in the skill —PLAYBOOKS.mdis only changed by whoever wants to change the order.
In addition to its own bot user it needs a permanently open report ticket
assigned to it and a human manager whose GitLab identifier is deposited in
their profile — without it, assign fails in exactly the path that hands open
subject-matter questions to humans.
2.9.1 The undertaking profile
At the beginning of every run the lead reads a page from its wiki memory
(covey/wiki_search → covey/wiki_read) and writes what it has learned back
there: a dependency that only showed up during preparation, a decision by the
client. That keeps the config generic — the same template leads every
undertaking, because it knows none of them.
Create it through the agent's memory view in the UI or as a task to it. The page
title: Engagement brief <milestone title>.
# Engagement brief <milestone title>
## Mandatory
- **Project ID:** <the numeric GitLab project id, not the path>
- **Milestone title:** <exactly as in GitLab — the filter matches literally>
- **Target branch:** <the branch that is developed against>
- **Deadline:** <date> — <what it derives from>
## Requirements in the original
- **Path in the repository:** <e.g. docs/requirements/criteria.md>
- **What is authoritative:** <which document wins on a contradiction with the ticket text>
## Steering
- **WIP limit:** <tickets at a time per developer; without a value 1 applies>
- **Report ticket:** project <id> / #<iid> — this is where the lead writes the
daily state. It has to be assigned to the lead and permanently open.
- **Responsible human:** <name>, GitLab identifier <username> — receives open
questions and the report. Without a deposited GitLab identifier `assign` fails.
## Order and dependencies
- #<iid> before #<iid>, #<iid>, … — <reason: a shared foundation>
## Decisions
<What the client has settled, with a date. The lead enters every answered
question here — otherwise it asks it again at the next ticket.>
## Open questions
<What nobody has answered, with the ticket and how long it has been waiting.
The daily report reads from here.>
The German bundle (delivery-lead.de.bundle.json) uses German headings
(Vorhaben-Steckbrief, ## Pflicht, ## Steuerung …) — the brief and the
skills that read it have to speak the same language, so take the headings from
the bundle you actually instantiated.
Why these fields:
- The project ID and milestone title are the cut of the working set
(
list_issues {"project_id":N,"milestone":"…"}). Without them the lead either grabs nothing or the whole project. - The path to the requirements is the difference between a lead that sorts
tickets and one that makes them implementable: a ticket text is a summary, and
the acceptance criteria have to come from the original. The documents
therefore belong in the repository — versioned and readable by all colleagues.
If the path is wrong, the lead searches for the file once with
list_treeand corrects the profile itself; if it does not find it, it aborts the preparation and reports that once in the report ticket instead of guessing criteria from ticket titles. An undertaking that does not get going is to be checked here first. - The WIP limit and the order are the brake against the most common error: several agents work simultaneously on tickets that share the same foundation and produce contradictory implementations on one branch. When in doubt, set it lower.
- Decisions prevent an answered question from being asked again in every further ticket. An individual ticket's comment history is the wrong place for that — at the next ticket it is out of sight.
3. Which issues does the agent take up?
The agent decides for itself: list_issues delivers only open issues, and the
project allowlist (3.3) filters the results of list_issues/list_projects
server-side. If the agent should work only on issues assigned directly to it,
there is list_issues {"assigned":true} (GitLab scope=assigned_to_me,
relative to the token's bot user) — the rule itself belongs in the agent's
PLAYBOOKS.md/HEARTBEAT.md; in addition every issue delivers its
assignees along, so the agent can also check the assignment case by case.
If the assignment hangs off an undertaking rather than individual tickets —
a tender, a release — the milestone is the more robust cut:
list_issues {"project_id":15,"milestone":"ECA-2026-045 Bundesdruckerei LMS"}
filters GitLab-side on the milestone title, and every issue carries its
milestone (with due_date) back. Labels remain usable alongside
({"labels":"MUSS-Kriterium"}) but carry no deadline.
3.1 No double working
Because the intake runs by polling, the agent sees the same open working set
again on every run. So that recurring runs do not work on anything twice, it
checks by list_notes (or list_mr_notes for MRs) whether its own comment is
already the last state, and reacts only to answers newly added since then. The
plugin's prompt documentation commits it to that.
3.2 Long comment threads
list_notes/list_mr_notes deliver a window at the new end of a thread: by
default the newest 20 comments, limit up to 100, page counts backwards into
the history (page=2 the 20 before them). The answer describes itself —
window, total, has_more, and truncated where something is missing — so
that an agent can tell a full window from a complete history.
That matters for tickets which run for months: an issue taking a daily report
carries hundreds of comments, and whoever loads them all pays for them in the
context of every single call. Comments longer than 4000 characters therefore
arrive cut off as well (body_truncated); get_note fetches an individual one
in full.
The internal readers — the duplicate check and the nur-wenn: advance check —
are not affected by the small window: their answer never reaches an agent, so
they read the last 100 comments of a thread.
3.3 The project allowlist
COVEY_GITLAB_INTAKE_PROJECTS="group/support, 42"
If the variable is set, list_issues/list_projects and the nur-wenn:
advance check deliver only hits from these projects (the project path
path_with_namespace case-insensitively, or the numeric project id).
Empty/unset → no restriction. The primary filter nevertheless remains the
GitLab-side setup: enter the bot user only in the target projects.
4. Code-based answers: checkout
The agent should not answer bug reports "from memory" but check the claim against the source. The action for that is
checkout {"project_id":15, "ref":"main"} # ref optional, default: the default branch
Sequence and security model:
- The daemon (not the runtime) downloads the repository archive through the
API (
GET /projects/:id/repository/archive.tar.gz) with the brokered token — the token stays in the daemon's RAM and never lands in the sandbox's file system (unlike agit clonewith a credential remote, which would persist the token in.git/config). - It is unpacked into
<home>/repos/p<project>-<ref>/; the action returns that directory aspath, and the agent then works locally with grep/read/bash. A renewed checkout replaces the old state (always fresh code). A partial checkout (path) lands underneath, at the place it occupies upstream, so several partial checkouts of one ref grow into ONE working tree —pathin the result stays the repository root,local_pathnames the subtree. - The home is persistent, so working copies would pile up in it without bound.
After every checkout the least recently used ones fall away; five survive
(
COVEY_CHECKOUT_KEEP,0switches the cleanup off). Which ones went is in the checkout result, because the agent may be holding a path from an earlier run. How full the sandbox is, and which working copies are eating it, is on the agent's Files tab. - Protections: path traversal is refused, symlinks are skipped, and the unpacked
size is limited (default 512 MB,
COVEY_GITLAB_CHECKOUT_MAX_MB). - Guard-rail subjects:
gitlab:checkout,gitlab:list_tree,gitlab:read_file(all read-only towards GitLab; whoever wants to restrict them puts rules on them).
Large repos: if the archive blows the limit, there are two ways out — both are also in the error message the agent sees:
- A partial checkout:
checkout {"project_id":N, "path":"web/upload"}loads only the subdirectory (thepathparameter of the GitLab archive API). Several of them share one working tree, so everything the project needs to build has to be fetched BEFORE the work starts — every checkout redraws the baseline commit and would swallow changes made in between. - Browsing without a checkout:
list_tree {"project_id":N, "path":"...", "recursive":true}lists the repository tree (max. 100 entries per call),read_file {"project_id":N, "file_path":"path/to/file"}reads a single file (up to 512 KB, above thattruncated:true).
History and MRs — "has that already been fixed?": a checkout is an archive
without .git — the agent sees no history through it. There are four further
read-only actions for that (guard-rail subjects gitlab:list_commits,
gitlab:get_commit, gitlab:list_merge_requests, gitlab:list_branches):
list_branches {"project_id":N, "search":"..."} # the default branch is marked
list_commits {"project_id":N, "ref":"...", "path":"...", "since":"2026-07-15T00:00:00Z"}
get_commit {"project_id":N, "sha":"..."} # a diff, truncated to 16 KB per file
list_merge_requests {"project_id":N, "state":"merged", "search":"...", "target_branch":"..."}
The plugin's prompt documentation commits the agent to this way of working:
first check whether the reported error has already been fixed since the
issue was created (list_commits with since, list_merge_requests; verify
suspicious commits with get_commit) — if so, it reports exactly that with a
commit reference instead of confirming the bug again. Only then: confirm a
bug only with a location (file:line) in the checked-out code, and only after the
reported route has been followed completely (UI → endpoint → processing — the
error can be in the frontend even when the backend looks suspicious); if it does
not find the place, it describes what it has checked and asks a targeted
follow-up question. Answers without evidence in the code are permissible only
for purely organisational issues. Prerequisite: the token from 2.1 needs read
access to the repository (the scope api covers that; the Reporter role
suffices for the archive download on private projects).
Reading screenshots and image attachments: bug reports often attach a screenshot — in the issue description (or a comment) it sits as a Markdown upload:

The agent only gets this text, not the image — the content cannot be derived
from the reference alone. The action download_upload exists for that:
download_upload {"project_id":15, "url":"/uploads/0123…/login-fehler.png"}
- The daemon loads the upload brokered through
GET /projects/:id/uploads/:secret/:filename(the token stays in the daemon and never lands in the file system) and puts the file under<home>/uploads/; the action returns the local path. - The agent then looks at the image with the read tool (vision) — so it can
actually evaluate the screenshot instead of passing over it. The prompt
documentation commits it to that: if it sees an image attachment in the
Markdown, it always downloads it and looks at it first, before taking it
into account in its analysis. As the
urlit passes the reference exactly as it stands between the Markdown brackets (a bare/uploads/…path or a full web URL — both are mapped onto the upload endpoint). - Protections: the file name is nailed to the basename (no path traversal), and
the size is limited to 25 MB. Guard-rail subject:
gitlab:download_upload(read-only towards GitLab). - Prerequisite: the upload API endpoint needs GitLab ≥ 16.6; on older
instances it returns
404, which the action reports with a corresponding note.
5. Internal vs. public comments
The adapter distinguishes — analogous to reply at Zammad:
- internal (
commentwithinternal:true, the default) → a GitLab "internal note", visible only to project members from Reporter upwards. - external (
commentwithinternal:false) → a public comment, visible to external reporters too. Guard-rail subject:gitlab:comment_external— this is typically where an approval rule takes hold.
escalate puts an internal note and removes the issue's assignment so that a
human takes over. set_state knows close and reopen
(the GitLab state_event).
5.1 The working state on the board: set_labels
set_labels {"project_id":15,"issue_iid":739,"add_labels":["in progress"],"remove_labels":["ready"]}
changes the labels of an existing issue. Deliberately additive/subtractive
(the GitLab add_labels/remove_labels) rather than as a full list: an agent
that maintains the working state would otherwise wipe the ticket's subject-matter
labels — component, type, procurement procedure — on every change. At least one
of the two lists has to be set; the answer contains the label state reached.
The state thereby belongs visibly on the board rather than only in comments — a
human sees without asking what is ready, what is running and what is waiting for
acceptance. The prerequisite is that the agent does both on every change:
remove the old state label, set the new one. Guard-rail subject:
gitlab:set_labels.
Two idiosyncrasies you have to know when designing such an agent:
- GitLab silently creates unknown labels when setting them. A typo by the
model (
lead::in_progressinstead oflead::in-progress) therefore produces a permanent project label that nobody clears away again — the same trap as with freely invented board columns. The playbook therefore has to prescribe a fixed, small set of state names character by character. The plugin's prompt documentation points the agent at this; a label with a comma in it is refused instead of silently falling apart into two labels. ::turns them into GitLab scoped labels — but mutually exclusive only in Premium/Ultimate. On Free they are normal labels with::in the name. An agent therefore must not rely on GitLab removing the old state label automatically; it has to include it inremove_labelsitself. That is exactly why the rule "both in the same call" stands above.
5.2 The undertaking: the milestone actions
A milestone is what a delivery lead leads (2.9): a release, a tender, a sprint
with a deadline. For a long time the plugin knew it only as a filter —
list_issues took a milestone title and every issue carried its milestone
back. That was enough to watch an undertaking and not enough to lead one:
a lead could see which tickets belonged to its milestone but could not put one
into it.
| Action | What it does |
|---|---|
list_milestones |
the undertakings of a project (state, search, include_parent) |
get_milestone |
one of them, addressed by title or by milestone_id |
create_milestone |
cuts a new one (title, description, start_date, due_date) |
update_milestone |
moves a date, rewrites the description, closes or reactivates |
set_milestone |
puts an issue or a merge request into a milestone — or takes it out |
set_milestone {"project_id":40,"issue_iid":819,"milestone":"NLC App"} is the
one that carries the role. As with set_labels, mr_iid instead of
issue_iid does the same to a merge request — exactly one of the two, never
both. create_issue takes a milestone along as well, so a newly filed ticket
does not spend the gap between two calls outside every undertaking.
Four things worth knowing when designing such an agent:
- A milestone carries an
idand aniid, and only theidattaches. Theiidis its number within the project, theidis instance-wide, and the attachment field means theid. Handing theiidover does not fail: on a project whose numbering happens to line up it attaches a different, existing milestone, and nothing afterwards shows it. That is why the actions take a title and resolve it themselves — an ambiguous or unknown title is an error with the candidates listed, never a guess. - Contradictory input is refused, not ranked. Title and
milestone_idtogether, ordetachtogether with a milestone, are errors. Both used to be resolved silently in favour of one side, and in both cases the agent got an operation it had not asked for — the wrong milestone edited, or a ticket unfiled — with nothing in the answer to show it. - Detaching has to be asked for (
"detach": true). If an omitted milestone meant "take it off", a forgotten field would silently unfile a ticket. - There is no delete. A milestone that is over gets
"state":"close", which keeps its issues and its history. If you want deletion barred centrally as well, note that the guard-rail matcher takes*or a trailing*only — the rule has to readdeny_action gitlab:delete*, one per system. A pattern with the wildcard in the middle (*:delete*) matches nothing and is furniture. - A group milestone can be filled but not edited from a project. One that
spans several projects only appears with
"include_parent":true. Attaching works by title and bymilestone_id— the project-scoped GitLab endpoint answers 404 for such an id, soset_milestonefalls back to the list rather than refusing an operation GitLab supports. Editing one really is impossible from here, and the action says so instead of passing GitLab's 404 on.
The actions sit under the scope write. For a delivery lead that plans but
does not develop, that is worth cutting down with the tools: allowlist — the
same scope otherwise opens commit and create_merge_request to it:
- system: gitlab scope: read,write,comment
tools: …, list_milestones, get_milestone, set_milestone
In GitLab itself reporter suffices for all of this; managing milestones does not need developer. So a planning agent gets a token that cannot push.
The delivery-lead template (examples/delivery-lead.bundle.json) carries
list_milestones, get_milestone and set_milestone — the ones its
documented role needs, since it leads one existing milestone from its
undertaking profile. create_milestone and update_milestone are deliberately
not in it: cutting a new milestone or moving a deadline is a wider brief than
the template describes, and it wants its own playbook alongside the extra
permission. Whoever needs it adds the two actions to the tools: line.
6. Env reference (GitLab-relevant)
| Variable | Default | Meaning |
|---|---|---|
COVEY_GITLAB_INTAKE_PROJECTS |
(empty = all) | An allowlist of projects (path or id) — filters list_issues/list_projects and the nur-wenn: advance check |
COVEY_GITLAB_CHECKOUT_MAX_MB |
512 |
The upper bound on the unpacked size of a checkout (section 4) |
COVEY_CHECKOUT_KEEP |
5 |
How many working copies survive under <home>/repos; 0 switches the cleanup off (section 4) |
COVEY_EGRESS_ALLOW |
(empty) | Additional permitted egress hosts, e.g. the GitLab host |
GitLab has no webhook intake — the former variables COVEY_PUBLIC_URL
(for GitLab only), COVEY_GITLAB_WEBHOOK_SECRET and
COVEY_GITLAB_AGENT_USERNAMES no longer apply to this plugin.
The general variables (egress, the daemon token TTL, …) are in
ops-zammad.md, section 6.