gitlab

package
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package gitlab provides a wrapper around the GitLab REST API v4 client. Some domains additionally use the GitLab GraphQL API for endpoints not covered by client-go service wrappers (see ADR-0006).

The package also detects Personal Access Token scopes so HTTP server entries can register tools according to the authenticated token's capabilities.

Resilience

Client initialization is lazy and recoverable. When GitLab is temporarily unavailable at startup, the server can enter degraded mode and retry initialization on later API calls with rate-limited health checks. This keeps local MCP startup responsive while still surfacing actionable errors to tool handlers.

Edition and Scope Detection

The wrapper tracks GitLab.com and Premium/Ultimate detection separately from token-scope detection. HTTP mode uses that information to register the correct tool catalog for each token and GitLab URL pair.

Index

Constants

View Source
const AllowPrivateInstancesEnv = config.EnvPrefix + "ALLOW_PRIVATE_INSTANCES"

AllowPrivateInstancesEnv names the setting that permits a destination a caller or a redirect chose to resolve to a private, loopback, CGNAT, link-local, unique-local or unspecified address.

It is the opt-out for tier B and only tier B: the cloud metadata addresses are refused whatever it says, because no value of this setting is a claim that a GitLab API or a presigned object store is served from one.

The `--allow-private-instances` flag writes this variable, so there stays one reader; see cmd/server/env_flags.go for why that indirection exists.

View Source
const DefaultMaxResponseBytes int64 = 64 << 20

DefaultMaxResponseBytes is how much of one GitLab response body a client will read before giving up on it.

The number bounds the *decompressed* stream, which is the only place it can do any good: net/http gunzips a Content-Encoding: gzip response inside the base transport, so a cap applied to wire bytes bounds the compressed size and misses the amplification entirely. A 528 KB gzip that inflates to 67 MB is an ordinary artifact of a hostile or compromised upstream, and the SDK decodes whatever it is handed with no ceiling of its own.

64 MiB is chosen to sit above every response this server asks for in practice — the largest are job traces, which the jobs package truncates to 100 KB of its own accord, and artifact and export downloads, which are base64-encoded into a JSON-RPC response and are already unusable long before this — while being far below the multi-gigabyte working set an unbounded decode reaches. Raise it with Client.SetMaxResponseBytes if a deployment genuinely streams more than that through a tool.

View Source
const DestinationRefusedHint = allowPrivateAdvice + ". Cloud metadata addresses stay refused whatever it is set to"

DestinationRefusedHint is the next step to offer a model or an operator who has just been told a destination was refused.

It names the flag rather than describing the policy: the policy is already in the error, and what the reader does not have is the one string that changes it. Tier A has no such string, which the sentence says out loud rather than leaving a reader to try the flag and find it did nothing.

View Source
const GitLabDotComHost = "gitlab.com"

GitLabDotComHost is the canonical host for GitLab SaaS-only features.

View Source
const ScopeAPI = "api"

ScopeAPI is the GitLab scope that permits writes.

Only the write scope is named here, and deliberately: this package answers one question — can this token mutate GitLab — and the read scope is not part of that answer. internal/oauth owns the full scope vocabulary for the authorization layer; duplicating it here would be a second place to keep in step with GitLab.

Variables

View Source
var ErrDestinationRefused = errors.New("outbound destination refused")

ErrDestinationRefused reports that this server declined to open a connection to an address, before any packet was sent to it.

It is a refusal by this server rather than an answer from anything upstream, which is the distinction a reader of the message needs: nothing was reached, nothing was disclosed, and the fix is configuration rather than credentials.

View Source
var ErrNoResponseCaptured = errors.New("no response was captured")

ErrNoResponseCaptured is what ResponseCapture.Decode returns when no response passed through the transport under the capture's context: the request was never made, or was made with another context.

View Source
var ErrResponseTooLarge = errors.New("gitlab response exceeded the maximum size")

