GitLab
GitLab
Spoken by GitLabProvider. Same resources, same shapes, its own quirks kept inside.
Access
CreatecreateProvider("gitlab")
Importimport { GitLabProvider } from"@agntn/forges/gitlab"
EnvGITLAB_TOKEN, GL_TOKEN, GITLAB_PAT
CLIglab config get token
Config~/.config/glab-cli/config.yml
- Header
- Private-Token
- Anonymous
- public projects only
- Threads
- REST discussions, no outdated flag
- Code search
- authenticated; global and group need Premium
- Templates
- effective project templates, inherited source may be hidden
Address it
import { createProvider } from "@agntn/forges";
const gitlab = await createProvider("gitlab"); // GITLAB_TOKEN, GL_TOKEN, GITLAB_PAT, then glab
const selfHosted = await createProvider("gitlab", {
token: "glpat-…",
baseURL: "https://gitlab.example.com",
});
/api/v4 is added when the base URL lacks it. The token travels in Private-Token.
What it reads
| Resource | Endpoint |
|---|---|
| repos | GET /projects/:id, GET /users/:owner/projects, GET /groups/:owner/projects on 404, GET …/repository/tags by version |
| issues | GET /projects/:id/issues, …/issues?search=, POST /projects/:id/issues, PUT …/issues/:iid, …/issues/:iid/notes |
| merge requests | GET /projects/:id/merge_requests, …/merge_requests/:iid/diffs, …/merge_requests/:iid/pipelines, …/merge_requests/:iid/notes |
| merge request search | GET /merge_requests?scope=all, GET /groups/:id/merge_requests, GET /projects/:id/merge_requests with search |
| merge request edits | PUT /projects/:id/merge_requests/:iid, after a read when assignees change |
| merge request merges | PUT /projects/:id/merge_requests/:iid/merge |
| commits | GET /projects/:id/repository/commits, …/commits/:sha, …/commits/:sha/diff, …/repository/compare |
| commit checks | GET /projects/:id/repository/commits/:ref, then GET /projects/:id/pipelines?sha= and …/pipelines/:pipeline_id |
| CI runs | GET /projects/:id/pipelines, …/pipelines/:pipeline_id, …/pipelines/:pipeline_id/jobs, …/jobs/:job_id/trace |
| releases | GET /projects/:id/releases, …/releases/:tag_name, POST …/releases, PUT …/releases/:tag_name |
| reactions | GET …/issues/:iid/award_emoji, …/merge_requests/:iid/award_emoji, …/notes/:note_id/award_emoji |
| threads | GET /projects/:id/merge_requests/:iid/discussions, PUT …/discussions/:id |
| templates | GET /projects/:id/templates/issues, …/templates/merge_requests |
| code | GET /search, GET /groups/:id/search, GET /projects/:id/search with scope=blobs |
| commit search | GET /search, GET /groups/:id/search, GET /projects/:id/search with scope=commits |
owner/repo becomes a project id once and is cached per provider (gitlab.projectIdCacheMax, gitlab.projectIdCacheTtl in the config). Lists that go through forges follow its next links, the few that don't read x-next-page.
What comes back
number is the iid, never the global id. viewerPermission takes the higher of your project and group access: Guest is read, Planner and Reporter are triage, Developer is write, Maintainer is maintain and Owner is admin. merge reads merge_method and squash_option. methods never holds rebase, since merge follows the project's fast forward or semi-linear setting anyway. always leaves only squash, and default_on makes squash the default. An anonymous read gets the basic project without them, so merge is null. With merge requests disabled, methods is empty. Thread.isOutdated is always false, the API has no such flag. Changed files of a merge request carry null counts when the diff is collapsed or oversized. Commit reads take at most 10 000 diff rows per call and filesComplete is null past that.
listComments asks for notes with an explicit ascending sort and drops two kinds: system notes about label and state churn, and inline DiffNotes, which are threads. So a short page that still carries nextCursor is normal, keep paging.
Reactions are award emoji, named by shortcode. Four of GitHub's eight go by another name here: thumbsup is +1, thumbsdown is -1, smile is laugh and tada is hooray. forges hands you the GitHub name. Everything else, fire and friends, stays as GitLab spells it.
Templates come from the effective template API, group and instance inheritance already applied. When the API hides the winning source, scope is unknown and the source fields are null.
Gotchas
- Discussions answer 401 without a token, even on a public project. Repositories, issues, merge requests, commits and pipelines read fine anonymously. Discussions do not, and I did not expect that either.
- Every search call needs a token. Global and group code search also need Premium or Ultimate with advanced or exact code search on, and global and group commit search need advanced search. Project search does not. Commit search in a project reads the default branch only.
/users/:owner/projectsis a 404 for a group.repos.listfalls back to/groups/:owner/projectson that status only.- Search qualifiers are plain text.
label:bugsearches for the stringlabel:bug. pullRequests.searchGlobalandissues.searchGlobalneed no token, but across all of gitlab.com a keyword search can hit GitLab's 408 after about 15 seconds. Anownergroup or anauthornarrows it enough.- A merged results or merge train pipeline runs on a merge commit GitLab builds. Its SHA isn't the head's, and REST doesn't say which head it was built from. So
listChecksasks GraphQL for the merge request'sheadPipelineand keeps that one. Why not REST'shead_pipeline? It can still point at the previous head after a push. GraphQL drops a pointer like that, REST hands it over with a straight face. - The merge request pipeline rows have no
name, onlyGET /projects/:id/pipelines/:pipeline_idhas it, solistChecksreads each pipeline on the page once more, from the project it ran in, on every call, because the metadata endpoint can rename a pipeline. A fork merge request runs in the fork, and the target project answers 404 for that pipeline. A name lookup that fails keeps its row, namedpipeline, with a warning on stderr. commits.listChecksfilters pipelines by an exact SHA. A branch or tag gets resolved first, one read more. A merged results pipeline never shows up. It ran on a merge commit, so ask the merge request.commits.comparesends every commit and diff of the range in one response and never says what base has that head lacks.behindBy,mergeBaseShaandstatusarenull,filesCompletetoo, since the answer has no flag for a cut file list.- A tag on a tree lists the head of the default branch as its commit. Such a tag has no date, so
repos.listTagsreads the target of an annotated tag that is undated or older than its commit, and a 404 there givessha: null. Before 16.11 tags carry no date, so nothing is checked. - GitLab Free takes one assignee. Two are rejected before the request.
- Releases have no id, no draft and no pre-release.
Release.idis the tag, both flags arefalse, and asking for either on a create or update is a 501 rather than a release that goes public unasked. users.getcosts two requests: the username search returns a stub, the id from it reads the full profile.
Where it lives
src/providers/gitlab.ts.