GitLab Adapter
The GitLab adapter connects Sortie to GitLab over the GitLab REST API v4. It is registered under kind "gitlab", fetches issues from the project issue-list route, derives Sortie states from project labels, follows Link header pagination, and normalizes responses to the same issue object fields. Two facts shape the rest of this page. GitLab ships both as SaaS and as a self-managed install, so tracker.endpoint is optional and defaults to https://gitlab.com; it is required only to reach a self-managed instance. And the adapter targets the Community Edition surface, so no Premium or Ultimate feature is on the contract. The canonical API documentation is GitLab REST API.
See also: WORKFLOW.md configuration for the full tracker schema, error reference for all tracker error kinds, environment variables for $VAR expansion behavior.
Configuration
The adapter reads its configuration from the tracker section of the WORKFLOW.md front matter. Two fields are required; the rest have defaults.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
kind | string | Yes | - | Must be "gitlab". |
api_key | string | Yes | - | GitLab access token. Sent verbatim in the PRIVATE-TOKEN header. See authentication. |
project | string | Yes | - | Namespace path or numeric project ID. See identifiers and project scoping. |
endpoint | string | No | https://gitlab.com | Instance base URL. Required only for a self-managed instance. See endpoint. |
active_states | list of strings | No | ["backlog", "in-progress", "review"] | Project or group label names. Stored lowercased. See state defaults. |
terminal_states | list of strings | No | ["done", "wontfix"] | Project or group label names that mark completed issues. Stored lowercased. |
handoff_state | string | No | (absent) | Label name set after a successful agent run. Must appear in neither active_states nor terminal_states. Absent disables handoff. |
query_filter | string | No | "" | URL query fragment merged into the issue-list request. Validated against a closed allowlist when the adapter is built. See query filter. |
user_agent | string | No | sortie/<version> | User-Agent header sent on all requests. Sortie sets the tracker role’s value to its own version string, so only the SCM and CI roles honor an override, set in a top-level gitlab: block. |
in_progress_state is not a GitLab adapter config key. The adapter never reads it: the orchestrator consumes it and routes the resulting move through the same transition path. Its collision rules (must appear in active_states, must not collide with terminal_states or handoff_state) are enforced by the generic configuration layer before adapter validation runs, so GitLab’s own offline validation checks report nothing for it.
tracker:
kind: gitlab
endpoint: https://gitlab.example.com # omit for GitLab.com
api_key: $SORTIE_GITLAB_TOKEN
project: group/subgroup/project
active_states:
- backlog
- in-progress
handoff_state: review
terminal_states:
- done
- wontfix
query_filter: "scope=assigned_to_me¬[labels]=needs-triage"endpoint, api_key, and project accept $VAR indirection.
endpoint
The instance base URL, for example https://gitlab.example.com. Optional: an empty or whitespace-only value becomes https://gitlab.com, so a GitLab.com workflow omits the field entirely. The adapter validates a present value as an absolute http or https URL carrying a hostname, with neither a query nor a fragment, trims trailing slashes, and appends /api/v4, tolerating a value that already ends in /api/v4 without appending it twice. A value that fails the URL check (including a port-only authority such as http://:80, which has no hostname, and a bare IPv6 host, which must be bracketed as http://[fd00::1]:3000) is rejected when the adapter is built, with tracker_payload_error, before any network call.
Plain-http endpoints send the token in cleartext in the PRIVATE-TOKEN header. sortie validate warns on an http endpoint and on a value already ending in /api/v4.
project
Either a numeric project ID or the project’s full namespace path. GitLab nests subgroups to any depth, so group/project and group/subgroup/project are both valid and there is no one-slash rule of the kind the GitHub and Gitea adapters enforce. Write the path unencoded: the adapter percent-encodes the whole value exactly once for the API path, and a pre-encoded value is a validation error.
A project can be renamed or moved, which changes the path but not the numeric ID. A deployment that expects to survive a rename configures the numeric ID.
State defaults
When active_states or terminal_states is omitted or empty, the adapter substitutes the corresponding default so it can derive an issue’s state from its labels. These defaults feed state derivation. The orchestrator gates dispatch on the workflow’s configured active_states, not on the adapter’s substituted defaults, so an omitted active_states dispatches nothing. Set both lists to the project’s actual labels rather than relying on the defaults.
Authentication
The adapter authenticates with a GitLab access token sent in the PRIVATE-TOKEN request header:
PRIVATE-TOKEN: <api_key>GitLab also accepts Authorization: Bearer <token> and a ?private_token=<token> query parameter. The adapter uses neither. The Authorization header is shared with OAuth flows and is therefore ambiguous, and the query-parameter form leaks the secret into URLs, proxy logs, and server logs.
The token is sent verbatim, so the configured value must be the bare token with no surrounding whitespace; a leading or trailing space becomes part of the credential and fails authentication. sortie validate warns when the resolved key carries surrounding whitespace.
The adapter performs no token prefix check and no token length check, because GitLab’s access-token prefix is an administrator-writable application setting rather than a fixed shape.
Scopes
Classic GitLab access tokens carry coarse scopes, and there is no finer-grained scope for issue writes.
| Scope | Reads | Writes |
|---|---|---|
api | Yes | Yes |
read_api | Yes | No. Every write returns 403 {"error":"insufficient_scope"} |
Required scope: api for the full adapter. read_api is enough only for a read-only deployment that never transitions an issue, posts a comment, or attaches a label.
Token types
| Token type | Identity | Access scope | Notes |
|---|---|---|---|
| Personal access token | The owning human user | Everything that user can reach | Works. Couples automation to a person’s account and their whole project set. |
| Project access token | A generated bot user | Exactly one project, enforced by the server | Least privilege. A sibling project in the same group returns 404. Available on any self-managed license tier; on GitLab.com it is gated by the namespace’s subscription plan. See Personal access tokens for which plans grant it. |
| Group access token | A generated bot user | Every project in the group | Appropriate when one workflow spans a group. Same GitLab.com subscription gating as the project access token. |
| OAuth 2.0 access token | The authorizing user | The granted scopes | Not used. Sortie runs headless and implements no interactive authorization-code flow. |
Project and group access tokens are created with an access_level that must permit issue writes. Their generated bot usernames take the form project_<id>_bot_<hex> and group_<id>_bot_<hex>, which is the identity a query_filter naming assignee_username must use.
Fixed headers
| Header | Value |
|---|---|
PRIVATE-TOKEN | <api_key>, verbatim. |
Accept | application/json |
User-Agent | sortie/<version> on tracker requests; the configured user_agent value on SCM and CI requests, defaulting to sortie/dev. |
Content-Type | application/json, on requests with a body. |
The HTTP client has a 30-second per-request timeout. Cancelling the operation in progress aborts the in-flight request immediately. There is no API version header: behavior is pinned by the instance version.
Startup preflight
Three calls run before the first poll. They differ in authority.
| Call | When | Authority |
|---|---|---|
GET /personal_access_tokens/self | Always, once, with no retry | Advisory. Never blocks. |
GET /projects/{project} | Always | Authoritative gate. A failure blocks startup. |
GET /projects/{project}/labels | Only when any state label is configured | A read failure blocks startup; a missing label does not. |
Token introspection reads scopes, active, revoked, and expires_at. A token reporting revoked or inactive logs a WARN. An unavailable or undecodable response degrades to a debug line and startup continues; its only lasting effect is that the project-check failure message reports whether the token authenticated. The token value never appears in a log record.
The project check is the gate. On a 404 it fails with tracker_not_found and a message naming both possibilities, because a project that does not exist and a project the credential cannot see are indistinguishable at the API. See the 404 ambiguity.
The label catalog read pages GET /projects/{project}/labels and resolves the canonical stored casing of every configured state label, so a later write never attaches a case variant of a label the project already holds. A configured label absent from the catalog is not an error: it is the operator’s intended new label, logged as a debug line and created by the first add_labels write.
The project check and the catalog read retry a retryable failure on a bounded backoff of 1, 2, and 4 seconds; a configuration error returns immediately with no retry. Cancelling the operation in progress during a backoff returns at once.
When tracker.query_filter names labels, the adapter makes one further catalog read after the preflight. It is non-blocking, and its only output is a WARN for each distinct label name absent from the catalog.
State model
GitLab issues natively carry only opened and closed. There is no workflow engine and no transition graph. The adapter derives Sortie state from project labels. active_states, terminal_states, and handoff_state name labels, lowercased when the adapter is built.
Derivation
The adapter collects every configured label present on the issue, scanning in this order:
active_states, in configuration order.terminal_states, in configuration order.handoff_state, if set.
When more than one matches, the adapter logs a WARN naming every matched label, carrying the issue’s iid in the issue_identifier log attribute, then keeps the first. When none matches, a natively opened issue maps to the first active_states entry and a natively closed issue maps to the first terminal_states entry. With the corresponding list empty, the native opened or closed value passes through unchanged. All comparisons are case-insensitive.
Transitions
Transitioning an issue reads the issue, then applies the whole move in one PUT request carrying state_event, add_labels, and remove_labels together. GitLab applies all three in one transaction, so there is no window in which an issue carries a terminal label while still open.
| Target | Request contents |
|---|---|
| Terminal state, issue open | state_event=close, plus the label swap. |
| Active state, issue closed | state_event=reopen, plus the label swap. |
| Handoff state | Label swap only. The native state is untouched. |
| Already converged | No request is issued. |
| Not a configured active, terminal, or handoff state | Rejected with tracker_payload_error before any request. |
state_event accepts exactly close and reopen; the past-tense and GitHub-style spellings closed and open return HTTP 400. Re-sending state_event=close to an already-closed issue returns 200 and is a no-op, so a partial failure converges on retry.
The label swap attaches the target label under its canonical stored casing and removes every case variant of both the outgoing and the incoming label in the same request, so a project already holding a case-duplicate does not accumulate state labels on every transition. When add_labels and remove_labels name the same label, remove wins.
Label creation is server-side
GitLab itself creates a label named in add_labels that does not yet exist, returns 200, and attaches it as a project label. The adapter therefore needs no create-on-missing policy, no name-to-id resolution, and no default label color, and there is no silent no-op on an unknown name. A remove_labels naming a nonexistent label likewise returns 200 and changes nothing, so removal is idempotent.
The hazard that follows is duplication by case variant, not a failed write. Label names are case-sensitive, so attaching REVIEW to an issue already carrying review leaves the issue with both labels and creates a second project label. Domain labels are lowercased, so both arrive as review and the derived state looks correct while the project has grown a phantom label. Nothing in the API reports this. The canonical-casing resolution performed by the startup preflight is the mitigation for configured state labels, and attaching an escalation label performs the same resolution per call.
GitLab’s key::value scoped labels do not enforce mutual exclusivity on Community Edition: a single request attaching workflow::a and workflow::b leaves the issue carrying both. The adapter never relies on scoped-label exclusivity and always removes the previous state label explicitly.
Labels exist at project and group level. A group label attaches to a project issue and filters exactly like a project label, and the adapter’s catalog read returns both. Auto-creation always creates a project label, so a group-level state label must be pre-created by the operator.
Identifiers and project scoping
A GitLab issue carries two integers. The iid is project-scoped, human-visible, and the value every per-issue route consumes. The id is an instance-global integer that the project-scoped routes do not accept; its own route is administrator-only in practice, and the adapter never decodes the field.
The adapter maps both .issue.id and .issue.identifier to the iid as a string. Because the two are the same value, looking up issue states by ID or by identifier is structurally equivalent for this adapter.
The display identifier comes from the server-computed references.full, for example group/project#2, falling back to the configured project, #, and the iid when that field is empty. It is surfaced as display_identifier in the HTTP API and on the dashboard; it is not a .issue.* template field.
The identifier guard
Before building any per-issue request path, the adapter parses the supplied identifier and rejects it with tracker_not_found when it is not a plain positive decimal integer. Rejected without a request:
| Input | Reason |
|---|---|
"" or whitespace only | Empty. |
"007" | Zero-padded. |
"12a", "1.5", "+42" | Non-numeric. |
"0" | Not positive. No issue has iid 0. |
Project scoping
tracker.project is a numeric project ID or a namespace path of any depth. The adapter percent-encodes the value once, so group/subgroup/project reaches GitLab as group%2Fsubgroup%2Fproject. An unencoded slash would not match the route and returns 404.
API operations
The adapter implements every method of the tracker contract against GitLab’s issues, notes, and labels surfaces, addressing each issue by its project-scoped internal ID rather than its global one. Which route serves which call is GitLab’s to document; see external references. What follows is the behaviour those calls produce.
Candidate polling
The adapter constrains the candidate query rather than relying on the surface’s defaults, and both constraints are load-bearing. It asks for open issues only, because the list surface returns every state by default and an unconstrained query would put terminal issues into the candidate set. It also asks for issues specifically, because the same surface returns tasks, incidents, and test cases alongside them, and an unconstrained query would dispatch agents against checklist items. Results come back oldest-first from the server, so the orchestrator does no client-side re-sort, and pages are requested at the server maximum.
State filtering stays on the client. The configured state labels are never pushed into a server-side label filter, because that filter is an AND across names while candidate selection needs an OR: an issue in any one active state is a candidate.
The type guard is applied a second time on the client, on every read path, and a per-issue route that returns an entity of another type is reported as tracker_not_found rather than normalized into a fake issue.
Preflight
The startup preflight verifies the token, the project, and the project’s labels before the first poll, so a misconfigured deployment fails at startup rather than on the first dispatch.
Field mapping
The adapter normalizes GitLab issue responses to the issue object fields.
| Template field | GitLab source | Normalization |
|---|---|---|
.issue.id | iid | Project-scoped iid as a string. Same value as .issue.identifier. The global id is never read. |
.issue.identifier | iid | Same value as .issue.id (for example, "42"). |
.issue.title | title | String, as-is. |
.issue.description | description | Markdown pass-through. Empty string when null. |
.issue.priority | (not available) | Always nil. GitLab issues carry no priority field. |
.issue.state | labels + native state | Derived via the state model. Native state is opened or closed. |
.issue.branch_name | (not available) | Always empty. GitLab issues carry no branch reference field. |
.issue.url | web_url | Stored opaque and never parsed. Points at the work-item path at the researched version; both that form and the issue path resolve. |
.issue.labels | labels[] | Each label lowercased. Non-nil empty list when no labels. |
.issue.assignee | assignees[0].username | First assignee’s username. The deprecated singular assignee field is never read. Empty string when unassigned. |
.issue.issue_type | issue_type | Lowercase (issue, incident, task, test_case). The parallel uppercase type field is never read. |
.issue.parent | (not available) | Always nil. The issue route exposes no parent reference. |
.issue.comments | separate route | nil on list operations. Populated when the issue is read individually or comments are fetched on demand. Markdown. |
.issue.blocked_by | (not available) | Always a non-nil empty list. See Community Edition. No links request is issued. |
.issue.created_at | created_at | ISO-8601 with zone offset, as-is. |
.issue.updated_at | updated_at | String, as-is. |
Comment normalization
| Template field | GitLab source | Normalization |
|---|---|---|
.id | id | Integer formatted as a string. |
.author | author.username | String, as-is. |
.body | body | Markdown pass-through, no flattening. |
.created_at | created_at | ISO-8601 string, as-is. |
GitLab’s notes route mixes system notes into the human comment stream. Notes carrying system: true are dropped, both server-side by activity_filter=only_comments and again client-side, so state changes and label changes never reach an agent as human feedback. A note carrying internal: true passes through: it is a genuine human comment, visible to project members at Reporter level and above.
The notes route returns newest-first by default. The adapter requests sort=asc, so comments arrive oldest-first and need no client-side re-sort. An issue with no comments yields a non-nil empty list.
Query filter
tracker.query_filter is a URL query fragment, parsed as standard key=value URL query-string syntax and merged into the issue-list request. A merged key replaces the adapter’s own value for that key rather than appending to it, which is how a filter narrows polling to, for example, scope=assigned_to_me.
This adapter validates the fragment against a closed allowlist when the adapter is built and fails on anything outside it. That strictness is required by GitLab’s behavior: an unrecognized query parameter is silently ignored and the route returns an unfiltered result set with HTTP 200. A typo such as assignee= in place of assignee_username= would return every open issue and widen the candidate set with no visible signal. Invalid values on recognized keys behave in the opposite, safe way and return HTTP 400, so the danger is confined to key names.
Every rejection below happens when the adapter is built, before the first poll, with tracker_payload_error. sortie validate reports the same verdict offline by running the same validation.
Reserved keys
The adapter owns these eight and rejects a fragment naming any of them, because overriding one changes correctness rather than scope.
state | issue_type | order_by | sort |
page | per_page | pagination | with_labels_details |
Allowed keys
Every other key must be one the project issue-list route honors. The complete set is:
assignee_id | assignee_username | author_id |
author_username | confidential | created_after |
created_before | due_date | iids |
in | labels | milestone |
milestone_id | my_reaction_emoji | scope |
search | updated_after | updated_before |
Negatable keys
GitLab’s not[...] hash is accepted for the subset it honors there. The other allowed keys parse without error inside not[...] and then have no effect, so the adapter rejects them in that position.
not[assignee_id] | not[assignee_username] | not[author_id] | not[author_username] |
not[iids] | not[labels] | not[milestone] | not[milestone_id] |
labels and not[labels] are different parameters and may both appear.
Other rejections
| Fault | Example |
|---|---|
| The fragment does not parse as a URL query. | labels=%zz |
| A value carries an empty comma-separated segment. | labels=ready,,urgent |
| A non-array key repeats. | labels=a&labels=b |
| Two spellings name the same parameter. | labels=a&labels[]=b |
Repeat an array parameter with the [] suffix (iids[]=3&iids[]=4). A key without the suffix must carry exactly one value, because repeat semantics are not portable across GitLab versions.
Merge scope
The filter merges into candidate polling and into the state=opened half of the state-based lookup. It never merges into the state=closed half, and never into the batched state lookup, which addresses issues by iid and carries no filter. A running issue therefore stays visible to reconciliation even after an edit moves it outside the filter.
Server-side semantics
| Behavior | Detail |
|---|---|
labels combination | AND across comma-separated names. An issue must carry every name listed. |
labels case | Case-sensitive. labels=BACKLOG and labels=backlog are different filters. |
Unresolvable labels name | Returns an empty set rather than dropping the filter, so a misspelling shows up as “no candidates” instead of “every candidate”. |
None and Any | Wildcards on the non-negated labels parameter. Under not[labels] GitLab treats them as literal names. |
assignee_username cardinality | Community Edition accepts exactly one value and returns HTTP 400 for two, unless the filter repeats the key with the [] suffix. A GitLab.com namespace’s subscription plan may lift this restriction; consult GitLab’s own Issues API documentation for the current behavior on a given plan. |
When the adapter is built, it warns once per distinct labels name that no project or group label matches by exact, case-sensitive comparison. The warning does not block startup, because an operator may reference a label that does not exist yet. None and Any are skipped on the non-negated form. A catalog read failure at this point logs a WARN and startup continues.
Pagination
List routes take page (1-based) and per_page. The adapter never sends page. It sends per_page=100, the server maximum, and follows the RFC 8288 Link header’s rel="next" absolute URL the same way on every route, up to a 200-page guard that logs a WARN naming the endpoint when reached. The server default page size is 20.
The batched state lookup chunks requests at 50 distinct iids each. The chunk size sits far below any plausible front-end request-line limit rather than close to a measured one, which is how the adapter avoids provoking a 414.
An absent Link header, or a final page carrying no rel="next", is the normal end of results and never an error. The adapter drives from rel="next" rather than from the offset-header family for a documented reason: above 10,000 records GitLab omits X-Total, X-Total-Pages, and the rel="last" link, so counting up to a page total would silently truncate on exactly the large projects where it matters. That omission is documented by GitLab and was not verified against a live instance during adapter research.
GitLab also supports keyset pagination (pagination=keyset), which advertises its next page in the same Link header with an opaque embedded cursor. The adapter does not use it. Because GitLab exposes no cursor the adapter must carry itself, the missing-end-cursor error kind has no analogue here.
Unlike Gitea’s comments route, the GitLab notes route is paginated, and the adapter follows it the same way as every other paginated route.
Rate limiting
GitLab meters requests per user, and the hosted service and a self-managed instance are metered differently, with the hosted service the stricter of the two on comment creation. The current quotas are GitLab’s to publish, and a self-managed administrator can change them; see the GitLab REST API documentation.
The adapter parses no rate-limit header and does not throttle preemptively. Poll cadence is the only control, so a deployment tuned against a permissive self-managed instance can exhaust a hosted budget on comment writes alone. A throttled response maps to tracker_api_error and is retried with backoff; its body is not guaranteed to be JSON, so the adapter falls back to a bounded snippet for the message.
Error model
The adapter maps GitLab HTTP responses and network conditions to normalized error categories.
| HTTP status | Condition | Error kind |
|---|---|---|
| 2xx | Success | (none) |
| 400 | Parameter or model validation | tracker_payload_error |
| 401 | Missing, invalid, revoked, or expired token | tracker_auth_error |
| 403 | Insufficient token scope, or a route the credential may not reach | tracker_auth_error |
| 404 | Missing issue, missing project, or a project the credential cannot see | tracker_not_found |
| 409 | Conflict | tracker_api_error. |
| 414 | Request URI too large, from an over-long iids[] batch | tracker_payload_error. The adapter prevents it by chunking. |
| 422 | Unprocessable entity | tracker_payload_error. |
| 429 | Rate limited. Logs Retry-After when present | tracker_api_error |
| 5xx | Server error | tracker_transport_error |
| Any other status | Unexpected status | tracker_api_error |
| - | Network, DNS, TCP, or TLS failure | tracker_transport_error |
| - | JSON decode failure on a 2xx response | tracker_payload_error |
This 414 mapping extends the set the GitHub and Gitea adapters classify, because the batched iids[] lookup is the one adapter request whose URL length grows with input.
Error body
GitLab has no single error envelope. Four shapes occur:
| Shape | Origin |
|---|---|
{"message": "<string>"} | Application-level errors. |
{"error": "<string>"} | Parameter validation and unmatched routes. |
{"error": "...", "error_description": "..."} | Token authorization, OAuth-style. |
{"message": "<string with embedded model errors>"} | Model validation surfaced through a message. |
Detail extraction prefers message, tolerating a non-string value there by compacting it rather than failing the whole response. It falls back to error, appends error_description when present, and falls back again to a bounded raw snippet when the body does not decode as JSON at all. The error message carries the request method and path and never the token, which travels only in the PRIVATE-TOKEN header.
The 404 ambiguity
A 404 on a project-scoped route has three causes the response cannot distinguish, and one of them is an authorization failure:
| Cause | Response |
|---|---|
| The project does not exist | 404 {"message":"404 Project Not Found"} |
| The project exists and the token’s identity is not a member | Byte-identical |
| No token at all, private project | Byte-identical |
This is deliberate. GitLab masks the existence of private resources rather than returning 403, so an unauthorized caller cannot enumerate them. Note the asymmetry: a bad token returns 401, while a valid token lacking access returns 404. A 404 can therefore never be ruled out as an authorization problem. The startup preflight is the mitigation: a wrong project or an unauthorized token fails at startup with a message naming both possibilities, rather than producing a permanent stream of not-found results at poll time.
Silent success traps
The dangerous failures on this API are the 200s. Four behaviors return success with the wrong result and no status to key on.
| Trap | Effect |
|---|---|
| An unrecognized query parameter | Silently disables the filter and returns an unfiltered set. |
| A case-variant label attach | Silently creates a duplicate project label instead of matching the existing one. |
remove_labels naming a nonexistent label | Returns 200 and changes nothing. |
| No concurrency control on issue updates | Two simultaneous opposing writes both return 200, with no 409 and no conflict signal, and their label deltas interleave. |
The first two are prevented by the adapter’s own validation: the query_filter allowlist and the canonical-casing resolution.
Write-path guards
| Guard | Behavior |
|---|---|
| Comment created with no returned ID | Treated as a failure with tracker_payload_error. GitLab returns no note when the body was consumed entirely as quick actions, and reporting that as success would lose the comment silently. |
| Comment body that triggered quick actions | Logs a WARN naming the executed command keys. The note text itself is never logged. |
| An empty or whitespace-only escalation label | Attaches nothing and issues no request, but logs a WARN, so a failed escalation leaves a log trace rather than only a silent no-op. |
| Label catalog unavailable when attaching an escalation label | Logs a WARN and attaches the configured spelling, because a missed escalation is worse than a cosmetic duplicate. |
For the full error taxonomy and operator guidance, see the error reference.
SCM and CI surface
The gitlab kind also provides an SCM adapter and a CI status provider, so a GitLab-backed deployment drives the same pull-request reactions as a GitHub-backed one: review-comment feedback, CI-failure escalation, auto-merge, and branch cleanup. The reaction kinds and their lifecycle are provider-agnostic and documented in the reactions reference; provider: gitlab on a reaction block activates this adapter, and how to set up PR reactions covers the operator procedure. This section documents only the GitLab-specific behavior.
Both roles take api_key and endpoint from a top-level gitlab: block (adapter pass-through configuration), falling back to the tracker block’s values for any key that block omits when tracker.kind is also gitlab. endpoint behaves exactly as it does for the tracker, defaulting to https://gitlab.com and taking the instance root rather than the API path. The CI status provider also requires project; the SCM adapter ignores it, because owner and repo arrive with each call. Neither role makes a network call when the adapter is built.
SCM read operations
The adapter implements the SCM contract’s six read operations, plus the auto-merge scope check. Every merge-request route addresses the same project-scoped iid the tracker adapter uses.
| Operation | GitLab route(s) |
|---|---|
| Review decision | GET /projects/{project}/merge_requests/{iid}/reviewers, then GET .../merge_requests/{iid}/approvals |
| Mergeability | GET /projects/{project}/merge_requests/{iid} |
| CI status | GET /projects/{project}/merge_requests/{iid}, and GET .../repository/commits/{sha}/statuses for a manual head pipeline that is not superseded |
| Pending reviews | GET .../merge_requests/{iid}/reviewers, GET .../merge_requests/{iid}/notes, and GET /users/{id} per unresolved reviewer |
| Bot review comments | GET .../merge_requests/{iid}/notes, and GET /users/{id} per unresolved author |
| Label events | GET .../merge_requests/{iid}/resource_label_events |
| Auto-merge scope check | GET /personal_access_tokens/self |
The project half of every route above is built from the owner and repo values supplied for the pull request, joined with a slash and percent-encoded once. The two values together must reconstruct the project’s full namespace path, and a project nested in subgroups may carry its intermediate groups on either side of the split. A half that arrives already percent-encoded reaches GitLab double-encoded and returns 404.
The reviewers, notes, and label-event routes all paginate through the same Link header walk the tracker adapter uses, requesting per_page=100 and stopping at a 200-page guard. When a returned comment carries a diff position, normalizing it into a review comment costs one further GET /projects/{project}/merge_requests/{iid} read, to compare the comment’s recorded head SHA against the merge request’s current head and set the outdated flag; a general, non-diff comment costs no extra read.
Mergeability
GitLab exposes mergeability as a single detailed_merge_status string rather than GitHub’s mergeable_state enum. The adapter maps four values and treats everything else as blocked:
detailed_merge_status | Mergeability |
|---|---|
mergeable | Clean |
conflict | Dirty |
unchecked, checking, preparing, approvals_syncing | Unknown |
| Any other value, including one the adapter does not recognize | Blocked, logged at WARN when unrecognized |
The adapter never reports unstable. A pipeline whose only failing job carries allow_failure: true reports success, so its merge request reports mergeable and maps to clean; the warning survives only in the pipeline’s own detailed status, which no merge read consults.
detailed_merge_status recomputes: approving a merge request has been observed to flip the value from mergeable to checking and back within the same second. A single read can land mid-recompute, so the auto-merge precondition check treats that window as unknown rather than clean, and the reconcile loop re-enqueues for the next poll instead of merging on a stale computation. Expect the check to cost an extra poll tick right after an approval or a push, not a stuck state.
Review decision
The review-decision read finds no aggregate review-decision field. It folds the decision from two reads, the per-reviewer states and the merge request’s approvals payload, evaluated in this order:
- Any reviewer’s state is
requested_changes→CHANGES_REQUESTED, decided before the approvals read. - The approvals payload reports
approved: true→APPROVED. - The merge request has at least one reviewer →
REVIEW_REQUIRED. - No reviewers and no approval →
NOT_REQUIRED.
The changes-requested rule is checked first and applies unconditionally, so a later approval from a second reviewer can never clear an outstanding change request. The last rule is the one to read closely: the fold treats a merge request with no reviewer assigned as unreviewed rather than pending, and NOT_REQUIRED lets auto-merge proceed on it. Assigning a reviewer is what moves such a merge request to REVIEW_REQUIRED. Whether the instance can also require approval by rule is a GitLab subscription question; the adapter reads no approval-rule route and never consults one.
Bot classification
A note’s embedded author carries no platform bot marker on GitLab; only GET /users/{id} reports one. The bot-review-comment read selects a comment when its author matches the bot_usernames allowlist with no lookup, and otherwise resolves the platform marker through that route; a lookup failure aborts the whole read, because the lookup only ever widens the selected comment set, and silently dropping a bot’s comments there is worse than failing the call. The pending-review read excludes a requested_changes reviewer whose account resolves as a platform bot, but applies no bot_usernames allowlist of its own: a review tool that comments under a regular user identity still counts as a blocking reviewer there. A lookup failure there logs a WARN and treats the reviewer as not a bot rather than aborting.
Resolved bot flags are cached for the adapter’s lifetime, so a given author costs one GET /users/{id} call at most once per process. This differs from GitHub, whose embedded comment author carries its own account-type marker with no extra request, and from Gitea, which exposes no platform marker at all and relies solely on the bot_usernames allowlist.
Label events
The label-events read normalizes add and remove events from the merge request’s resource label-event journal into the same entry shape the label commands reactions consume, sorted ascending by event time and then by event id. An event whose label was later deleted from the project carries no name and is skipped.
Pipeline status
The CI status read starts from the merge request’s embedded head_pipeline object and maps its status onto the merge-gate conclusion the auto-merge CI precondition reads. The mapping applies only when head_pipeline.sha matches the merge request’s own head SHA, compared case-insensitively, with one exemption: a merged-results or merge-train pipeline generated for this merge request, which runs on a ref whose commit exists in neither branch and carries no field relating it to the head. A head pipeline that fails the comparison describes a superseded commit and resolves to pending, with one warning naming both SHAs and the pipeline id, before manual is even considered; see stale head pipeline below. The platform folds an externally reported commit status into the head pipeline, so for the twelve statuses the table below maps directly, the pipeline’s own status already accounts for one and no second request is issued. manual is the exception, resolved after the table.
head_pipeline.status | CI conclusion |
|---|---|
head_pipeline absent | Empty, meaning no checks exist on the head |
success, skipped | success |
failed | failing |
created, waiting_for_resource, preparing, waiting_for_callback, pending, running, canceling, canceled, scheduled | pending |
| Any other value | pending, logged at WARN naming the observed value |
A skipped pipeline is merge-eligible. GitLab reports that status only for a pipeline whose every job is skipped or an untriggered manual job, so its job set cannot carry a failing conclusion.
A manual head pipeline is resolved separately: the adapter reads that pipeline’s own job set, a second, paginated request scoped to the pipeline, and folds the normalized entries through the same rule the CI status provider uses to compute its own aggregate.
Job set on a manual head pipeline | Verdict |
|---|---|
Every job completed, none failing, and none cancelled (an untriggered manual job or a failed job with allow_failure: true both count as completed and non-failing) | success |
A job reports failed with allow_failure: false | failing |
A job is still queued or running, or a completed job reports canceled | pending |
The first row is the correction: a manual head pipeline settling with nothing left but an untriggered manual job is merge-eligible, not stuck. The second row is also a correction: a manual job sharing the pipeline with a job that genuinely failed reports failing, not pending. The third row also covers a completed job that reports canceled: that job asserts no result about the commit, so it holds the verdict at pending alongside the ordinary case of later work that has been created but has not run yet. An unrecognized job status logs one warning naming it and its count, and folds the same way an in-progress job does.
A job set that folds to the empty verdict holds at pending instead, with one warning naming the pipeline: the platform never reports manual for a pipeline with no jobs, so an empty scoped result means the read itself was mis-addressed, not that the pipeline is clean. A manual head pipeline with no SHA of its own, or a pipeline id of zero, fails as a payload error before any request is issued, for the same reason: the platform answers a zero-scoped query with an empty result rather than an error, and failing loudly here is what keeps the anomaly visible instead of it masquerading as a clean pipeline. A failure of the job-set read itself surfaces unchanged, never degraded into a verdict.
The job-set read is one request in the ordinary case, and more on a large job set, since it walks the same paginated route the CI status provider uses for its own commit-status read. With require_ci: true, an auto-merge entry now merges once a manual head pipeline’s job set genuinely settles clean, escalates when the set hides a failed job, and defers only while queued or running work remains, rather than deferring on every tick regardless of the job set.
Stale head pipeline
GitLab exposes head_pipeline as a stored association on the merge request, not a value recomputed against the current head. A push that creates no pipeline of its own, for example one that touches no path a pipeline configuration watches, leaves head_pipeline pointing at the pipeline for the previous commit after the merge request’s own head SHA has already moved.
The adapter detects this by comparing head_pipeline.sha against the merge request’s own SHA, case-insensitively, before classifying anything. A mismatch resolves to pending, with one warning naming both SHAs and the pipeline id, rather than reporting a status computed for a commit that is no longer current. The comparison runs before the manual job-set read, so a superseded manual pipeline never pays for one. No second request is issued for a stale head pipeline of any status: the merge-request read that already carries head_pipeline is the only one.
Two pipeline shapes are exempt from the comparison: a merged-results pipeline and a merge-train pipeline generated for the merge request being read. Both run on a ref whose commit exists in neither the source nor the target branch, so their SHA can never equal the merge request’s own head, and no field on the response relates one to the other. The adapter recognizes the shape by an exact match on both the pipeline’s source and its ref anchored to the merge request being read, never by testing the ref alone: a branch can be named after a generated ref, but its source cannot be forged to match.
The platform’s own mergeability check is not a backstop here: a merge request with a demonstrably stale head pipeline still reported a mergeable, can-be-merged status, so a deployment cannot rely on mergeability alone to catch this.
CI status provider
The CI status provider drives the ci_failure reaction. Reading a ref’s CI status resolves the given ref to a commit SHA and the pipeline GitLab reports as that commit’s current one, then reads that pipeline’s commit-status list to exhaustion and normalizes each entry to a check run. A commit with no pipeline yields an empty check-run list and a pending result, the same convention the GitHub and Gitea providers use.
The commit-status read scopes to one pipeline twice: on the wire, through the pipeline_id query parameter, and again after decoding, by discarding any entry whose own pipeline_id differs, across every page the read walks. The second check is what keeps the scope correct against a deployment that ignores the query parameter, whether the mismatched entries land on the first page or a later one.
Each status entry’s status and allow_failure fields decide its check conclusion and run status:
Entry status | Conclusion | Run status |
|---|---|---|
success | success | Completed |
failed with allow_failure: false | failure | Completed |
failed with allow_failure: true | neutral | Completed |
canceled | cancelled | Completed |
skipped | skipped | Completed |
manual | neutral | Completed |
created, pending, waiting_for_resource, waiting_for_callback, preparing, scheduled | pending | Queued |
running, canceling | pending | In progress |
| Any other value | pending | In progress, logged at WARN naming the observed value and the count |
An allowed-to-fail job therefore never turns the aggregate red and is never counted as failing. A canceled job is a different kind of non-failing: it withholds a passing verdict without turning the aggregate red either, holding it at pending instead. The aggregate status and failing count come from the same shared rule the GitHub and Gitea providers use, the same rule the merge gate now folds a manual head pipeline’s job set through, so the two readers agree on a manual verdict. They still differ on a stale head pipeline: the merge gate reads the SHA embedded on the merge request response and can hold at pending on one, the shape covered under stale head pipeline, while this provider resolves the commit it was asked about for itself and is never exposed to that staleness.
On a failing verdict, the log excerpt is the sanitized tail of the first failing job’s trace, capped by the max_log_lines budget; a max_log_lines of zero or less disables it. GitLab ignores the Range header on the trace route, so a trace larger than 1 MiB yields the tail of that first megabyte rather than the true tail. .ci_failure.ref always echoes the input ref, never the resolved SHA.
The write surface
The write surface covers merging a pull request, deleting a branch, and removing a label. The supported merge strategies are merge, squash, and rebase, the same set the auto-merge strategy field accepts.
Merging a pull request calls PUT /projects/{project}/merge_requests/{iid}/merge and always sends the expected head SHA as a stale-head precondition; a call with no expected SHA is rejected before any request. GitLab expresses rebase-on-merge as the target project’s own merge-method setting rather than a per-call parameter, so a rebase request issues the same call as merge and logs one WARN naming that governance.
| Merge outcome | GitLab response | Mapping |
|---|---|---|
| Merged | HTTP 200, decoded state merged | Success, carrying the merge commit SHA. |
| Already merged, draft, or conflicting | HTTP 405, a constant body naming no reason | Conflict error. A re-read of the merge request confirms whether it landed; only then does the error carry the “already merged” marker. |
| Stale expected head SHA | HTTP 409 | Conflict error, same re-read disposition as the 405 case. |
| Branch protection refuses the merge, or the credential is invalid | HTTP 401 or 403 | Auth error, rewritten to name both possible causes, since GitLab answers a branch-protection refusal on this route with 401 rather than the 403 a plain permission failure returns elsewhere in the API. |
HTTP 200 with a decoded state other than merged | n/a | Conflict error directly, with no “already merged” marker. |
The already-merged marker is never read from GitLab’s rejection text: the 405 body is byte-identical across the already-merged, draft, and conflicting cases, so the adapter re-reads the merge request after any 405 or 409 and attaches the marker only when that re-read shows the merge landed.
Deleting a branch calls DELETE /projects/{project}/repository/branches/{branch}, with the branch name percent-encoded. An already-gone branch (HTTP 404) is a no-op.
Removing a label reads the merge request’s own labels, matches the target name case-insensitively, and sends every matching case variant in one PUT /projects/{project}/merge_requests/{iid} carrying remove_labels, because GitLab matches label names case-sensitively and a project can accumulate case-duplicate labels (see label creation is server-side). No match is a no-op; a 404 reading or writing the merge request maps to a no-op too, since on GitLab that status can only mean the merge request or the project is gone, never an absent label.
Token scope for the write path
api is the only classic scope that can merge a pull request, delete a branch, or remove a label; read_api performs every read this section documents and is refused on every write with 403 {"error":"insufficient_scope"}. This is the same scopes table the tracker surface uses; GitLab has one coarse write scope covering both surfaces, not a separate contents-and-pull-request split.
The startup auto-merge preflight calls GET /personal_access_tokens/self once. A classic token reporting api in its scopes passes; one that omits it fails closed, blocking auto-merge for the process lifetime. A token whose scopes report as the opaque ["granular"] value, an empty scopes array, an unreadable introspection response, or an instance whose introspection route answers 404, all fail open instead: the preflight cannot verify anything from them, so it lets auto-merge proceed rather than blocking a credential it cannot classify. A fine-grained (“granular”) GitLab token is the practical case this covers, because its scopes carry no permission detail for the preflight to read; confirm a granular token’s permissions directly rather than relying on this check.
Key differences from the GitHub adapter
| Aspect | GitHub | GitLab |
|---|---|---|
| Mergeability signal | mergeable_state enum with several concrete blocking states | Single detailed_merge_status value that recomputes and can mask a second blocker |
| Review decision | GraphQL aggregate field, read directly | No aggregate on Community Edition; folded from per-reviewer states plus the approvals payload |
| Bot classification | Account-type marker on the embedded comment author, no extra request | No marker on the embedded author; resolved per user through a separate, cached request |
| Merge and branch token scope | pull_requests:write plus contents:write, or classic repo | One coarse api scope covers merge, branch delete, and label removal |
| Rebase-on-merge | A per-call merge strategy | The target project’s own merge-method setting; a per-call rebase request logs a WARN and merges the same way as merge |
See the GitHub adapter reference.
Offline validation
sortie validate runs the GitLab-specific checks below without making network calls.
Errors
| Check | Condition |
|---|---|
tracker.endpoint.invalid | A present endpoint that does not parse to an absolute http or https URL with a host. |
tracker.project.format | tracker.project is whitespace only. |
tracker.project.format | tracker.project contains embedded whitespace. |
tracker.project.format | tracker.project is percent-encoded. |
tracker.project.format | tracker.project contains no slash and is not all digits. |
tracker.project.format | tracker.project has an empty path segment, a leading slash, or a trailing slash. |
tracker.query_filter.invalid | The fragment fails the same validation applied when the adapter is built. |
The project checks are evaluated in that order and report the first fault that applies. A value of all ASCII digits is accepted as a numeric project ID and skips the remaining checks.
Warnings
| Check | Condition |
|---|---|
tracker.endpoint.insecure | endpoint uses http; the token travels in cleartext in the PRIVATE-TOKEN header. |
tracker.endpoint.api_suffix | endpoint already ends in /api/v4; the adapter appends it automatically. |
tracker.api_key.sortie_gitlab_token_hint | api_key is empty and SORTIE_GITLAB_TOKEN is set in the environment. |
tracker.api_key.sortie_gitlab_token_missing | api_key is empty and SORTIE_GITLAB_TOKEN is not set. |
tracker.api_key.gitlab_whitespace | api_key has leading or trailing whitespace. |
tracker.active_states.empty_element, tracker.terminal_states.empty_element | A state list element is empty or whitespace only. |
tracker.active_states.untrimmed_element, tracker.terminal_states.untrimmed_element | A state list element has leading or trailing whitespace. |
tracker.states.overlap | A name appears in both active_states and terminal_states, compared case-insensitively. |
SORTIE_GITLAB_TOKEN is the conventional variable name this advisory suggests, not a separate config path; the key resolves through the standard tracker.api_key field.
Deliberate non-checks
| Not checked | Reason |
|---|---|
An empty endpoint | No diagnostic. It becomes https://gitlab.com when the adapter is built. |
A one-slash rule on project | GitLab subgroups nest to any depth. |
| Token prefix or length | The prefix is an administrator-writable application setting. |
Community Edition, Enterprise Edition, and GitLab.com
Community Edition is the compatibility floor: the adapter depends only on what Community Edition provides, and no minimum GitLab version is claimed. Every tracker operation works there, with one degradation in the normalized issue model.
That degradation is .issue.blocked_by. GitLab’s blocking issue-link type is not available on Community Edition, so the adapter normalizes blockers to an empty list and no issue is ever held out of dispatch for a blocker. Sortie does not synthesize a blocker from a generic relation, because a related issue is not a blocking one. A GitLab.com namespace on a lower subscription plan can hit the same gap through licence gating instead of absence, which fails differently for the same reason: the value exists but the license check rejects it, an authorization-shaped failure rather than a parameter-validation one.
Which features each GitLab edition and tier includes is GitLab’s to document, and the adapter avoids the licence-gated surface entirely rather than degrading against it.
Key differences from the Gitea and GitHub adapters
Most of what separates these three is their own API surface, which each vendor documents. Four differences change what you configure or what you can rely on:
| Difference | Consequence for a Sortie configuration |
|---|---|
endpoint defaults to the hosted service | Self-managed instances set it; Gitea always requires it. |
project takes a namespace path of any depth, or a numeric ID | Subgroups need the full path, and a numeric ID survives a rename. |
| Blockers are structurally unavailable | .issue.blocked_by is always empty, so no issue is ever held out of dispatch for a blocker. |
| A merge token needs one coarse scope | The write path requires api; there is no finer scope that works. |
External references
- GitLab REST API: base URL, pagination, and request conventions
- REST API authentication: how the token is presented and which token types are accepted
- Issues API: the issue surface this adapter reads and writes, including its filter parameters
- Notes API: the comment surface behind
tracker.comments - Merge requests API: the surface behind the SCM role
- Personal access tokens: creating a token and what each scope covers
Related pages
- How to connect Sortie to GitLab: setup instructions with token creation, state mapping, and verification
- WORKFLOW.md configuration reference: full schema for the
trackersection and all other configuration - Error reference: all tracker error kinds with retry behavior and operator actions
- Environment variables reference:
$VARexpansion modes and agent passthrough variables - GitHub adapter reference: the closest sibling forge adapter
- Gitea adapter reference: the other self-hostable forge adapter
- State machine reference: orchestration states, candidate eligibility, and how tracker state drives dispatch
- Prometheus metrics reference:
sortie_tracker_requests_totaland related counters - How to write a prompt template: using
.issuefields populated by this adapter in templates
Was this page helpful?