Skip to content

feat(gitlab): typed connector for GitLab REST API - #22

Merged
murbard merged 7 commits into
mainfrom
feat/gitlab-connector
Jun 16, 2026
Merged

feat(gitlab): typed connector for GitLab REST API#22
murbard merged 7 commits into
mainfrom
feat/gitlab-connector

Conversation

@murbard

@murbard murbard commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

First-class GitLab connector mirroring the github connector's shape where they overlap, deliberately simpler where they don't.

Operations

# Op Method ReadOnly
1 gitlab_list_projects GET /projects
2 gitlab_get_file GET /projects/{id}/repository/files/{path}
3 gitlab_put_file POST or PUT
4 gitlab_list_issues GET /projects/{id}/issues
5 gitlab_create_issue POST /projects/{id}/issues
6 gitlab_comment_issue POST /projects/{id}/issues/{iid}/notes
7 gitlab_list_mrs GET /projects/{id}/merge_requests
8 gitlab_get_mr GET
9 gitlab_create_mr POST
10 gitlab_search_blobs GET /projects/{id}/search?scope=blobs
11 gitlab_request any (raw escape hatch)

How it differs from the github connector

  • Single PAT per connection instead of github's multi-credential shape. GitLab PATs auth as the token owner without per-namespace scoping, so a multi-cred model would be premature. Operators needing per-namespace trust boundaries add separate connections.
  • Generic data-driven create + edit via SetupFields (token Required+Secret, base_url optional). 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_url override for self-hosted GitLab instances; defaults to https://gitlab.com.
  • Validate semantics match anthropic post-feat(anthropic): typed connector for Anthropic Messages API #19: returns ErrNeedsReauth ONLY on 401/403 (token rejected, scope mismatch). Transient 5xx, network errors, and unexpected shapes leave Validate succeeding so an outage doesn't block saving the connection — the error will surface on first agent call.

Hardening (mirrors github)

  • 5 MiB response cap (matches github + LLM evaluator).
  • 60s timeout, redirects disabled.
  • validateRelativePath rejects .., 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 against r.URL.RawPath since Go's URL parser stores Path in decoded form (would silently flatten the encoding).

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 pass membership=false to opt out.

Test plan

  • 23 tests covering config validation (token required, trimmed, base_url defaulted/normalized), auth header (PRIVATE-TOKEN), response cap, path traversal, Validate (401, 403, 5xx, transport-failure, 200), URL encoding for namespaced project paths, default narrowing, request escape hatch (method/path/body/query), method validation, op-shape catalog regression guard, unknown-op error.
  • go test -race -count=1 ./... clean.
  • Manual: add a GitLab connection via the admin UI, list projects, get a file, open an issue.

Followups

  • A specifically-rendered "GitLab" catalog card in 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).
  • Per-namespace credentials if a need emerges.
  • gitlab_get_pipeline / gitlab_trigger_pipeline for CI workflows.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/gitlab with hardened HTTP client, config parsing, and a curated v1 operation catalog (plus gitlab_request escape 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.go so 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.

Comment thread internal/connectors/gitlab/ops.go Outdated
Comment thread internal/connectors/gitlab/ops.go Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread internal/connectors/gitlab/ops.go
Comment thread internal/connectors/gitlab/client.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread internal/connectors/gitlab/client.go
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 AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread internal/connectors/gitlab/client.go Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread internal/connectors/gitlab/ops.go
Comment thread internal/connectors/gitlab/gitlab.go Outdated
Comment thread internal/connectors/gitlab/config.go Outdated
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 AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread internal/connectors/gitlab/ops.go
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 AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread internal/connectors/gitlab/ops.go Outdated
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>
@murbard
murbard requested a review from Copilot June 16, 2026 21:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@murbard
murbard merged commit da4f509 into main Jun 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants