Skip to content
GitLab Adapter

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.

FieldTypeRequiredDefaultDescription
kindstringYes-Must be "gitlab".
api_keystringYes-GitLab access token. Sent verbatim in the PRIVATE-TOKEN header. See authentication.
projectstringYes-Namespace path or numeric project ID. See identifiers and project scoping.
endpointstringNohttps://gitlab.comInstance base URL. Required only for a self-managed instance. See endpoint.
active_stateslist of stringsNo["backlog", "in-progress", "review"]Project or group label names. Stored lowercased. See state defaults.
terminal_stateslist of stringsNo["done", "wontfix"]Project or group label names that mark completed issues. Stored lowercased.
handoff_statestringNo(absent)Label name set after a successful agent run. Must appear in neither active_states nor terminal_states. Absent disables handoff.
query_filterstringNo""URL query fragment merged into the issue-list request. Validated against a closed allowlist when the adapter is built. See query filter.
user_agentstringNosortie/<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&not[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.

ScopeReadsWrites
apiYesYes
read_apiYesNo. 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 typeIdentityAccess scopeNotes
Personal access tokenThe owning human userEverything that user can reachWorks. Couples automation to a person’s account and their whole project set.
Project access tokenA generated bot userExactly one project, enforced by the serverLeast 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 tokenA generated bot userEvery project in the groupAppropriate when one workflow spans a group. Same GitLab.com subscription gating as the project access token.
OAuth 2.0 access tokenThe authorizing userThe granted scopesNot 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

HeaderValue
PRIVATE-TOKEN<api_key>, verbatim.
Acceptapplication/json
User-Agentsortie/<version> on tracker requests; the configured user_agent value on SCM and CI requests, defaulting to sortie/dev.
Content-Typeapplication/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.

CallWhenAuthority
GET /personal_access_tokens/selfAlways, once, with no retryAdvisory. Never blocks.
GET /projects/{project}AlwaysAuthoritative gate. A failure blocks startup.
GET /projects/{project}/labelsOnly when any state label is configuredA 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:

  1. active_states, in configuration order.
  2. terminal_states, in configuration order.
  3. 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.

TargetRequest contents
Terminal state, issue openstate_event=close, plus the label swap.
Active state, issue closedstate_event=reopen, plus the label swap.
Handoff stateLabel swap only. The native state is untouched.
Already convergedNo request is issued.
Not a configured active, terminal, or handoff stateRejected 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:

InputReason
"" or whitespace onlyEmpty.
"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 fieldGitLab sourceNormalization
.issue.idiidProject-scoped iid as a string. Same value as .issue.identifier. The global id is never read.
.issue.identifieriidSame value as .issue.id (for example, "42").
.issue.titletitleString, as-is.
.issue.descriptiondescriptionMarkdown pass-through. Empty string when null.
.issue.priority(not available)Always nil. GitLab issues carry no priority field.
.issue.statelabels + native stateDerived 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.urlweb_urlStored opaque and never parsed. Points at the work-item path at the researched version; both that form and the issue path resolve.
.issue.labelslabels[]Each label lowercased. Non-nil empty list when no labels.
.issue.assigneeassignees[0].usernameFirst assignee’s username. The deprecated singular assignee field is never read. Empty string when unassigned.
.issue.issue_typeissue_typeLowercase (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.commentsseparate routenil 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_atcreated_atISO-8601 with zone offset, as-is.
.issue.updated_atupdated_atString, as-is.

Comment normalization

Template fieldGitLab sourceNormalization
.ididInteger formatted as a string.
.authorauthor.usernameString, as-is.
.bodybodyMarkdown pass-through, no flattening.
.created_atcreated_atISO-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.

stateissue_typeorder_bysort
pageper_pagepaginationwith_labels_details

Allowed keys

Every other key must be one the project issue-list route honors. The complete set is:

assignee_idassignee_usernameauthor_id
author_usernameconfidentialcreated_after
created_beforedue_dateiids
inlabelsmilestone
milestone_idmy_reaction_emojiscope
searchupdated_afterupdated_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

FaultExample
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

BehaviorDetail
labels combinationAND across comma-separated names. An issue must carry every name listed.
labels caseCase-sensitive. labels=BACKLOG and labels=backlog are different filters.
Unresolvable labels nameReturns an empty set rather than dropping the filter, so a misspelling shows up as “no candidates” instead of “every candidate”.
None and AnyWildcards on the non-negated labels parameter. Under not[labels] GitLab treats them as literal names.
assignee_username cardinalityCommunity 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 statusConditionError kind
2xxSuccess(none)
400Parameter or model validationtracker_payload_error
401Missing, invalid, revoked, or expired tokentracker_auth_error
403Insufficient token scope, or a route the credential may not reachtracker_auth_error
404Missing issue, missing project, or a project the credential cannot seetracker_not_found
409Conflicttracker_api_error.
414Request URI too large, from an over-long iids[] batchtracker_payload_error. The adapter prevents it by chunking.
422Unprocessable entitytracker_payload_error.
429Rate limited. Logs Retry-After when presenttracker_api_error
5xxServer errortracker_transport_error
Any other statusUnexpected statustracker_api_error
-Network, DNS, TCP, or TLS failuretracker_transport_error
-JSON decode failure on a 2xx responsetracker_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:

ShapeOrigin
{"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:

CauseResponse
The project does not exist404 {"message":"404 Project Not Found"}
The project exists and the token’s identity is not a memberByte-identical
No token at all, private projectByte-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.

TrapEffect
An unrecognized query parameterSilently disables the filter and returns an unfiltered set.
A case-variant label attachSilently creates a duplicate project label instead of matching the existing one.
remove_labels naming a nonexistent labelReturns 200 and changes nothing.
No concurrency control on issue updatesTwo 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

GuardBehavior
Comment created with no returned IDTreated 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 actionsLogs a WARN naming the executed command keys. The note text itself is never logged.
An empty or whitespace-only escalation labelAttaches 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 labelLogs 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.

OperationGitLab route(s)
Review decisionGET /projects/{project}/merge_requests/{iid}/reviewers, then GET .../merge_requests/{iid}/approvals
MergeabilityGET /projects/{project}/merge_requests/{iid}
CI statusGET /projects/{project}/merge_requests/{iid}, and GET .../repository/commits/{sha}/statuses for a manual head pipeline that is not superseded
Pending reviewsGET .../merge_requests/{iid}/reviewers, GET .../merge_requests/{iid}/notes, and GET /users/{id} per unresolved reviewer
Bot review commentsGET .../merge_requests/{iid}/notes, and GET /users/{id} per unresolved author
Label eventsGET .../merge_requests/{iid}/resource_label_events
Auto-merge scope checkGET /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_statusMergeability
mergeableClean
conflictDirty
unchecked, checking, preparing, approvals_syncingUnknown
Any other value, including one the adapter does not recognizeBlocked, 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:

  1. Any reviewer’s state is requested_changes → CHANGES_REQUESTED, decided before the approvals read.
  2. The approvals payload reports approved: true → APPROVED.
  3. The merge request has at least one reviewer → REVIEW_REQUIRED.
  4. 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.statusCI conclusion
head_pipeline absentEmpty, meaning no checks exist on the head
success, skippedsuccess
failedfailing
created, waiting_for_resource, preparing, waiting_for_callback, pending, running, canceling, canceled, scheduledpending
Any other valuepending, 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 pipelineVerdict
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: falsefailing
A job is still queued or running, or a completed job reports canceledpending

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 statusConclusionRun status
successsuccessCompleted
failed with allow_failure: falsefailureCompleted
failed with allow_failure: trueneutralCompleted
canceledcancelledCompleted
skippedskippedCompleted
manualneutralCompleted
created, pending, waiting_for_resource, waiting_for_callback, preparing, scheduledpendingQueued
running, cancelingpendingIn progress
Any other valuependingIn 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 outcomeGitLab responseMapping
MergedHTTP 200, decoded state mergedSuccess, carrying the merge commit SHA.
Already merged, draft, or conflictingHTTP 405, a constant body naming no reasonConflict 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 SHAHTTP 409Conflict error, same re-read disposition as the 405 case.
Branch protection refuses the merge, or the credential is invalidHTTP 401 or 403Auth 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 mergedn/aConflict 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

AspectGitHubGitLab
Mergeability signalmergeable_state enum with several concrete blocking statesSingle detailed_merge_status value that recomputes and can mask a second blocker
Review decisionGraphQL aggregate field, read directlyNo aggregate on Community Edition; folded from per-reviewer states plus the approvals payload
Bot classificationAccount-type marker on the embedded comment author, no extra requestNo marker on the embedded author; resolved per user through a separate, cached request
Merge and branch token scopepull_requests:write plus contents:write, or classic repoOne coarse api scope covers merge, branch delete, and label removal
Rebase-on-mergeA per-call merge strategyThe 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

CheckCondition
tracker.endpoint.invalidA present endpoint that does not parse to an absolute http or https URL with a host.
tracker.project.formattracker.project is whitespace only.
tracker.project.formattracker.project contains embedded whitespace.
tracker.project.formattracker.project is percent-encoded.
tracker.project.formattracker.project contains no slash and is not all digits.
tracker.project.formattracker.project has an empty path segment, a leading slash, or a trailing slash.
tracker.query_filter.invalidThe 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

CheckCondition
tracker.endpoint.insecureendpoint uses http; the token travels in cleartext in the PRIVATE-TOKEN header.
tracker.endpoint.api_suffixendpoint already ends in /api/v4; the adapter appends it automatically.
tracker.api_key.sortie_gitlab_token_hintapi_key is empty and SORTIE_GITLAB_TOKEN is set in the environment.
tracker.api_key.sortie_gitlab_token_missingapi_key is empty and SORTIE_GITLAB_TOKEN is not set.
tracker.api_key.gitlab_whitespaceapi_key has leading or trailing whitespace.
tracker.active_states.empty_element, tracker.terminal_states.empty_elementA state list element is empty or whitespace only.
tracker.active_states.untrimmed_element, tracker.terminal_states.untrimmed_elementA state list element has leading or trailing whitespace.
tracker.states.overlapA 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 checkedReason
An empty endpointNo diagnostic. It becomes https://gitlab.com when the adapter is built.
A one-slash rule on projectGitLab subgroups nest to any depth.
Token prefix or lengthThe 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:

DifferenceConsequence for a Sortie configuration
endpoint defaults to the hosted serviceSelf-managed instances set it; Gitea always requires it.
project takes a namespace path of any depth, or a numeric IDSubgroups 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 scopeThe write path requires api; there is no finer scope that works.

External references


Related pages

Was this page helpful?