GitLab adapter: client and core Forge methods #138

Closed
opened 2026-10-12 07:48:33 -05:00 by deadstyle · 3 comments
Owner

Problem

lazyforge can't talk to GitLab. This ticket builds the adapter's foundation: the API client and every core Forge method plus Approver, so a GitLab session can list projects, show MRs and issues with CI status, merge, approve, comment and close. Connecting from onboarding is a sibling ticket. Parent: #137.

Acceptance criteria

  • internal/forge/gitlab implements forge.Forge and forge.Approver, and passes forgetest.RunContract (ADR 0002: every adapter passes the contract suite).
  • It works against gitlab.com and self-managed GitLab: the API root is <web root>/api/v4, and Info().URL stays the web root so o opens GitLab pages. Info().ChangeRequestTerm is "MR" (#137; design.md → Keymap o; architecture.md → domain model).
  • Projects in subgroups (group/sub/repo) list, open and act like any other repo (architecture.md: "Owner may contain slashes (GitLab subgroups)").
  • MRs and the MR list show one CI state from the MR's pipeline, using the same CIState values as the other adapters (architecture.md → API mapping: "MR head pipeline").
  • Merge sends the confirmed head SHA, and a stale head returns ErrHeadChanged (ADR 0006; ADR 0011).
  • The merge strategy is the project's default from the forge, and the confirm dialog shows it (design.md → Decided: "always the repo's default from the forge"). On GitLab that is the project's merge method, squashed when the project's squash option is "always" or "on by default".
  • Issues list only issues, never incidents, test cases or tasks; comments never include GitLab's system notes ("changed the description") (ADR 0006 paper check: issues and comments are user content).
  • Hitting the rate limit surfaces as ErrRateLimited, never as a bad-token error (architecture.md → How the forges differ: rate limits).
  • ADR 0018 records the adapter decisions below, and docs/architecture.md's GitLab column is updated to the endpoints used, each row marked unverified until it runs against gitlab.com.

Plan

New package internal/forge/gitlab, shaped like internal/forge/github:

  1. Client (gitlab.go): one *http.Client, per-call ctx, header PRIVATE-TOKEN: <token>. New(ctx, webURL, token, hc) reads GET /user (username) and GET /metadata (version), so a bad token fails at connect. Probe(ctx, webURL, hc) sends an unauthenticated GET /api/v4/metadata: a 401 whose JSON body is {"message":"401 Unauthorized"}, or a 200 with a version field, returns forge.KindGitLab; anything else returns ErrNotFound. #141 wires Probe in.
  2. Addressing: every project path uses the URL-encoded full path (group%2Fsub%2Frepo) built from RepoRef, so the adapter holds no path→ID state. Build URLs with url.URL{Opaque/RawPath} so %2F survives Go's path cleaning; add a test that proves the request line carries %2F. File paths in later tickets are encoded the same way.
  3. Errors: 401 → ErrUnauthorized (GitLab also uses 401 for "no permission to merge", so map 401 from merge/approve to ErrRefused with GitLab's message); 403 → ErrUnauthorized; 404 → ErrNotFound; 429 → wait Retry-After (capped at 60 s, ctx-cancellable) and retry once, then ErrRateLimited; merge 409 → ErrHeadChanged; 405, 406, 422 and other 409s → ErrRefused carrying message. message is a string or a {field: [errors]} object; handle both.
  4. Pagination: per_page=100, follow Link: rel="next" to the end; never stop on a short page; a next link off the API origin is an error (as ADR 0017). Offset paging only; keyset doesn't support the orderings we need.
  5. No conditional-request cache. GitLab doesn't document REST ETag/304, and nothing says a 304 skips the limit. Instead keep calls per refresh low (CI below).
  6. Methods:
    • ListRepos: GET /projects?membership=true&archived=false&order_by=last_activity_at&sort=desc. Access: max of permissions.project_access.access_level and group_access.access_level; 50 and 40 → admin, 30 → write, lower → read. When both are null (common for inherited membership), make one extra paged GET /projects?membership=true&min_access_level=30&simple=true and mark those write. Archived → read. LastActivity ← last_activity_at. DefaultBranch ← default_branch. MergeStyle ← the project's method in the strings the gitea adapter already hands the merge dialog (internal/ui/actions.go:268 shows it raw): squash when the squash rule applies, else merge → merge, rebase_merge → rebase-merge, ff → fast-forward-only.
    • ListChangeRequests / GetChangeRequest: GET /projects/:id/merge_requests?state=opened|closed|merged (always send state; GitLab's default is all). Number ← iid, head ← sha, draft boolean (not the title prefix).
    • CI: the list doesn't carry head_pipeline. Per repo, make one GET /projects/:id/pipelines?per_page=100&order_by=id&sort=desc and give each MR the newest pipeline whose sha equals the MR's sha. An MR with no match gets one GET …/merge_requests/:iid for head_pipeline (covers merged-results pipelines, whose sha differs). GetChangeRequest always uses head_pipeline. Fold: failed → fail; canceled, canceling → cancelled; running → running; created, waiting_for_resource, preparing, waiting_for_callback, pending, scheduled, manual → pending; success → pass; skipped → skipped; no pipeline → none; unknown → pending (never green). External commit statuses already land in a pipeline, so no separate fold.
    • Merge: PUT …/merge_requests/:iid/merge with sha and squash (from the project's squash_option: always, default_on → true). GitLab has no per-call merge method, so a non-empty MergeOpts.Method that differs from the project's returns ErrUnsupported. Draft MRs come back 405 → ErrRefused.
    • Approve: POST …/merge_requests/:iid/approve with sha. Exists on Free.
    • ListIssues: GET /projects/:id/issues?state=opened|closed&issue_type=issue. Merged returns nil with no request, as gitea does.
    • EditIssueBody: PUT /projects/:id/issues/:iid {description}.
    • ListComments / Comment: …/issues/:iid/notes or …/merge_requests/:iid/notes by ItemRef.Kind, sort=asc&order_by=created_at, dropping system: true notes. POST {body}.
    • Close: PUT the issue or MR with state_event=close.
    • ListReleases: GET /projects/:id/releases; PublishedAt ← released_at, falling back to created_at.
    • Gate: always nil. Attachments: nil (#143 covers images).
  7. Tests: fixtures in testdata/ synthesised from the docs examples, with a README in the gitea format (source URL, date, hand edits, and "synthesised, not recorded"). A stateful httptest server like github/server_test.go backs RunContract. Unit tests cover the CI fold table, sha matching and the single-GET fallback, the 401-on-merge mapping, 429 retry-once with Retry-After, Link paging across three pages, subgroup path encoding, null-permissions fallback, system-note filtering, and the squash rule.
  8. ADR 0018 (in this MR): thin net/http client (consistency with ADR 0005/0017, no new module); encoded-path addressing; no ETag cache and why; CI from one pipelines list per repo; merge method is the project's, squash from squash_option; the 429 retry-once rule.

Context

  • Interface and sentinels: internal/forge/forge.go. Availability: internal/forge/can.go. Contract: internal/forge/forgetest/contract.go. Pattern to mirror: internal/forge/github/github.go (client, pager, errors), forge.go (methods), server_test.go (fake server). ADR 0017 is the closest precedent.
  • API facts (docs.gitlab.com, read 2026-10-12):
    • Auth: PRIVATE-TOKEN header; 401 body {"message":"401 Unauthorized"}. /metadata replaces /version (deprecated). Scope api is needed for writes; read_api is read-only.
    • Rate limits: gitlab.com today 2,000/min per user, GET /projects 2,000 per 10 min. Free tier drops to 100/min burst, 5,000/hour from 2026-10-19 (about.gitlab.com/blog/rate-limit-change-2026). 429 carries Retry-After; the Projects API sends no RateLimit-* headers.
    • Pagination: page/per_page (max 100), Link rel next; x-total omitted above 10,000 rows.
    • Projects list includes permissions (often null), merge_method, squash_option, empty_repo, default_branch, archived, last_activity_at, path_with_namespace. Access levels: 10 Guest, 15 Planner, 20 Reporter, 30 Developer, 40 Maintainer, 50 Owner.
    • MRs: iid in paths; head_pipeline only on the single GET. Merge: 401 no permission, 405 not mergeable (draft, closed, checks), 409 SHA does not match HEAD of source branch, 422 Branch cannot be merged.
    • Pipeline statuses: created, waiting_for_resource, preparing, waiting_for_callback, pending, running, canceling, canceled, success, failed, skipped, manual, scheduled.
    • Notes default to sort=desc, 20 per page; MRs have no only_comments filter.
    • Issues list returns incidents, test cases and tasks unless issue_type=issue.
  • No gitlab.com token is wired up, so nothing here is recorded from a live server. The employer GitLab must not be used for fixtures or testing.

Out of scope

Onboarding and connect wiring (#141). RunLister, LogReader, Labeler (#139). Repo box readers and BranchUpdater (#140). WorkflowDispatcher (#142). Images (#143). Re-run, unapprove, review threads, OAuth.

## Problem lazyforge can't talk to GitLab. This ticket builds the adapter's foundation: the API client and every core `Forge` method plus `Approver`, so a GitLab session can list projects, show MRs and issues with CI status, merge, approve, comment and close. Connecting from onboarding is a sibling ticket. Parent: #137. ## Acceptance criteria - `internal/forge/gitlab` implements `forge.Forge` and `forge.Approver`, and passes `forgetest.RunContract` *(ADR 0002: every adapter passes the contract suite)*. - It works against gitlab.com and self-managed GitLab: the API root is `<web root>/api/v4`, and `Info().URL` stays the web root so `o` opens GitLab pages. `Info().ChangeRequestTerm` is `"MR"` *(#137; design.md → Keymap `o`; architecture.md → domain model)*. - Projects in subgroups (`group/sub/repo`) list, open and act like any other repo *(architecture.md: "Owner may contain slashes (GitLab subgroups)")*. - MRs and the MR list show one CI state from the MR's pipeline, using the same `CIState` values as the other adapters *(architecture.md → API mapping: "MR head pipeline")*. - Merge sends the confirmed head SHA, and a stale head returns `ErrHeadChanged` *(ADR 0006; ADR 0011)*. - The merge strategy is the project's default from the forge, and the confirm dialog shows it *(design.md → Decided: "always the repo's default from the forge")*. On GitLab that is the project's merge method, squashed when the project's squash option is "always" or "on by default". - Issues list only issues, never incidents, test cases or tasks; comments never include GitLab's system notes ("changed the description") *(ADR 0006 paper check: issues and comments are user content)*. - Hitting the rate limit surfaces as `ErrRateLimited`, never as a bad-token error *(architecture.md → How the forges differ: rate limits)*. - ADR 0018 records the adapter decisions below, and `docs/architecture.md`'s GitLab column is updated to the endpoints used, each row marked unverified until it runs against gitlab.com. ## Plan New package `internal/forge/gitlab`, shaped like `internal/forge/github`: 1. **Client** (`gitlab.go`): one `*http.Client`, per-call `ctx`, header `PRIVATE-TOKEN: <token>`. `New(ctx, webURL, token, hc)` reads `GET /user` (username) and `GET /metadata` (`version`), so a bad token fails at connect. `Probe(ctx, webURL, hc)` sends an unauthenticated `GET /api/v4/metadata`: a 401 whose JSON body is `{"message":"401 Unauthorized"}`, or a 200 with a `version` field, returns `forge.KindGitLab`; anything else returns `ErrNotFound`. #141 wires `Probe` in. 2. **Addressing:** every project path uses the URL-encoded full path (`group%2Fsub%2Frepo`) built from `RepoRef`, so the adapter holds no path→ID state. Build URLs with `url.URL{Opaque/RawPath}` so `%2F` survives Go's path cleaning; add a test that proves the request line carries `%2F`. File paths in later tickets are encoded the same way. 3. **Errors:** 401 → `ErrUnauthorized` (GitLab also uses 401 for "no permission to merge", so map 401 from merge/approve to `ErrRefused` with GitLab's `message`); 403 → `ErrUnauthorized`; 404 → `ErrNotFound`; 429 → wait `Retry-After` (capped at 60 s, ctx-cancellable) and retry once, then `ErrRateLimited`; merge 409 → `ErrHeadChanged`; 405, 406, 422 and other 409s → `ErrRefused` carrying `message`. `message` is a string or a `{field: [errors]}` object; handle both. 4. **Pagination:** `per_page=100`, follow `Link: rel="next"` to the end; never stop on a short page; a next link off the API origin is an error (as ADR 0017). Offset paging only; keyset doesn't support the orderings we need. 5. **No conditional-request cache.** GitLab doesn't document REST ETag/304, and nothing says a 304 skips the limit. Instead keep calls per refresh low (CI below). 6. **Methods:** - `ListRepos`: `GET /projects?membership=true&archived=false&order_by=last_activity_at&sort=desc`. Access: max of `permissions.project_access.access_level` and `group_access.access_level`; 50 and 40 → admin, 30 → write, lower → read. When both are null (common for inherited membership), make one extra paged `GET /projects?membership=true&min_access_level=30&simple=true` and mark those write. Archived → read. `LastActivity` ← `last_activity_at`. `DefaultBranch` ← `default_branch`. `MergeStyle` ← the project's method in the strings the gitea adapter already hands the merge dialog (`internal/ui/actions.go:268` shows it raw): `squash` when the squash rule applies, else `merge` → `merge`, `rebase_merge` → `rebase-merge`, `ff` → `fast-forward-only`. - `ListChangeRequests` / `GetChangeRequest`: `GET /projects/:id/merge_requests?state=opened|closed|merged` (always send `state`; GitLab's default is `all`). Number ← `iid`, head ← `sha`, `draft` boolean (not the title prefix). - **CI:** the list doesn't carry `head_pipeline`. Per repo, make one `GET /projects/:id/pipelines?per_page=100&order_by=id&sort=desc` and give each MR the newest pipeline whose `sha` equals the MR's `sha`. An MR with no match gets one `GET …/merge_requests/:iid` for `head_pipeline` (covers merged-results pipelines, whose sha differs). `GetChangeRequest` always uses `head_pipeline`. Fold: `failed` → fail; `canceled`, `canceling` → cancelled; `running` → running; `created`, `waiting_for_resource`, `preparing`, `waiting_for_callback`, `pending`, `scheduled`, `manual` → pending; `success` → pass; `skipped` → skipped; no pipeline → none; unknown → pending (never green). External commit statuses already land in a pipeline, so no separate fold. - `Merge`: `PUT …/merge_requests/:iid/merge` with `sha` and `squash` (from the project's `squash_option`: `always`, `default_on` → true). GitLab has no per-call merge method, so a non-empty `MergeOpts.Method` that differs from the project's returns `ErrUnsupported`. Draft MRs come back 405 → `ErrRefused`. - `Approve`: `POST …/merge_requests/:iid/approve` with `sha`. Exists on Free. - `ListIssues`: `GET /projects/:id/issues?state=opened|closed&issue_type=issue`. Merged returns nil with no request, as gitea does. - `EditIssueBody`: `PUT /projects/:id/issues/:iid` `{description}`. - `ListComments` / `Comment`: `…/issues/:iid/notes` or `…/merge_requests/:iid/notes` by `ItemRef.Kind`, `sort=asc&order_by=created_at`, dropping `system: true` notes. POST `{body}`. - `Close`: `PUT` the issue or MR with `state_event=close`. - `ListReleases`: `GET /projects/:id/releases`; `PublishedAt` ← `released_at`, falling back to `created_at`. - `Gate`: always nil. `Attachments`: nil (#143 covers images). 7. **Tests:** fixtures in `testdata/` synthesised from the docs examples, with a README in the gitea format (source URL, date, hand edits, and "synthesised, not recorded"). A stateful `httptest` server like `github/server_test.go` backs `RunContract`. Unit tests cover the CI fold table, sha matching and the single-GET fallback, the 401-on-merge mapping, 429 retry-once with `Retry-After`, `Link` paging across three pages, subgroup path encoding, null-permissions fallback, system-note filtering, and the squash rule. 8. **ADR 0018** (in this MR): thin `net/http` client (consistency with ADR 0005/0017, no new module); encoded-path addressing; no ETag cache and why; CI from one pipelines list per repo; merge method is the project's, `squash` from `squash_option`; the 429 retry-once rule. ## Context - Interface and sentinels: `internal/forge/forge.go`. Availability: `internal/forge/can.go`. Contract: `internal/forge/forgetest/contract.go`. Pattern to mirror: `internal/forge/github/github.go` (client, pager, errors), `forge.go` (methods), `server_test.go` (fake server). ADR 0017 is the closest precedent. - API facts (docs.gitlab.com, read 2026-10-12): - Auth: `PRIVATE-TOKEN` header; 401 body `{"message":"401 Unauthorized"}`. `/metadata` replaces `/version` (deprecated). Scope `api` is needed for writes; `read_api` is read-only. - Rate limits: gitlab.com today 2,000/min per user, `GET /projects` 2,000 per 10 min. Free tier drops to 100/min burst, 5,000/hour from 2026-10-19 (about.gitlab.com/blog/rate-limit-change-2026). 429 carries `Retry-After`; the Projects API sends no `RateLimit-*` headers. - Pagination: `page`/`per_page` (max 100), `Link` rel next; `x-total` omitted above 10,000 rows. - Projects list includes `permissions` (often null), `merge_method`, `squash_option`, `empty_repo`, `default_branch`, `archived`, `last_activity_at`, `path_with_namespace`. Access levels: 10 Guest, 15 Planner, 20 Reporter, 30 Developer, 40 Maintainer, 50 Owner. - MRs: `iid` in paths; `head_pipeline` only on the single GET. Merge: 401 no permission, 405 not mergeable (draft, closed, checks), 409 `SHA does not match HEAD of source branch`, 422 `Branch cannot be merged`. - Pipeline statuses: `created`, `waiting_for_resource`, `preparing`, `waiting_for_callback`, `pending`, `running`, `canceling`, `canceled`, `success`, `failed`, `skipped`, `manual`, `scheduled`. - Notes default to `sort=desc`, 20 per page; MRs have no `only_comments` filter. - Issues list returns incidents, test cases and tasks unless `issue_type=issue`. - No gitlab.com token is wired up, so nothing here is recorded from a live server. The employer GitLab must not be used for fixtures or testing. ## Out of scope Onboarding and connect wiring (#141). `RunLister`, `LogReader`, `Labeler` (#139). Repo box readers and `BranchUpdater` (#140). `WorkflowDispatcher` (#142). Images (#143). Re-run, unapprove, review threads, OAuth.
Author
Owner

🤖 Picked up through composer-lite on branch feature/138-gitlab-adapter-core. The plan is this ticket body; one implementer builds internal/forge/gitlab and the ADR while one test-writer writes the contract fake and unit tests from the AC.

🤖 Picked up through composer-lite on branch `feature/138-gitlab-adapter-core`. The plan is this ticket body; one implementer builds `internal/forge/gitlab` and the ADR while one test-writer writes the contract fake and unit tests from the AC.
Author
Owner

🤖 MR open: #144

🤖 MR open: https://git.bobparsons.dev/deadstyle/lazyforge/pulls/144
Author
Owner

🤖 merged: #144

🤖 merged: https://git.bobparsons.dev/deadstyle/lazyforge/pulls/144
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
deadstyle/lazyforge#138
No description provided.