ErrResponseTooLarge reports that a GitLab response body exceeded the client's size ceiling and was abandoned rather than buffered.

It surfaces from whatever was reading the body — the SDK's JSON decode, an io.Copy into a caller's writer — so a tool handler sees a failed call rather than a truncated success.

View Source
var ErrUnboundClient = errors.New("gitlab client is unbound: the handler belongs to a shared catalog and was not bound to a credential")

ErrUnboundClient is what every request through an unbound client fails with. A handler that reports it was called through the shared, unbound copy of a catalog instead of through a copy bound to a credential, which is a wiring defect and never a user error.

Functions

func CheckCallerNamedInstance

func CheckCallerNamedInstance(rawURL string) error

CheckCallerNamedInstance refuses an instance URL a caller named that this server would decline to dial anyway.

It exists to answer at the door instead of deep inside a tool call. The dialer is still the authority — this consults the same predicate and the same opt-out — and this is the friendlier half: an operator who pointed the header at their own private GitLab gets one 400 naming the flag, rather than a server that starts, admits the credential and fails every action.

It resolves nothing. A host spelled as a literal address is judged here; a host spelled as a name is left entirely to the dialer, which sees what the name resolved to and is the only check that can. Asking DNS a question per request at the door would put a resolver timeout in front of every caller and would still not be authoritative.

func DetectScopes

func DetectScopes(ctx context.Context, client *gl.Client) []string

DetectScopes queries the GitLab PAT self endpoint to retrieve the scopes of the currently authenticated token. Returns nil on failure or when the endpoint is unavailable (GitLab < 16.0), allowing graceful fallback to registering all tools.

func HTTPTransport

func HTTPTransport(skipTLSVerify bool) http.RoundTripper

HTTPTransport returns the GitLab HTTP transport configured with the same TLS policy used by authenticated GitLab clients.

func IsCredentialRejection

func IsCredentialRejection(err error) bool

IsCredentialRejection reports whether err is GitLab judging the credential, as opposed to any of the many ways a request can fail without producing a verdict about it.

Only an explicit 401 or 403 counts, the same rule Client.CredentialRejected applies to its own probe. A transport error, a timeout, a 404 and a 5xx all mean the question went unanswered, and treating those as a rejection turns a GitLab that is briefly unreachable into a mass revocation.

func IsGitLabDotComURL

func IsGitLabDotComURL(rawURL string) bool

IsGitLabDotComURL reports whether rawURL targets the canonical GitLab.com host.

func NarrowToTokenScope

func NarrowToTokenScope(cfg *config.ServerConfig) bool

NarrowToTokenScope marks a server configuration read-only when the token it was built for cannot write, and reports whether it did. Both transports call it once the scopes are known: the HTTP pool per entry, since an entry is per token, and stdio once at startup for its single token. The catalog built from the configuration then withholds every write action and reports it as withheld by the token scope, rather than listing actions GitLab would refuse one by one with its own 403 (ADR-0018).

A configuration already read-only is left alone, so the operator's setting and the token's limit never contradict each other in the log, and unknown scopes narrow nothing, for the reason WriteCapable gives.

func ScopeSatisfied

func ScopeSatisfied(tokenScopes, requiredScopes []string) bool

ScopeSatisfied checks whether requiredScopes are all present in the detected tokenScopes. If tokenScopes is nil (detection failed or disabled), returns true (allow all). If requiredScopes is empty, returns true (no requirement).

func WithClient

func WithClient(ctx context.Context, c *Client) context.Context

WithClient returns ctx carrying the client every handler running under it must use.

It is the channel that lets one github.com/modelcontextprotocol/go-sdk/mcp.Server serve many credentials. A server built once per configuration shape registers handlers that capture the credential-less client from NewUnboundClient; the HTTP layer resolves the pool entry for each request and installs that entry's client here, and Client.For is what every handler reads it back through.

