Platforms

GitLab

API v4 with Private-Token auth, project ids resolved and cached for you, iid as the number, and a group fallback for owners that are not users.
IDgitlab02 / 05gitlab.com/api/v4
Platform / 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

Tryexplorer against the docs worker

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

ts
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

ResourceEndpoint
reposGET /projects/:id, GET /users/:owner/projects, GET /groups/:owner/projects on 404, GET …/repository/tags by version
issuesGET /projects/:id/issues, …/issues?search=, POST /projects/:id/issues, PUT …/issues/:iid, …/issues/:iid/notes
merge requestsGET /projects/:id/merge_requests, …/merge_requests/:iid/diffs, …/merge_requests/:iid/pipelines, …/merge_requests/:iid/notes
merge request searchGET /merge_requests?scope=all, GET /groups/:id/merge_requests, GET /projects/:id/merge_requests with search
merge request editsPUT /projects/:id/merge_requests/:iid, after a read when assignees change
merge request mergesPUT /projects/:id/merge_requests/:iid/merge
commitsGET /projects/:id/repository/commits, …/commits/:sha, …/commits/:sha/diff, …/repository/compare
commit checksGET /projects/:id/repository/commits/:ref, then GET /projects/:id/pipelines?sha= and …/pipelines/:pipeline_id
CI runsGET /projects/:id/pipelines, …/pipelines/:pipeline_id, …/pipelines/:pipeline_id/jobs, …/jobs/:job_id/trace
releasesGET /projects/:id/releases, …/releases/:tag_name, POST …/releases, PUT …/releases/:tag_name
reactionsGET …/issues/:iid/award_emoji, …/merge_requests/:iid/award_emoji, …/notes/:note_id/award_emoji
threadsGET /projects/:id/merge_requests/:iid/discussions, PUT …/discussions/:id
templatesGET /projects/:id/templates/issues, …/templates/merge_requests
codeGET /search, GET /groups/:id/search, GET /projects/:id/search with scope=blobs
commit searchGET /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/projects is a 404 for a group. repos.list falls back to /groups/:owner/projects on that status only.
  • Search qualifiers are plain text. label:bug searches for the string label:bug.
  • pullRequests.searchGlobal and issues.searchGlobal need no token, but across all of gitlab.com a keyword search can hit GitLab's 408 after about 15 seconds. An owner group or an author narrows 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 listChecks asks GraphQL for the merge request's headPipeline and keeps that one. Why not REST's head_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, only GET /projects/:id/pipelines/:pipeline_id has it, so listChecks reads 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, named pipeline, with a warning on stderr.
  • commits.listChecks filters 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.compare sends every commit and diff of the range in one response and never says what base has that head lacks. behindBy, mergeBaseSha and status are null, filesComplete too, 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.listTags reads the target of an annotated tag that is undated or older than its commit, and a 404 there gives sha: 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.id is the tag, both flags are false, and asking for either on a create or update is a 501 rather than a release that goes public unasked.
  • users.get costs two requests: the username search returns a stub, the id from it reads the full profile.

Where it lives

src/providers/gitlab.ts.