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 domain Issue and Comment types. 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 at construction. See query filter.
user_agentstringNo"sortie/dev"User-Agent header sent on all requests.

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, so its collision rules (must appear in active_states, must not collide with terminal_states or handoff_state) are enforced by the generic config validation and the GitLab validate hook carries no arm 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 the value as an absolute http or https URL with a host, 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 fails construction 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

defaultActiveStates is ["backlog", "in-progress", "review"]; defaultTerminalStates is ["done", "wontfix"]. 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 at any license on self-managed; on GitLab.com it requires a Premium or Ultimate subscription.
Group access tokenA generated bot userEvery project in the groupAppropriate when one workflow spans a group. Same GitLab.com paid-tier requirement 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-AgentConfigured user_agent value.
Content-Typeapplication/json, on requests with a body.

The HTTP client has a 30-second per-request timeout. Context cancellation propagates; a cancelled context aborts the in-flight request. There is no API version header: behavior is pinned by the instance version.


Construction preflight

The constructor runs three calls 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 construction.
GET /projects/{project}/labelsOnly when any state label is configuredA read failure blocks construction; 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 construction 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 the constructor 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. A context cancellation during a backoff returns at once.

When tracker.query_filter names labels, the constructor 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 at construction.

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 the issue’s iid and every matched label, 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

TransitionIssue 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 construction preflight is the mitigation for configured state labels, and AddLabel performs the same resolution per call for escalation labels.

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 domain.Issue.ID and domain.Issue.Identifier to the iid as a string. Because the two are the same value, FetchIssueStatesByIDs and FetchIssueStatesByIdentifiers share one implementation.

domain.Issue.DisplayID 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.

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 the nine methods of the TrackerAdapter interface. Every per-issue route uses the iid.

MethodGitLab route(s)
FetchCandidateIssuesGET /projects/{project}/issues?state=opened&issue_type=issue&scope=all&per_page=100&order_by=created_at&sort=asc
FetchIssueByIDGET .../issues/{iid}, then the notes route below
FetchIssuesByStatesGET .../issues?state=opened&... and GET .../issues?state=closed&...
FetchIssueStatesByIDsGET .../issues?iids[]=N&iids[]=M&state=all&scope=all&per_page=100, one request per batch
FetchIssueStatesByIdentifiersSame as FetchIssueStatesByIDs
FetchIssueCommentsGET .../issues/{iid}/notes?activity_filter=only_comments&sort=asc&per_page=100
TransitionIssueGET .../issues/{iid}, then one PUT .../issues/{iid}
CommentIssuePOST .../issues/{iid}/notes
AddLabelGET .../labels, then PUT .../issues/{iid} with add_labels

Preflight routes: GET /personal_access_tokens/self, GET /projects/{project}, and GET /projects/{project}/labels?per_page=100.

Candidate polling parameters

Each parameter the adapter sets on the candidate query earns its place.

ParameterReason
state=openedThe list route’s default state is all, not open. Omitting it would put terminal issues in the candidate set. The value is opened, not open; state=open returns HTTP 400.
issue_type=issueGitLab’s issue list also returns tasks, incidents, and test cases. Omitting it would dispatch agents against checklist items.
scope=allRemoves any dependence on the route’s default scope.
per_page=100The server maximum. See pagination.
order_by=created_at, sort=ascHands the orchestrator oldest-first candidates, so no client-side re-sort is needed. The server default is sort=desc.

State filtering stays client-side. The configured state labels are never pushed into the labels parameter: that filter is AND across names, and candidate selection needs OR across several active states, which the route does not offer.

The adapter keeps a client-side issue_type guard on every read path in addition to the server-side filter, and returns tracker_not_found when a per-issue route resolves to a non-issue work item. Comments is nil on issues returned by list operations.


Field mapping

The adapter normalizes GitLab issue responses to domain.Issue fields.

Domain fieldGitLab sourceNormalization
IDiidProject-scoped iid as a string. Same value as Identifier. The global id is never read.
IdentifieriidSame value as ID (for example, "42").
DisplayIDreferences.fullFor example group/project#2. Falls back to <project>#<iid>.
TitletitleString, as-is.
DescriptiondescriptionMarkdown pass-through. Empty string when null.
Priority(not available)Always nil. GitLab issues carry no priority field.
Statelabels + native stateDerived via the state model. Native state is opened or closed.
BranchName(not available)Always empty. GitLab issues carry no branch reference field.
URLweb_urlStored opaque and never parsed. Points at the work-item path at the researched version; both that form and the issue path resolve.
Labelslabels[]Each label lowercased. Non-nil empty slice when no labels.
Assigneeassignees[0].usernameFirst assignee’s username. The deprecated singular assignee field is never read. Empty string when unassigned.
IssueTypeissue_typeLowercase (issue, incident, task, test_case). The parallel uppercase type field is never read.
Parent(not available)Always nil. The issue route exposes no parent reference.
Commentsseparate routenil on list operations. Populated by FetchIssueByID and FetchIssueComments. Markdown.
BlockedBy(not available)Always a non-nil empty slice. See Community Edition. No links request is issued.
CreatedAtcreated_atISO-8601 with zone offset, as-is.
UpdatedAtupdated_atString, as-is.

