Replace raw fetch transport with CLI subprocess calls: - Gitea: spawn 'tea api --include' with GITEA_SERVER_TOKEN env var - GitLab: spawn 'glab api --include' with GITLAB_TOKEN env var Binary paths env-overridable (TEA_BIN / GLAB_BIN). 8s request timeout via AbortSignal on spawned process. ETag cache and rate-limit cooldown dropped (tradeoff documented). Pagination via --paginate for list endpoints. Tests mock child_process.spawn instead of globalThis.fetch.
16 KiB
16 KiB
GitLab Module Documentation
Purpose
- This module owns GitLab auth (Personal Access Token), CLI-backed REST v4 client access, remote-URL repo resolution, and GitLab issue / merge-request (MR) APIs for OpenChamber, including MR create/update/merge writes.
- From a user perspective, this is the layer that lets the app show GitLab issues and merge requests for a local project, including comments and per-file diffs, and create, edit, and merge merge requests.
- The module mirrors
packages/web/server/lib/github/but uses a Personal Access Token (PAT) with a configurable base URL (gitlab.com by default, or a self-hosted instance), and talks to GitLab's REST v4 API via theglabCLI transport instead of rawfetch.
Entrypoints and structure
packages/web/server/lib/gitlab/index.js: public server entrypoint re-exports.packages/web/server/lib/gitlab/routes.js: Express route registration for/api/gitlab/*endpoints.packages/web/server/lib/gitlab/auth.js: PAT auth storage, multi-account support, base URL normalization.packages/web/server/lib/gitlab/client.js: CLI-backedglab apiclient (process spawn with 8s timeout,--includefor HTTP status/headers,--paginatefor list endpoints). Token is passed viaGITLAB_TOKENenv var.packages/web/server/lib/gitlab/repo.js: GitLab remote URL parsing and directory-to-repo resolution.packages/web/server/lib/opencode/feature-routes-runtime.js: API route layer that calls this module (viaregisterGitLabRoutes).packages/web/src/api/gitlab.ts: web client wrapper for GitLab endpoints.packages/ui/src/lib/api/types.ts: shared response types consumed by web, desktop, VS Code, and mobile.
Public exports
Auth (auth.js)
getGitLabAuth(): current auth entry.getGitLabAuthAccounts(): all configured accounts ({ id, user, baseUrl, current }).setGitLabAuth({ accessToken, baseUrl, user }): save or update an account (validatingusercomes fromGET /user).activateGitLabAuth(accountId): switch active account.clearGitLabAuth(): remove the current account.normalizeBaseUrl(raw): addhttps://when a scheme is missing, strip trailing slash, returnnullfor invalid input.GITLAB_AUTH_FILE: auth file path.DEFAULT_GITLAB_BASE_URL:https://gitlab.com(compatibility constant).getGitLabDefaultBaseUrl(): effective default base URL — configuredgitProviders.gitlab.apiBaseUrlfromsettings.jsonif present, elsehttps://gitlab.com. Used for stored-account fallback and the auth status/connectdefaultBaseUrlfields.
Client (client.js)
createGitLabClient({ token, baseUrl }): raw-fetch REST v4 client withrequest(path, { method, query, body })plus convenience methodsuser(),project(path),issues(path, params),issue(path, iid),issueNotes(path, iid, params),createIssueNote(path, iid, body),updateIssue(path, iid, params),mergeRequests(path, params),mergeRequest(path, iid),mergeRequestDiffs(path, iid, params),createMergeRequest(path, body),updateMergeRequest(path, iid, body),mergeMergeRequest(path, iid, body),createMrNote(path, iid, body),approveMr(path, iid),milestones(path, params),branches(path, params).getGitLabClientOrNull(): client for the current account, ornull.isGitLabRateLimited()/noteGitLabRateLimit(error): own module-level rate-limit cooldown (not shared with the GitHub module'srate-limit.js).
Repo (repo.js)
parseGitLabRemoteUrl(raw, knownHosts?): parse SSH/HTTPS remote URL into{ namespace, project, host, baseUrl, url }(multi-segment namespaces supported; never matchesgithub.com).resolveGitLabRepoFromDirectory(directory, remoteName?): resolve a GitLab repo from a local git remote.
Auth storage and config
- Auth storage:
~/.config/openchamber/gitlab-auth.json(override withOPENCHAMBER_DATA_DIR). - Writes are atomic (tmp file + rename) and file mode is
0o600. - Base URL resolution: caller-supplied
baseUrl(normalized) -> effective default viagetGitLabDefaultBaseUrl()(configuredsettings.jsongitProviders.gitlab.apiBaseUrl, elsehttps://gitlab.com). - Per-project overrides: data routes resolve a directory-scoped API base via
getEffectiveProviderApiBaseUrl('gitlab', directory)(inpackages/web/server/lib/git-providers/project-config.js). A per-projectgitProviders.gitlab.apiBaseUrloverride (stored underprojects/<projectId>.json) replaces the global default for that project's routes, and its host is accepted for directory-to-repo resolution (resolveGitLabRepoFromDirectory); a connected account whose host matches the remote keeps its own base URL. A forcedgitProviders.provider: 'gitlab'accepts any remote host for directory resolution. Global routes (auth/connect,auth/status,auth/activate,me,repo/branches) stay global. - Account id:
`${host}:${username}`(e.g.gitlab.com:alice), falling back totoken:<first8>when the username is missing. - Auth header on every request:
PRIVATE-TOKEN: <pat>.
OAuth readiness
The stored entry shape (accessToken, baseUrl, username, name, avatarUrl, webUrl, email, createdAt, current) is intentionally generic. OAuth flows would slot in at two points:
routes.js— addPOST /api/gitlab/auth/start/auth/completeendpoints next to the existingauth/connect(mirroring the GitHub device-flow routes), exchanging the OAuth grant for an access token.setGitLabAuth— persists whateveraccessToken+usershape the OAuth callback produces; no storage changes needed.
Nothing in the client or repo layers assumes the token came from a PAT.
Client behavior
- Transport: each call spawns a
glab apiprocess with--includefor HTTP status/headers. Auth is passed viaGITLAB_TOKENenv var (never on argv). Binary path:GLAB_BINenv or/home/user/.local/bin/glab. - Base URL: the
baseUrlparameter is passed to the client constructor for compatibility butglabresolves the instance from its own config. The--includeflag provides HTTP status codes and response headers. - Per-request timeout: 8000 ms via
AbortSignal.timeouton the spawned process. The process is killed withSIGKILLon timeout. - Pagination:
--paginateis passed for GET requests with query params, causingglabto fetch all pages in a single call. The returnedpageobject isnullsince pagination is handled by the CLI. requestnever throws for HTTP error statuses — callers branch onstatus.- ETag cache and rate-limit cooldown have been dropped with the CLI pivot. Each call spawns a fresh process, so there is no persistent connection for conditional requests or shared rate-limit state.
isGitLabRateLimited()always returnsfalse;noteGitLabRateLimit()is a no-op.
API integration overview
- Issues/MRs are addressed project-scoped by iid.
- Issue list:
GET /projects/:id/issues?state=opened&scope=all&per_page=50&page=N&search=<query>. - Issue detail:
GET /projects/:id/issues/:issue_iid. - Issue notes:
GET /projects/:id/issues/:issue_iid/notes?per_page=100(system notes are skipped; each note links as{issue_web_url}#note_{id}). - MR list:
GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=<query>&source_branch=<branch>(the route passessourceBranchthrough tosource_branchwhen present, matching local-branch MR-status UIs). - MR detail:
GET /projects/:id/merge_requests/:merge_request_iid. - MR diffs:
GET /projects/:id/merge_requests/:merge_request_iid/diffs?per_page=100&page=N(paginated; the route caps at 10 pages / 3000 files). - MR commits:
GET /projects/:id/merge_requests/:merge_request_iid/commits?per_page=100(mapped to{ sha, shortSha, message, summary, authorName, committedAt, parents }). - MR notes:
GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100; the timeline route keepssystem: truenotes only and infers the eventtypefrom the note body text (best-effort heuristic, falls back to'other'). - MR notes:
GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100. - MR create:
POST /projects/:id/merge_requestswith{ source_branch, target_branch, title, description?, remove_source_branch }(description omitted when absent;remove_source_branchdefaults tofalse). - MR update:
PUT /projects/:id/merge_requests/:merge_request_iidwith{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }(undefined fields omitted;state_eventis derived fromstate, milestone titles are resolved to ids). - MR merge:
PUT /projects/:id/merge_requests/:merge_request_iid/mergewith{ squash? }. - Issue comment write:
POST /projects/:id/issues/:issue_iid/noteswith{ body }(the route resolves the issueweb_urlfirst so the note links as{issue_web_url}#note_{id}). - Issue update:
PUT /projects/:id/issues/:issue_iidwith{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }(state: 'open'|'closed'maps tostate_event: 'reopen'|'close'; labels/assignees are full-set replaces per GitLab semantics;milestonetitles are resolved to ids andnullclears). - MR comment write:
POST /projects/:id/merge_requests/:merge_request_iid/noteswith{ body }. - MR approve:
POST /projects/:id/merge_requests/:merge_request_iid/approve(approve-only; GitLab has no request-changes event via this API — the facade capability reflects that). - Milestones:
GET /projects/:id/milestones?state=all&per_page=100(first page) for title-to-id resolution on issue/MR updates. - Branches:
GET /projects/:id/repository/branches?per_page=100&page=N. - User:
GET /user->{ id, username, name, state, avatar_url, web_url, email, ... }.
Route contract (/api/gitlab/*)
| Method | Path | Shape |
|---|---|---|
| GET | /api/gitlab/auth/status |
{ connected, user?, accounts[], defaultBaseUrl } |
| POST | /api/gitlab/auth/connect |
body { accessToken, baseUrl? } -> { connected, user, accounts, defaultBaseUrl }; 400 for missing/invalid token |
| POST | /api/gitlab/auth/activate |
body { accountId } -> { connected, user, accounts, defaultBaseUrl }; 404 unknown account |
| DELETE | /api/gitlab/auth |
{ removed } |
| GET | /api/gitlab/me |
{ username, id, name, avatarUrl, webUrl, email? }; 401 when not connected |
| GET | /api/gitlab/issues/list |
?directory&page&query -> { connected, repo?, issues[], page, hasMore } |
| GET | /api/gitlab/issues/get |
?directory&number&namespace&project -> { connected, repo?, issue } |
| GET | /api/gitlab/issues/comments |
?directory&number&namespace&project -> { connected, repo?, comments[] } |
| GET | /api/gitlab/mrs/list |
?directory&page&query&sourceBranch -> { connected, repo?, mrs[], page, hasMore } |
| GET | /api/gitlab/mrs/context |
?directory&number&diff&namespace&project -> { connected, repo?, mr, comments[], files[], diff? } |
| GET | /api/gitlab/mrs/commits |
?directory&number&namespace&project -> { connected, repo?, commits[] } |
| GET | /api/gitlab/mrs/timeline |
?directory&number&namespace&project -> { connected, repo?, events[] } (system notes only; event type inferred from note body text — best-effort heuristic) |
| POST | /api/gitlab/mrs/create |
body { directory, title, sourceBranch, targetBranch, description?, removeSourceBranch? } -> { connected, repo?, mr }; 400 for missing fields, unresolvable repo, or a token without the api scope |
| PUT | /api/gitlab/mrs/update |
body { directory, number, title?, description?, state?, labels?, assigneeIds?, milestone? } -> { connected, repo?, mr }; 404 when the MR does not exist; 400 'Milestone not found' when a milestone title does not match |
| PUT | /api/gitlab/mrs/merge |
body { directory, number, squash? } -> { connected, merged: true } on success; non-mergeable MRs -> the GitLab status (405/406/409/422) with { connected, merged: false, message } |
| POST | /api/gitlab/issues/comment |
body { directory, number, body, namespace?, project? } -> { connected, repo?, comment } |
| POST | /api/gitlab/issues/create |
body { directory, title, body?, labels?, namespace?, project? } -> { connected, repo?, issue } |
| PUT | /api/gitlab/issues/update |
body { directory, number, title?, body?, state?, labels?, assigneeIds?, milestone?, namespace?, project? } -> { connected, repo?, issue }; 400 'Milestone not found' when a milestone title does not match |
| POST | /api/gitlab/mrs/comment |
body { directory, number, body, namespace?, project? } -> { connected, repo?, comment } |
| POST | /api/gitlab/mrs/approve |
body { directory, number, namespace?, project? } -> { connected, repo?, approved: true } |
| GET | /api/gitlab/repo/branches |
?namespace&project -> { branches[], defaultBranch? } (defaultBranch is null when the repo has no marked default branch or GitLab is disconnected) |
Conventions mirror github/routes.js:
- Not authenticated ->
connected: false(or401for/me). - Missing/invalid params ->
400with{ error }. - Hard failures ->
4xx/5xxwith{ error }. - A GitLab
429->503 { error: 'GitLab rate limited' }. - Lazy-import pattern: route handlers import
./index.json first use, so the module never loads unless GitLab endpoints are hit. - Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
Consumers
packages/web/src/api/gitlab.tscalls every/api/gitlab/*endpoint and maps them to the shared types.packages/ui/src/lib/api/types.tsdefines the sharedGitLab*response types used across web, desktop, VS Code, and mobile.
Failure handling
- If GitLab is disconnected, read routes return
connected: false. - A repo that does not resolve from the local git remote yields
repo: nullwith empty lists, matching the GitHub behavior. Write routes reject an unresolvable repo with400 { error: 'Unable to resolve GitLab repo from directory' }. - Invalid/expired tokens are cleared on
401/403and reported as disconnected. - GitLab
403on write routes means the token lacks theapiscope; they respond400 { error: 'Your GitLab token needs the api scope to ...' }. - Milestone titles on issue/MR updates are resolved against
GET /projects/:id/milestones; an unmatched title yields400 { error: 'Milestone not found' }andnullclears the milestone (milestone_id: null). - MR merge rejections (
405/406/409/422from GitLab) are surfaced as{ connected, merged: false, message }with the GitLab status so clients can show the message without treating it as a transport error (mirrorsgithub/pr/merge). - Rate-limit and timeout failures surface explicit
503responses so clients keep last-known state rather than clearing UI.
Notes for contributors
- Keep the response shapes in lockstep with
GitLab*types inpackages/ui/src/lib/api/types.ts. - Never log tokens. Error messages must not include the access token.
- Do not double-encode project paths; convenience methods already call
encodeURIComponenton thepathWithNamespace. - The
glabCLI handles authentication, base URL resolution, and pagination internally. The client does not maintain its own ETag cache or rate-limit cooldown — each call spawns a fresh process. - To add further GitLab write operations, add the endpoint in
routes.js, add a convenience method inclient.js, and extend the shared types — mirror the existing issue/MR write routes and the GitHub PR write routes.