Skip to content

Commit 01e7df9

Browse files
committed
feat(forges): support the GitLab public instance
gitlab.com is the public instance of GitLab. Ref #1
1 parent 05a2c39 commit 01e7df9

5 files changed

Lines changed: 147 additions & 6 deletions

File tree

‎DOCUMENTATION.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ fine-grained) — no scope is required for public repositories; grant repository
109109
read access for private ones. For Bitbucket Cloud, create a
110110
[repository, project or workspace access token][bb-token] (or an API token)
111111
with pull-request read access. For Codeberg, create an [access token][cb-token]
112-
in your account settings with repository read scope.
112+
in your account settings with repository read scope. For GitLab, create a
113+
[personal or project access token][gl-token] with the `read_api` scope.
113114

114115
[gh-token]: https://github.com/settings/tokens
115116
[bb-token]: https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/
116117
[cb-token]: https://docs.codeberg.org/advanced/access-token/
118+
[gl-token]: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html
117119

118120
## Forges
119121

@@ -152,7 +154,8 @@ So, to add a new forge:
152154
Supported forges:
153155
- **GitHub**: https://docs.github.com/en/rest/pulls
154156
- **Bitbucket** (Cloud): https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/
155-
- **Codeberg** (Forgejo, Gitea-compatible API): https://forgejo.org/docs/latest/user/api-usage/.
157+
- **Codeberg** (Forgejo, Gitea-compatible API): https://forgejo.org/docs/latest/user/api-usage/
158+
- **GitLab**: https://docs.gitlab.com/ee/api/merge_requests.html.
156159

157160
## Internationalization
158161

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ API rate limit and list pull requests from private repositories.
2424
- GitHub
2525
- Bitbucket
2626
- Codeberg
27+
- GitLab

‎forges.js‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,73 @@ export const codeberg = {
182182
},
183183
}
184184

185+
// API docs: https://docs.gitlab.com/ee/api/merge_requests.html
186+
// GitLab calls PRs "merge requests" and identifies a project by its full,
187+
// URL-encoded path (namespaces can be nested: group/subgroup/project), so the
188+
// adapter carries a single `projectPath` in `info` rather than a projectKey /
189+
// repoSlug pair. The `/-/` separator in web URLs delimits the project path from
190+
// the route type (blob, merge_requests, …).
191+
export const gitlab = {
192+
label: "GitLab",
193+
hostnames: ["gitlab.com"],
194+
base_url: "https://gitlab.com/api/v4",
195+
196+
parseUrl(url) {
197+
// file view: /{group}/.../{project}/-/blob/{ref}/{filepath}
198+
const m = url.pathname.match(
199+
"^/(?<projectPath>.+?)/-/blob/[^/]+/(?<filepath>.+)$")
200+
if (!m) return null
201+
return { ...m.groups, origin: url.origin }
202+
},
203+
204+
// the project is addressed by its URL-encoded full path
205+
projectId(info) {
206+
return encodeURIComponent(info.projectPath)
207+
},
208+
209+
listPRsUrl(info) {
210+
// /merge_requests defaults to every state, so filter to the open ones
211+
return `${this.base_url}/projects/${this.projectId(info)}/merge_requests?state=opened`
212+
},
213+
214+
// the list endpoint returns a JSON array of merge requests
215+
pullRequests(data) {
216+
return data
217+
},
218+
219+
filesUrl(info, pr) {
220+
return `${this.base_url}/projects/${this.projectId(info)}/merge_requests/${this.prNumber(pr)}/diffs`
221+
},
222+
223+
// a diff entry keeps new_path == old_path for deletions, so new_path is safe
224+
filenames(files) {
225+
return files.map(file => file.new_path)
226+
},
227+
228+
// iid is the project-internal number shown in URLs (id is global)
229+
prNumber(pr) {
230+
return pr.iid
231+
},
232+
233+
prWebUrl(info, prNumber) {
234+
return `${info.origin}/${info.projectPath}/-/merge_requests/${prNumber}`
235+
},
236+
237+
// GitLab exposes rate-limit info only through response headers, not a
238+
// pollable endpoint.
239+
rateLimit: null,
240+
241+
// GitLab accepts a personal or project access token via the PRIVATE-TOKEN
242+
// header. Authenticating grants access to private repositories.
243+
tokenStorageKey: "gitlabToken",
244+
245+
authHeader(token) {
246+
return { "PRIVATE-TOKEN": token }
247+
},
248+
}
249+
185250
// registry of forges matched by a static hostname
186-
const staticForges = [github, bitbucket, codeberg]
251+
const staticForges = [github, bitbucket, codeberg, gitlab]
187252

188253
// pick a forge adapter for a page hostname (null if none matches).
189254
export function forgeForHostname(hostname) {

‎manifest.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"host_permissions": [
5555
"*://api.github.com/*",
5656
"*://api.bitbucket.org/*",
57-
"*://codeberg.org/*"
57+
"*://codeberg.org/*",
58+
"*://gitlab.com/*"
5859
]
5960
}