Comment normalization

Domain fieldGitLab sourceNormalization
IDidInteger formatted as a string.
Authorauthor.usernameString, as-is.
BodybodyMarkdown pass-through, no flattening.
CreatedAtcreated_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 slice.


Query filter

tracker.query_filter is a URL query fragment, parsed with url.ParseQuery 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 at construction 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 at construction, before the first poll, with tracker_payload_error. sortie validate reports the same verdict offline by running the same parser.

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 construction-time 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 FetchIssuesByStates. 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. GitLab.com accepts several.

At construction the adapter warns once per distinct labels name that no project or group label matches by exact, case-sensitive comparison. The warning does not block construction, 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 construction 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 through the shared paginator, 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 routes it through the same paginator.


Rate limiting

Two products, two answers, and the self-managed default is the opposite of what the SaaS behavior suggests.

Self-managed Community EditionGitLab.com
General API throttlingDisabled by defaultEnabled
Documented budget7,200 requests per hour per user, once enabled2,000 authenticated API requests per minute
RateLimit-* headersAbsent while throttling is offPresent
Note-creation limit300 per minute60 per minute
Search API limit30 per minute10 per minute per IP

The hosted product is the stricter one on note creation, so a deployment tuned against a self-managed instance can hit the comment limit on GitLab.com.

GitLab.com returns the IETF-style headers RateLimit-Limit, RateLimit-Name, RateLimit-Observed, RateLimit-Remaining, and RateLimit-Reset. GitLab documents two more on a 429, RateLimit-ResetTime and Retry-After. These are not GitHub’s X-RateLimit-* names; header names copied from the GitHub adapter match nothing here.

The adapter parses no RateLimit-* header and applies no preemptive throttling. On a 429 it reads Retry-After only to log a WARN, maps the status to tracker_api_error, and leaves backoff to the orchestrator’s retry classification. Poll cadence is the pressure control.

A self-managed 429 body is not guaranteed to be JSON: the response text is an operator-configurable instance setting. The adapter never requires a parseable body to classify a 429.


Error model

The adapter maps the HTTP status to a domain.TrackerErrorKind.

HTTP statusConditionError kind
2xxSuccess(none)
400Parameter or model validation, including a parameter enum missing an Enterprise Edition valuetracker_payload_error
401Missing, invalid, revoked, or expired tokentracker_auth_error
403Insufficient token scope, a license-gated feature, or a route requiring administratortracker_auth_error
404Missing issue, missing project, or a project the credential cannot seetracker_not_found
409Conflicttracker_api_error. Not exercised: no 409 was provoked on any adapter route during research.
414Request URI too large, from an over-long iids[] batchtracker_payload_error. Documented by GitLab, not observed; the adapter prevents it by chunking.
422Unprocessable entitytracker_payload_error. Not exercised: validation failures arrived as 400 on the issue surface.
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

The 414 arm 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 construction 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.
Empty or whitespace-only label on AddLabelAttaches nothing, issues no request, returns nil, and logs a WARN, so a caller reading nil as a successful escalation is not the only record.
Label catalog unavailable during AddLabelLogs 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.


Adapter registration

The adapter registers itself under kind "gitlab" via an init function in internal/scm/gitlab. Registration metadata declares:

PropertyValue
RequiresProjecttrue
RequiresAPIKeytrue
DefaultActiveStates["backlog", "in-progress", "review"]
DefaultTerminalStates["done", "wontfix"]
ValidateTrackerConfigOffline config diagnostics for sortie validate.

The orchestrator’s preflight validation uses RequiresProject and RequiresAPIKey to produce specific error messages before adapter construction, and resolves the adapter through the registry rather than by importing the package.

The package sits under the source-control adapter family rather than at internal/tracker/gitlab, because forge integrations live in one package per forge and GitLab’s issue and merge-request halves share their authentication, project addressing, pagination, error envelopes, and comment entity. This package registers the tracker role only; the source-control and CI roles are not implemented for this forge.


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 parser the constructor uses.

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. The constructor substitutes https://gitlab.com.
A one-slash rule on projectGitLab subgroups nest to any depth.
Token prefix or lengthThe prefix is an administrator-writable application setting.

The untrimmed-state-label warning exists here and not in the sibling GitHub and Gitea validators, because this adapter lowercases configured states without trimming them: a padded label can never match a normalized issue label, and a write would create a padded label.


Community Edition, Enterprise Edition, and GitLab.com

Every TrackerAdapter operation is available on self-managed Community Edition 19.2.1, verified live, with exactly one degradation in the normalized issue model and none in behavior. That degradation is BlockedBy. GitLab’s blocking issue-link types are a paid feature, so the relation is structurally unavailable on Community Edition and domain.Issue.BlockedBy normalizes to a non-nil empty slice. The adapter does not populate it from relates_to links: a “relates to” edge carries no direction and no blocking semantics, and inventing blockers would suppress dispatch for merely cross-referenced issues.

Community Edition is the compatibility floor. The adapter depends only on what it provides. No minimum GitLab version is claimed; the facts on this page hold for the pinned version’s defaults.

Two caveats belong with that verdict. No self-managed Enterprise Edition instance was available during adapter research, so every Enterprise Edition claim rests on documentation and upstream source reading rather than observation. And GitLab.com, though built from the Enterprise Edition codebase, is not a self-managed Enterprise Edition deployment; the two are not interchangeable. GitLab.com on the Free plan is likewise not Community Edition: it gates features by namespace subscription plan rather than by which code is loaded, so the same gap surfaces as a different error.

ProductCodebaseFeature gating
Self-managed Community EditionCommunity Edition onlyNothing to gate. Enterprise routes and parameters are absent from the running API.
Self-managed Enterprise EditionCommunity Edition plus the Enterprise treeLicense tier gates features at the service layer.
GitLab.comEnterprise EditionNamespace subscription plan gates features.

Surface the adapter avoids

FeatureCommunity Edition behavior
Blocking issue links (link_type=blocks, is_blocked_by)HTTP 400: the value is not in the enum. On GitLab.com Free the same request returns 403 naming the license.
Issue weight, write and filterNot an accepted update parameter; silently ignored as a filter.
epic_id, epic_iidAbsent from the accepted parameter set.
Multiple assigneesHTTP 400 for a second assignee_username value.
Scoped-label mutual exclusionNot enforced; both labels coexist.
iteration_id, iteration_title, health_statusDeclared only in the Enterprise tree.
blocking_issues_count issue fieldAbsent from the issue object.

Key differences from the Gitea and GitHub adapters

All three are forge platforms with label-driven state, but nearly every value a reader looks up differs.

AspectGitHubGiteaGitLab
Default hosthttps://api.github.comNone; endpoint is requiredhttps://gitlab.com; endpoint is optional
Auth headerAuthorization: Bearer <token>Authorization: token <key>PRIVATE-TOKEN: <key> (Bearer also accepted; adapter sends PRIVATE-TOKEN)
Token shapePrefixed (ghp_, github_pat_)40 hex characters, no prefixPrefixed, but the prefix is an instance setting, so no shape check
Least-privilege tokenFine-grained PAT, per-repositoryScopes onlyProject access token, single project enforced by the server
Permission modelPer-resource permissionsread: / write: scopesCoarse api / read_api only
Token introspectionn/aScopes visible only inside a 403 bodyGET /personal_access_tokens/self: scopes, active, revoked, expiry
Issue identifierRepo-scoped numberRepo-scoped indexProject-scoped iid; a global id exists and is inaccessible
Project scopingowner/repo, one slashowner/repo, one slashNumeric ID or percent-encoded path, any number of slashes
Non-issue items in the listPull requests, excluded by a key checkPull requests, excluded by type=issuesTasks, incidents, test cases, excluded by issue_type=issue
Default list stateOpenOpenAll; must be set explicitly
labels filterAND, case-insensitiveAND, case-sensitive, unresolvable name drops the filterAND, case-sensitive, unresolvable name returns an empty set
Unknown query parameterErrorIgnored; adapter warns and forwardsSilently ignored; adapter rejects at construction against an allowlist
Unknown label on attachHTTP 200, label created by the endpointHTTP 200, silently ignoredHTTP 200, label created by the server
Label case handlingCase-insensitiveCase-sensitiveCase-sensitive and auto-creating, so a case variant creates a duplicate
Transition costSeveral requestsUp to five requestsOne read plus one PUT
Batch state lookupPer-issue or searchPer-issue loopOne request per 50 iids
Comments routePaginatedUnpaginatedPaginated, and system notes must be filtered
Comment default ordern/aOldest-firstNewest-first; sort=asc requested
Page-size maximum100 (per_page)50 (limit)100 (per_page)
Rate limits5,000/hr core plus 30/min searchNone built-inOff by default self-managed; 2,000/min on GitLab.com with a stricter 60/min note-creation limit
Rate-limit header namesX-RateLimit-*NoneRateLimit-* (IETF style), absent when throttling is off
BlockedByDependencies endpointDependencies endpointUnavailable on Community Edition; always empty
Error envelopeVaried shapesUniform {"message", "url"}Four shapes; message and error both read
Unauthorized resource404404404, byte-identical to a missing project
GraphQLAvailable, used for review decisionsNot availableAvailable, not needed for the tracker

See the GitHub adapter reference and the Gitea adapter reference.


Related pages

Was this page helpful?