Skip to content

Commit 132264d

Browse files
committed
feat: support Bitbucket Server/Data Center self-hosted instances
Ref #2
1 parent f7bcc1e commit 132264d

4 files changed

Lines changed: 130 additions & 2 deletions

File tree

‎DOCUMENTATION.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ skips an instance until its permission is granted.
183183
Supported self-hosted software:
184184
- **GitLab** (CE/EE): https://docs.gitlab.com/ee/api/merge_requests.html
185185
- **Forgejo / Gitea**: https://forgejo.org/docs/latest/user/api-usage/
186+
- **Bitbucket Server / Data Center**: https://developer.atlassian.com/server/bitbucket/rest/latest/
187+
(self-hosted only — a different API from Bitbucket Cloud)
186188

187189
To support new self-hosted software, define its family and add it to
188190
`selfHostedFamilies`.

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ API rate limit and list pull requests from private repositories.
2525
- Bitbucket
2626
- Codeberg
2727
- GitLab
28-
- self-hosted GitLab (CE/EE) and Forgejo/Gitea instances (add them in the
29-
extension's options)
28+
- self-hosted instances (GitLab CE/EE, Forgejo/Gitea, Bitbucket Server/Data
29+
Center) — add them in the extension's options

‎forges.js‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,67 @@ export const gitlab = instanceOf(gitlabFamily, {
277277
tokenStorageKey: "gitlabToken",
278278
})
279279

280+
// Bitbucket Data Center / Server family: self-hosted only — Bitbucket Cloud
281+
// (the `bitbucket` adapter above) is a separate product with a different API.
282+
// Serves its REST API under /rest/api/latest and paginates in a `values` array.
283+
// API docs: https://developer.atlassian.com/server/bitbucket/rest/latest/
284+
const bitbucketServerFamily = {
285+
label: "Bitbucket Server / Data Center",
286+
apiPath: "/rest/api/latest",
287+
288+
parseUrl(url) {
289+
// file view, project repo: /projects/{projectKey}/repos/{repoSlug}/browse/{filepath}
290+
// file view, personal repo: /users/{userSlug}/repos/{repoSlug}/browse/{filepath}
291+
// (the branch/tag ref lives in the ?at= query, so filepath stays clean)
292+
const m = url.pathname.match(
293+
"^/(?<section>projects|users)/(?<owner>[^/]+)/repos/(?<repoSlug>[^/]+)/browse/(?<filepath>.+)$")
294+
if (!m) return null
295+
const { section, owner, repoSlug, filepath } = m.groups
296+
// the API addresses a personal repo as the ~{userSlug} project
297+
const projectKey = section === "users" ? `~${owner}` : owner
298+
return { projectKey, repoSlug, filepath, origin: url.origin }
299+
},
300+
301+
listPRsUrl(info) {
302+
// the pull-requests endpoint defaults to OPEN, but be explicit
303+
return `${this.base_url}/projects/${info.projectKey}/repos/${info.repoSlug}/pull-requests?state=OPEN`
304+
},
305+
306+
// the list endpoint wraps the PRs in a paginated envelope
307+
pullRequests(data) {
308+
return data.values
309+
},
310+
311+
filesUrl(info, pr) {
312+
return `${this.base_url}/projects/${info.projectKey}/repos/${info.repoSlug}/pull-requests/${this.prNumber(pr)}/changes`
313+
},
314+
315+
// each change carries the file path as `path.toString`
316+
filenames(changes) {
317+
return changes.values.map(change => change.path.toString)
318+
},
319+
320+
prNumber(pr) {
321+
return pr.id
322+
},
323+
324+
prWebUrl(info, prNumber) {
325+
// a personal repo (project key ~{userSlug}) lives under /users/{userSlug}
326+
const repoPath = info.projectKey.startsWith("~")
327+
? `users/${info.projectKey.slice(1)}/repos/${info.repoSlug}`
328+
: `projects/${info.projectKey}/repos/${info.repoSlug}`
329+
return `${info.origin}/${repoPath}/pull-requests/${prNumber}`
330+
},
331+
332+
// Bitbucket Server exposes no simple rate-limit endpoint.
333+
rateLimit: null,
334+
335+
// Bitbucket Server accepts an HTTP access token as a Bearer credential.
336+
authHeader(token) {
337+
return { Authorization: `Bearer ${token}` }
338+
},
339+
}
340+
280341
// registry of forges matched by a static hostname
281342
const staticForges = [github, bitbucket, codeberg, gitlab]
282343

@@ -299,6 +360,7 @@ export function authForges() {
299360
const selfHostedFamilies = {
300361
gitlab: gitlabFamily,
301362
forgejo: forgejoFamily,
363+
"bitbucket-server": bitbucketServerFamily,
302364
}
303365

304366
// software types selectable when adding a self-hosted instance, for the options

‎tests/forges.test.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ test("selfHostedTypes lists the supported software with labels", () => {
343343
assert.deepEqual(selfHostedTypes(), [
344344
{ type: "gitlab", label: "GitLab" },
345345
{ type: "forgejo", label: "Forgejo / Gitea" },
346+
{ type: "bitbucket-server", label: "Bitbucket Server / Data Center" },
346347
])
347348
})
348349

@@ -383,6 +384,69 @@ test("buildSelfHostedForge builds a Forgejo instance adapter reusing the codeber
383384
assert.deepEqual(forge.authHeader("cb_secret"), { Authorization: "token cb_secret" })
384385
})
385386

387+
test("buildSelfHostedForge builds a Bitbucket Server instance adapter", () => {
388+
const forge = buildSelfHostedForge({ type: "bitbucket-server", hostname: "bitbucket.example.com" })
389+
390+
assert.equal(forge.base_url, "https://bitbucket.example.com/rest/api/latest")
391+
assert.equal(forge.selfHosted, true)
392+
393+
const info = { projectKey: "PRJ", repoSlug: "widget", origin: "https://bitbucket.example.com" }
394+
assert.equal(
395+
forge.listPRsUrl(info),
396+
"https://bitbucket.example.com/rest/api/latest/projects/PRJ/repos/widget/pull-requests?state=OPEN")
397+
assert.equal(
398+
forge.filesUrl(info, { id: 7 }),
399+
"https://bitbucket.example.com/rest/api/latest/projects/PRJ/repos/widget/pull-requests/7/changes")
400+
assert.equal(
401+
forge.prWebUrl(info, 7),
402+
"https://bitbucket.example.com/projects/PRJ/repos/widget/pull-requests/7")
403+
assert.equal(forge.prNumber({ id: 7 }), 7)
404+
assert.deepEqual(forge.authHeader("bbs_secret"), { Authorization: "Bearer bbs_secret" })
405+
})
406+
407+
test("bitbucket-server parseUrl extracts project, repo and filepath from a browse URL", () => {
408+
const forge = buildSelfHostedForge({ type: "bitbucket-server", hostname: "bitbucket.example.com" })
409+
const url = new URL("https://bitbucket.example.com/projects/PRJ/repos/widget/browse/src/app.js?at=refs%2Fheads%2Fmain")
410+
assert.deepEqual(forge.parseUrl(url), {
411+
projectKey: "PRJ",
412+
repoSlug: "widget",
413+
filepath: "src/app.js",
414+
origin: "https://bitbucket.example.com",
415+
})
416+
})
417+
418+
test("bitbucket-server parseUrl maps a personal repo browse URL to the ~userSlug project", () => {
419+
const forge = buildSelfHostedForge({ type: "bitbucket-server", hostname: "bitbucket.example.com" })
420+
const url = new URL("https://bitbucket.example.com/users/jdoe/repos/widget/browse/dir/app.js?at=refs%2Fheads%2Fmain")
421+
assert.deepEqual(forge.parseUrl(url), {
422+
projectKey: "~jdoe",
423+
repoSlug: "widget",
424+
filepath: "dir/app.js",
425+
origin: "https://bitbucket.example.com",
426+
})
427+
})
428+
429+
test("bitbucket-server addresses a personal repo as ~userSlug in the API but /users on the web", () => {
430+
const forge = buildSelfHostedForge({ type: "bitbucket-server", hostname: "bitbucket.example.com" })
431+
const info = { projectKey: "~jdoe", repoSlug: "widget", origin: "https://bitbucket.example.com" }
432+
assert.equal(
433+
forge.listPRsUrl(info),
434+
"https://bitbucket.example.com/rest/api/latest/projects/~jdoe/repos/widget/pull-requests?state=OPEN")
435+
assert.equal(
436+
forge.prWebUrl(info, 7),
437+
"https://bitbucket.example.com/users/jdoe/repos/widget/pull-requests/7")
438+
})
439+
440+
test("bitbucket-server pullRequests and filenames unwrap the paginated envelopes", () => {
441+
const forge = buildSelfHostedForge({ type: "bitbucket-server", hostname: "bitbucket.example.com" })
442+
assert.deepEqual(forge.pullRequests({ values: [{ id: 1 }, { id: 2 }] }), [{ id: 1 }, { id: 2 }])
443+
const changes = { values: [
444+
{ path: { toString: "a.js" } },
445+
{ path: { toString: "dir/b.js" } },
446+
] }
447+
assert.deepEqual(forge.filenames(changes), ["a.js", "dir/b.js"])
448+
})
449+
386450
test("the public instance and a self-hosted instance share one family", () => {
387451
// the inversion: gitlab.com is itself an instanceOf the GitLab family, the
388452
// same family a self-hosted GitLab is built from, so they share a prototype

0 commit comments

Comments
 (0)