‎tests/forges.test.js‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test } from "node:test"
22
import assert from "node:assert/strict"
33

4-
import { github, bitbucket, codeberg, forgeForHostname } from "../forges.js"
4+
import { github, bitbucket, codeberg, gitlab, forgeForHostname } from "../forges.js"
55

66
//
77
// forgeForHostname
@@ -18,13 +18,16 @@ test("forgeForHostname matches codeberg.org", () => {
1818
assert.equal(forgeForHostname("codeberg.org"), codeberg)
1919
})
2020

21+
test("forgeForHostname matches gitlab.com", () => {
22+
assert.equal(forgeForHostname("gitlab.com"), gitlab)
23+
})
24+
2125
test("forgeForHostname matches a subdomain containing the hostname", () => {
2226
// hostnames are matched as substrings of the page hostname
2327
assert.equal(forgeForHostname("www.github.com"), github)
2428
})
2529

2630
test("forgeForHostname returns null for an unknown forge", () => {
27-
assert.equal(forgeForHostname("gitlab.com"), null)
2831
assert.equal(forgeForHostname("example.com"), null)
2932
})
3033

@@ -260,3 +263,71 @@ test("codeberg.authHeader builds a token Authorization header", () => {
260263
Authorization: "token cb_secret",
261264
})
262265
})
266+
267+
//
268+
// gitlab adapter (merge requests, nested namespaces, URL-encoded project path)
269+
//
270+
test("gitlab.parseUrl extracts the project path and filepath from a blob URL", () => {
271+
const url = new URL("https://gitlab.com/gitlab-org/gitlab/-/blob/master/README.md")
272+
assert.deepEqual(gitlab.parseUrl(url), {
273+
projectPath: "gitlab-org/gitlab",
274+
filepath: "README.md",
275+
origin: "https://gitlab.com",
276+
})
277+
})
278+
279+
test("gitlab.parseUrl keeps nested group namespaces in the project path", () => {
280+
const url = new URL("https://gitlab.com/group/subgroup/widget/-/blob/main/dir/app.js")
281+
const info = gitlab.parseUrl(url)
282+
assert.equal(info.projectPath, "group/subgroup/widget")
283+
assert.equal(info.filepath, "dir/app.js")
284+
})
285+
286+
test("gitlab.parseUrl returns null for a non-file URL", () => {
287+
assert.equal(gitlab.parseUrl(new URL("https://gitlab.com/acme/widget")), null)
288+
})
289+
290+
test("gitlab.listPRsUrl builds the merge_requests endpoint with the encoded path", () => {
291+
const info = { projectPath: "group/subgroup/widget" }
292+
assert.equal(
293+
gitlab.listPRsUrl(info),
294+
"https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fwidget/merge_requests?state=opened")
295+
})
296+
297+
test("gitlab.filesUrl builds the diffs endpoint for a merge request", () => {
298+
const info = { projectPath: "acme/widget" }
299+
assert.equal(
300+
gitlab.filesUrl(info, { iid: 42 }),
301+
"https://gitlab.com/api/v4/projects/acme%2Fwidget/merge_requests/42/diffs")
302+
})
303+
304+
test("gitlab.prWebUrl builds the web URL of a merge request", () => {
305+
const info = { projectPath: "acme/widget", origin: "https://gitlab.com" }
306+
assert.equal(
307+
gitlab.prWebUrl(info, 42),
308+
"https://gitlab.com/acme/widget/-/merge_requests/42")
309+
})
310+
311+
test("gitlab.prNumber reads the iid off a merge request object", () => {
312+
assert.equal(gitlab.prNumber({ id: 999, iid: 7, title: "ignored" }), 7)
313+
})
314+
315+
test("gitlab.pullRequests returns the list response as-is", () => {
316+
const mrs = [{ iid: 1 }, { iid: 2 }]
317+
assert.equal(gitlab.pullRequests(mrs), mrs)
318+
})
319+
320+
test("gitlab.filenames maps diff entries to their new_path", () => {
321+
const files = [{ new_path: "a.js", old_path: "a.js" }, { new_path: "dir/b.js", old_path: "dir/b.js" }]
322+
assert.deepEqual(gitlab.filenames(files), ["a.js", "dir/b.js"])
323+
})
324+
325+
test("gitlab exposes no rate-limit endpoint", () => {
326+
assert.equal(gitlab.rateLimit, null)
327+
})
328+
329+
test("gitlab.authHeader builds a PRIVATE-TOKEN header", () => {
330+
assert.deepEqual(gitlab.authHeader("gl_secret"), {
331+
"PRIVATE-TOKEN": "gl_secret",
332+
})
333+
})

0 commit comments

Comments
 (0)