A nil client is not stored. "Bound to nothing" and "not bound" would then be indistinguishable to Client.For, and the difference decides whether a handler runs under the caller's credential or under the one that refuses every request.

func WriteCapable

func WriteCapable(scopes []string) bool

WriteCapable reports whether a token's scopes permit mutating GitLab.

Unknown scopes (nil: detection failed, was disabled, or the instance is too old to answer) count as write-capable. Assuming otherwise would silently strip every mutating tool from a deployment whose token is perfectly able to use them, and a wrong "no" is invisible — the tools are simply not there — while a wrong "yes" surfaces as GitLab's own 403 on the call that actually tried to write.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps the official GitLab API client with project-specific configuration. It includes connection resilience: when GitLab is unreachable at startup, the server enters degraded mode and automatically recovers when connectivity is restored.

func ClientFrom

func ClientFrom(ctx context.Context) (*Client, bool)

ClientFrom returns the client bound to ctx, and whether there was one.

Client.For is what handlers use; this is for the few callers that must distinguish "no credential is bound" from "the fallback was returned", such as a middleware deciding whether it has anything to install.

func NewClient

func NewClient(cfg *config.Config) (*Client, error)

NewClient creates an authenticated GitLab client from the provided configuration. When cfg.SkipTLSVerify is true, TLS certificate verification is disabled (for self-signed certs). The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable at startup.

func NewClientWithToken

func NewClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)

NewClientWithToken creates an authenticated GitLab client with explicit parameters. Unlike NewClient, it does not require a full config.Config and is designed for use in the server pool where each client has a unique token but shares the same base URL and TLS settings. The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable.

func NewClientWithTokenRetries

func NewClientWithTokenRetries(baseURL, token string, skipTLSVerify, disableRetries bool) (*Client, error)

NewClientWithTokenRetries is NewClientWithToken with the retry policy exposed: disableRetries turns off client-go's retryablehttp wrapper (RetryMax 5, linear backoff), which unit tests need — a pooled client probing a mock that answers 5xx otherwise sleeps through the whole credential-check deadline instead of reading the answer it already has.

func NewOAuthClientWithToken

func NewOAuthClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)

NewOAuthClientWithToken creates a GitLab client that authenticates with "Authorization: Bearer" instead of the PRIVATE-TOKEN header. The server pool uses it in oauth HTTP mode, where every credential arrives as a Bearer token: an OAuth access token (gloas-...) is ONLY valid as Bearer — GitLab rejects it in PRIVATE-TOKEN — while personal access tokens are valid in both schemes, so forwarding exactly as received is correct for every token kind the mode admits.

func NewUnboundClient

func NewUnboundClient(baseURL string) *Client

NewUnboundClient returns a client for baseURL that carries no credential and refuses every request with ErrUnboundClient.

It exists for the catalog cached per configuration and shared by every pooled server. The catalog is built once, through the same ActionSpecs functions a bound client would be handed, and those inspect the client only for the instance class (GitLab.com or self-managed), which the URL answers. Building it with a real client would capture that client's credential in a process-wide cache; building it with this one captures nothing that can reach GitLab, so a handler served from the shared copy by mistake fails closed instead of running under someone else's token.

It panics when baseURL does not parse: the URL is a constant chosen by the caller, so a bad one is a programming error rather than a condition to handle.

func (*Client) CredentialRejected

func (c *Client) CredentialRejected(ctx context.Context) bool

CredentialRejected reports whether GitLab actively refuses this credential.

It issues GET /api/v4/user through the raw health client rather than the SDK on purpose: client-go wraps requests in retryablehttp with RetryMax 5 and a linear backoff, which turns a refused connection or a struggling instance into seconds of stalling. A liveness question about a credential should be asked once and answered fast.

Only an explicit 401 or 403 counts as a rejection. Every other outcome — a transport error, a 404 from a stubbed instance, a 5xx — means no verdict was obtained and is reported as false, so callers fail open.

