GitLab adapter: client and core Forge methods #138
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
lazyforge can't talk to GitLab. This ticket builds the adapter's foundation: the API client and every core
Forgemethod plusApprover, 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/gitlabimplementsforge.Forgeandforge.Approver, and passesforgetest.RunContract(ADR 0002: every adapter passes the contract suite).<web root>/api/v4, andInfo().URLstays the web root sooopens GitLab pages.Info().ChangeRequestTermis"MR"(#137; design.md → Keymapo; architecture.md → domain model).group/sub/repo) list, open and act like any other repo (architecture.md: "Owner may contain slashes (GitLab subgroups)").CIStatevalues as the other adapters (architecture.md → API mapping: "MR head pipeline").ErrHeadChanged(ADR 0006; ADR 0011).ErrRateLimited, never as a bad-token error (architecture.md → How the forges differ: rate limits).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 likeinternal/forge/github:gitlab.go): one*http.Client, per-callctx, headerPRIVATE-TOKEN: <token>.New(ctx, webURL, token, hc)readsGET /user(username) andGET /metadata(version), so a bad token fails at connect.Probe(ctx, webURL, hc)sends an unauthenticatedGET /api/v4/metadata: a 401 whose JSON body is{"message":"401 Unauthorized"}, or a 200 with aversionfield, returnsforge.KindGitLab; anything else returnsErrNotFound. #141 wiresProbein.group%2Fsub%2Frepo) built fromRepoRef, so the adapter holds no path→ID state. Build URLs withurl.URL{Opaque/RawPath}so%2Fsurvives Go's path cleaning; add a test that proves the request line carries%2F. File paths in later tickets are encoded the same way.ErrUnauthorized(GitLab also uses 401 for "no permission to merge", so map 401 from merge/approve toErrRefusedwith GitLab'smessage); 403 →ErrUnauthorized; 404 →ErrNotFound; 429 → waitRetry-After(capped at 60 s, ctx-cancellable) and retry once, thenErrRateLimited; merge 409 →ErrHeadChanged; 405, 406, 422 and other 409s →ErrRefusedcarryingmessage.messageis a string or a{field: [errors]}object; handle both.per_page=100, followLink: 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.ListRepos:GET /projects?membership=true&archived=false&order_by=last_activity_at&sort=desc. Access: max ofpermissions.project_access.access_levelandgroup_access.access_level; 50 and 40 → admin, 30 → write, lower → read. When both are null (common for inherited membership), make one extra pagedGET /projects?membership=true&min_access_level=30&simple=trueand 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:268shows it raw):squashwhen the squash rule applies, elsemerge→merge,rebase_merge→rebase-merge,ff→fast-forward-only.ListChangeRequests/GetChangeRequest:GET /projects/:id/merge_requests?state=opened|closed|merged(always sendstate; GitLab's default isall). Number ←iid, head ←sha,draftboolean (not the title prefix).head_pipeline. Per repo, make oneGET /projects/:id/pipelines?per_page=100&order_by=id&sort=descand give each MR the newest pipeline whoseshaequals the MR'ssha. An MR with no match gets oneGET …/merge_requests/:iidforhead_pipeline(covers merged-results pipelines, whose sha differs).GetChangeRequestalways useshead_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/mergewithshaandsquash(from the project'ssquash_option:always,default_on→ true). GitLab has no per-call merge method, so a non-emptyMergeOpts.Methodthat differs from the project's returnsErrUnsupported. Draft MRs come back 405 →ErrRefused.Approve:POST …/merge_requests/:iid/approvewithsha. 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/notesor…/merge_requests/:iid/notesbyItemRef.Kind,sort=asc&order_by=created_at, droppingsystem: truenotes. POST{body}.Close:PUTthe issue or MR withstate_event=close.ListReleases:GET /projects/:id/releases;PublishedAt←released_at, falling back tocreated_at.Gate: always nil.Attachments: nil (#143 covers images).testdata/synthesised from the docs examples, with a README in the gitea format (source URL, date, hand edits, and "synthesised, not recorded"). A statefulhttptestserver likegithub/server_test.gobacksRunContract. Unit tests cover the CI fold table, sha matching and the single-GET fallback, the 401-on-merge mapping, 429 retry-once withRetry-After,Linkpaging across three pages, subgroup path encoding, null-permissions fallback, system-note filtering, and the squash rule.net/httpclient (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,squashfromsquash_option; the 429 retry-once rule.Context
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.PRIVATE-TOKENheader; 401 body{"message":"401 Unauthorized"}./metadatareplaces/version(deprecated). Scopeapiis needed for writes;read_apiis read-only.GET /projects2,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 carriesRetry-After; the Projects API sends noRateLimit-*headers.page/per_page(max 100),Linkrel next;x-totalomitted above 10,000 rows.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.iidin paths;head_pipelineonly on the single GET. Merge: 401 no permission, 405 not mergeable (draft, closed, checks), 409SHA does not match HEAD of source branch, 422Branch cannot be merged.created,waiting_for_resource,preparing,waiting_for_callback,pending,running,canceling,canceled,success,failed,skipped,manual,scheduled.sort=desc, 20 per page; MRs have noonly_commentsfilter.issue_type=issue.Out of scope
Onboarding and connect wiring (#141).
RunLister,LogReader,Labeler(#139). Repo box readers andBranchUpdater(#140).WorkflowDispatcher(#142). Images (#143). Re-run, unapprove, review threads, OAuth.🤖 Picked up through composer-lite on branch
feature/138-gitlab-adapter-core. The plan is this ticket body; one implementer buildsinternal/forge/gitlaband the ADR while one test-writer writes the contract fake and unit tests from the AC.🤖 MR open: #144
🤖 merged: #144