feat(gitlab): typed connector for GitLab REST API - #22
Merged
Conversation
Adds a first-class gitlab connector covering 10 curated operations
plus a raw-request escape hatch:
Read: gitlab_list_projects, gitlab_get_file, gitlab_list_issues,
gitlab_list_mrs, gitlab_get_mr, gitlab_search_blobs
Write: gitlab_put_file, gitlab_create_issue, gitlab_comment_issue,
gitlab_create_mr
Raw: gitlab_request (escape hatch for any /api/v4 path)
Architectural choices and how they differ from the github connector:
- Single PAT per connection, not the github connector's multi-credential
shape. GitLab PATs authenticate as the token owner without per-
namespace scoping at the auth layer, so a multi-cred model would be
premature complexity. Operators needing separate trust boundaries
per namespace add separate connections.
- Generic data-driven create + edit via SetupFields (token Required+
Secret, base_url optional). No bespoke /connections/gitlab/...
handler — the post-PR-21 form mechanism handles this cleanly.
- base_url defaults to https://gitlab.com but can be overridden for
self-hosted instances.
- Validate semantics match anthropic post-#19: returns ErrNeedsReauth
ONLY on 401/403 (token rejected / scope mismatch). Transient 5xx,
network errors, and unexpected shapes leave Validate succeeding so
outages don't block saving the connection.
Hardening mirrors the github connector:
- Response cap at 5 MiB (same as the LLM evaluator + github cap).
- 60s request timeout, redirects disabled.
- validateRelativePath rejects '..', backslashes, and iteratively-
decoded encoded-traversal patterns. The validator works on the fully
decoded path so embedded-slash-in-segment encoding (the legitimate
GitLab convention for namespaced project paths) flattens to literal
'/' before checking, while %2e (encoded dot) is still caught.
URL-encoding contract: project identifiers and file paths are passed
through url.PathEscape so "group/sub/project" and "docs/setup.md"
become single API path segments with embedded slashes as %2F (GitLab's
required encoding). The test pins this against r.URL.RawPath since
Go's URL parser stores Path in decoded form.
Default narrowing on gitlab_list_projects: membership defaults to true
so an agent that omits the param gets the user's own projects, NOT
every project visible to the token (which on a public-instance GitLab
could be millions of rows). Agents must explicitly opt out.
Registered in cmd/sieve/main.go. 23 tests cover config validation,
auth header, response size cap, path traversal, Validate semantics,
URL encoding, default narrowing, op-shape catalog regression guard,
and the request escape hatch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a first-class typed GitLab connector (mirroring the GitHub connector patterns where applicable) and registers it in the main connector registry, enabling policy-scoped GitLab REST operations plus a raw request escape hatch.
Changes:
- Introduces
internal/connectors/gitlabwith hardened HTTP client, config parsing, and a curated v1 operation catalog (plusgitlab_requestescape hatch). - Adds a comprehensive test suite covering config validation, auth header behavior, response cap, path traversal defenses, Validate semantics, and URL-encoding expectations.
- Registers the GitLab connector in
cmd/sieve/main.goso it becomes available in the running binary.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/connectors/gitlab/ops.go | Defines GitLab operation catalog + per-op request construction helpers. |
| internal/connectors/gitlab/gitlab.go | Implements connector interface, metadata (SetupFields), Validate semantics, and Execute dispatch. |
| internal/connectors/gitlab/gitlab_test.go | Adds extensive tests for config, request hardening, encoding, op shape, and Validate behavior. |
| internal/connectors/gitlab/config.go | Adds config decoding/normalization and validation for token + base_url. |
| internal/connectors/gitlab/client.go | Adds hardened HTTP client, response cap enforcement, header filtering, and traversal-safe path validation. |
| cmd/sieve/main.go | Registers the new GitLab connector in the connector registry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two Copilot follow-ups, both in ops.go: 1. encodeRefOrPath's comment was self-contradictory — it said the function "preserves the slashes" when the whole point is to percent-encode them as %2F so the value stays a single path segment. Rewrote the comment to describe what the function does (and why), with a concrete example of how GitLab's files endpoint parses the result. 2. gitlab_request's `method` param documented GET/POST/PUT/PATCH/ DELETE but the implementation also accepted HEAD and OPTIONS. Aligned the description with the implementation (HEAD is useful for cheap existence checks, OPTIONS for endpoint capability probing on self-hosted instances). Noted case-insensitive so policy authors don't have to guess. No code-behaviour change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two Copilot follow-ups: 1. gitlab_request documented `path` as "relative to /api/v4" but doRequest unconditionally prepends apiPrefix. An agent that read the GitLab docs and naturally typed /api/v4/projects would land at /api/v4/api/v4/projects and get a confusing 404. opRequest now strips a leading /api/v4 from the supplied path so both conventions work; the bare prefix (/api/v4 or /api/v4/) is rejected because "make a request to the API prefix itself" isn't meaningful. Three subtests pin the contract: both shapes route to the same endpoint, and the bare prefix is loudly refused. 2. validateRelativePath now includes %2f alongside %2e and %5c in the over-encoded-sequence rejection, matching the github connector's hardening. Legitimate single-pass %2f use (the embedded-slash encoding inside project identifiers and file paths constructed by encodeProject / encodeRefOrPath) fully decodes in one iteration and reaches the check as a literal '/', which is allowed. Only inputs whose encoding survives all five passes get rejected — TestValidateRelativePath_RejectsSurvivingEncodedSlash uses a five-layer-wrapped %25252525252f to exercise this; the complementary TestValidateRelativePath_AllowsSinglePassEncodedSlash pins that legitimate single-pass %2F (encodeProject output) still passes, so we don't accidentally break every gitlab_get_file call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot caught that the doc claimed curated ops "return this after
they unmarshal", but the curated ops in ops.go return doRequest's
*httpResponse verbatim — there's no per-op unmarshal step. Rewrote
the comment to describe what actually happens: every op (curated
plus the gitlab_request escape hatch) returns the same {status,
headers, body} wrapper, with Body carrying raw upstream JSON (or the
{"raw": "..."} wrapper for non-JSON payloads like files/raw).
No behaviour change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three Copilot follow-ups: 1. (Bug) gitlab_request dropped a literal JSON `null` body. The previous shape parsed body bytes with json.Unmarshal into an `any`, which sets the target to nil on a top-level null. The value then reached doRequest's nil-check and the entire body marshaling step was skipped — the upstream request went out with no body and no Content-Type header, observably different from a request with body=null. Fix: pass the validated bytes through as json.RawMessage so doRequest's json.Marshal returns them byte-for-byte. TestOpRequest_PreservesJSONNullBody pins null specifically; TestOpRequest_PreservesJSONBodyByteForByte pins the broader contract (precision, key ordering, whitespace). 2. & 3. gitlab.go and config.go both mentioned "OAuth token" support but the implementation only sends the PRIVATE-TOKEN header. Rewrote both comments to accurately describe v1 (PAT-only via PRIVATE-TOKEN) and called out the cleanest add path if OAuth support is ever needed (a token_type field on Config selecting between PRIVATE-TOKEN and Authorization: Bearer at request time). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot follow-up: encodeProject and encodeRefOrPath always called url.PathEscape on the caller-supplied string, which silently double-encoded inputs that arrived already URL-encoded. The bug: an agent reading GitLab's docs may paste "group%2Fsubgroup%2Fproj" as the project identifier (that's the shape every code sample on docs.gitlab.com shows). The old code re-encoded it to "group%252Fsubgroup%252Fproj", which GitLab parses as a literal project name "group%2Fsubgroup%2Fproj" — a different identifier from the intended one — and 404s. Both helpers now try url.PathUnescape first to canonicalise pre- encoded inputs, then re-escape. PathUnescape is idempotent on values without percent sequences, so plain "group/sub/proj" passes through unchanged. Malformed percent sequences (e.g. "%ZZ") leave PathUnescape returning an error and we fall back to the original string — GitLab 404s either way but the connector keeps running. Two new tests pin the contract: - TestOpGetFile_AcceptsPreEncodedProject covers raw, pre-encoded, and mixed forms (acme/subgroup/widget, acme%2Fsubgroup%2Fwidget, acme%2Fsubgroup/widget) all routing to the same upstream URL. - TestOpGetFile_AcceptsPreEncodedFilePath covers the symmetric case for the file-path component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot follow-up: gitlab_request previously validated the body via json.Unmarshal into an `any`. That works for round-trip preservation (the RawMessage carries the original bytes regardless) but it forces numbers through float64 in the throwaway parsed structure just to discard it. That's both wasted allocation and a footgun if the next refactor naively uses the parsed value. json.Valid is the canonical syntax-only check: no allocation, no numeric coercion, no rejection of large integer literals on the validation path. Existing TestOpRequest_PreservesJSONBodyByteForByte already covers a 9007199254740993 literal end-to-end and continues to pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First-class GitLab connector mirroring the github connector's shape where they overlap, deliberately simpler where they don't.
Operations
gitlab_list_projectsgitlab_get_filegitlab_put_filegitlab_list_issuesgitlab_create_issuegitlab_comment_issuegitlab_list_mrsgitlab_get_mrgitlab_create_mrgitlab_search_blobsgitlab_requestHow it differs from the github connector
tokenRequired+Secret,base_urloptional). No bespoke/connections/gitlab/...handler — the post-refactor(web): data-drive connection forms from connector.SetupFields #21 form mechanism handles both create and edit cleanly.base_urloverride for self-hosted GitLab instances; defaults tohttps://gitlab.com.Validatesemantics match anthropic post-feat(anthropic): typed connector for Anthropic Messages API #19: returnsErrNeedsReauthONLY on 401/403 (token rejected, scope mismatch). Transient 5xx, network errors, and unexpected shapes leaveValidatesucceeding so an outage doesn't block saving the connection — the error will surface on first agent call.Hardening (mirrors github)
validateRelativePathrejects.., backslashes, and iteratively-decoded encoded-traversal patterns. The check works on the fully decoded path so embedded-slash-in-segment encoding (the legitimate GitLab convention for namespaced project paths) flattens to literal/before checking, while%2e(encoded dot) is still caught.URL-encoding contract
Project identifiers and file paths are passed through
url.PathEscape, so\"group/sub/project\"and\"docs/setup.md\"become single API path segments with embedded slashes as%2F. The test pins this againstr.URL.RawPathsince Go's URL parser storesPathin decoded form (would silently flatten the encoding).Default narrowing on
gitlab_list_projectsmembershipdefaults totrueso an agent that omits the param gets the user's own projects, NOT every project visible to the token (which on a public-instance GitLab could be millions of rows). Agents must explicitly passmembership=falseto opt out.Test plan
go test -race -count=1 ./...clean.Followups
connections.html(currently the generic catalog fallback renders only id + display_name; the data-driven create form for typed connectors is a separate UI improvement).gitlab_get_pipeline/gitlab_trigger_pipelinefor CI workflows.🤖 Generated with Claude Code