func (*Client) CurrentUser

func (c *Client) CurrentUser(ctx context.Context) (*CurrentUserInfo, error)

CurrentUser returns the identity of the authenticated GitLab user. It calls the /user API endpoint and returns both the numeric ID and username.

func (*Client) CurrentUsername deprecated

func (c *Client) CurrentUsername(ctx context.Context) (string, error)

CurrentUsername returns the username of the authenticated GitLab user.

Deprecated: Use Client.CurrentUser which returns both ID and username.

func (*Client) DetectEnterprise

func (c *Client) DetectEnterprise(ctx context.Context, fallback bool) bool

DetectEnterprise updates the client edition flag from /api/v4/version when GitLab exposes it, returning fallback when the field is absent or detection fails.

func (*Client) DetectTier

func (c *Client) DetectTier(ctx context.Context) edition.Tier

DetectTier resolves the GitLab licensing tier from the instance license and stores it on the client. It calls the License API (GET /license, admin-only on self-managed) and maps the returned plan to a tier via edition.TierFromPlan.

When the license cannot be retrieved (non-admin token, CE/Free instance, or any API error) or reports no plan, it falls back to edition.Free. The resolved tier is stored and returned.

func (*Client) EnableLazyInit

func (c *Client) EnableLazyInit()

EnableLazyInit enables lazy re-initialization on subsequent API calls. Called when startup Initialize() fails so that the server can recover automatically when GitLab becomes available again.

func (*Client) EnsureInitialized

func (c *Client) EnsureInitialized(ctx context.Context)

EnsureInitialized attempts lazy re-initialization if the client was not initialized at startup (e.g. GitLab was down). This allows automatic recovery when GitLab becomes available again. Thread-safe via initMu. Includes a 30-second cooldown between attempts to avoid hammering GitLab. Called automatically by [resilienceTransport] on every SDK request.

func (*Client) For

func (c *Client) For(ctx context.Context) *Client

For returns the client bound to ctx, or the receiver when ctx carries none.

The receiver is the fallback rather than the answer, which is what makes the shared-server arrangement fail closed: on a shape server the captured client is the unbound one, so a handler reached without a bound context refuses with ErrUnboundClient instead of running under whichever credential happened to build the shape. On stdio, and in every test that registers with a real client, no context ever carries one and the receiver is simply returned, so the call is invisible.

A nil receiver is allowed and returns whatever the context carries, so a caller holding no fallback can still resolve one.

func (*Client) GL

func (c *Client) GL() *gl.Client

GL returns the underlying gitlab client for use in tool handlers.

func (*Client) Initialize

func (c *Client) Initialize(ctx context.Context) (string, error)

Initialize validates GitLab connectivity via a direct HTTP health check (bypassing the SDK transport chain to avoid recursion). On success it marks the client as initialized and returns the GitLab version string.

func (*Client) IsEnterprise

func (c *Client) IsEnterprise() bool

IsEnterprise reports whether the GitLab instance is Premium/Ultimate, derived from the resolved tier (tier >= Premium).

func (*Client) IsGitLabDotCom

func (c *Client) IsGitLabDotCom() bool

IsGitLabDotCom reports whether the client is configured for GitLab.com.

func (*Client) IsInitialized

func (c *Client) IsInitialized() bool

IsInitialized returns true if Initialize() completed successfully.

func (*Client) IsUnbound

func (c *Client) IsUnbound() bool

IsUnbound reports whether this client is the credential-less one, which refuses every request with ErrUnboundClient.

It exists so a surface can refuse early and say why. A resource read or a completion resolved to this client has not failed at GitLab and never will reach it: the request could not be attributed to a credential, which is a wiring defect on this side. Asked after Client.For, it is the question "did this request bring a credential", and answering it before the call is what keeps the caller from being told their token lacks access to something.

A nil client is not unbound: it is no client at all, which every reader already handles.

func (*Client) MarkInitialized

func (c *Client) MarkInitialized()

MarkInitialized sets the initialized flag without running the full Initialize flow. Intended for test setups where the client is preconfigured with a token or mock credentials.

func (*Client) MarkInstanceCallerNamed

func (c *Client) MarkInstanceCallerNamed()

MarkInstanceCallerNamed records that this client's instance was named by a caller rather than by the operator, which is the --allow-any-gitlab-url hatch, and puts its very first hop under tier B.

It is called by the server pool, which is the only place that knows: a client is built from a URL string, and the string looks identical whether --gitlab-url published it or a GITLAB-URL header did.

The default is the other way round because the alternative breaks every ordinary deployment: a client whose instance nobody marked is one the operator configured, and refusing those would refuse GitLab on localhost.

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) (string, error)

Ping validates connectivity and authentication by calling the GitLab version endpoint. Returns the GitLab version string on success. Callers should wrap ctx with context.WithTimeout to bound the network round-trip.

func (*Client) SetEnterprise

func (c *Client) SetEnterprise(v bool)

SetEnterprise marks the client as connected to a Premium/Ultimate instance. It sets the tier to Premium when v is true and Free when v is false; callers needing the Premium/Ultimate distinction should use Client.SetTier.

func (*Client) SetMaxResponseBytes

func (c *Client) SetMaxResponseBytes(n int64)

SetMaxResponseBytes sets how much of a single response body this client will read. A value of zero or less removes the ceiling.

The ceiling applies from the next request onwards; a body already being read keeps the limit it started with.

func (*Client) SetOnUnauthorized

func (c *Client) SetOnUnauthorized(fn func())

SetOnUnauthorized registers fn to run the first time GitLab answers a call made with this client's credential with 401. fn runs on the calling goroutine, once; a nil fn clears it.

func (*Client) SetTier

func (c *Client) SetTier(t edition.Tier)

SetTier records the resolved GitLab licensing tier for this client.

func (*Client) Tier

func (c *Client) Tier() edition.Tier

Tier returns the resolved GitLab licensing tier for this client.

type CurrentUserInfo

type CurrentUserInfo struct {
	UserID   int
	Username string
}

CurrentUserInfo holds the identity of the authenticated GitLab user.

type ResponseCapture

type ResponseCapture struct {
	// contains filtered or unexported fields
}

ResponseCapture holds the body of the last response a request made under WithResponseCapture received, so that a handler can read from it a field GitLab sends and client-go's struct does not model, from the same bytes the SDK decoded, without a second request and without spelling the route itself.

It exists because the 1:1 surface has no room for a field GitLab sends and this server drops, and client-go models most of the API but not all of it: a note's `imported_from`, a member's `membership_state`, a key's `expires_at`. Before this the answer was a request of the handler's own, which duplicates the route client-go already spells and, for a list, its pagination too. A capture leaves every request as it was and adds one decode of bytes already in memory.

Safe for use from one goroutine at a time, which is what a handler is; the transport records under a lock because a retried request answers more than once, and the last answer is the one the SDK decoded.

func CapturedBody

func CapturedBody(body []byte) *ResponseCapture

CapturedBody returns a capture already holding body, as if a request made under it had been answered with it. It is for a test of a reader that decodes captures, which would otherwise need a transport to hand it one.

func WithResponseCapture

func WithResponseCapture(ctx context.Context) (context.Context, *ResponseCapture)

WithResponseCapture returns a context under which the SDK client records the body of every response it receives, and the capture to read it from. Pass the context to the SDK call with gl.WithContext.

func (*ResponseCapture) Decode

func (c *ResponseCapture) Decode(v any) error

Decode unmarshals the captured body into v, the way the SDK unmarshalled it into its own struct. A body that does not decode is an error a handler reports rather than swallows: the SDK decoded the same bytes, so the fault is in the type v names.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL