diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3c557bd..2c22142 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -162,6 +162,8 @@ Here is the interface for each forge: | `rateLimit` | optional `{ header }`: response header carrying the remaining request quota; `null` if unsupported | | `tokenStorageKey` | optional `storage.local` key holding the user's token; omit for no auth | | `authHeader` | optional `(token) → headers` sent with API requests; omit for no auth | +| `findPRsForFile` | optional async `(info, headers) → [prNumbers]`: a single query answering "which open PRs/changes touch this file", bypassing the list + per-PR fan-out | +| `permissionHostname` | optional hostname the adapter queries when it differs from the browsed page's (a separate review server); drives the permission check | A forge that omits `tokenStorageKey`/`authHeader` simply stays unauthenticated; declaring both opts it into the [Authentication](#authentication) flow and the @@ -206,6 +208,68 @@ Supported self-hosted software: - **Forgejo / Gitea**: https://forgejo.org/docs/latest/user/api-usage/ - **Bitbucket Server / Data Center**: https://developer.atlassian.com/server/bitbucket/rest/latest/ (self-hosted only — a different API from Bitbucket Cloud) +- **Gerrit**: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html + (a review server paired with a code-hosting mirror — see below) + +### Review-backed forges (Gerrit) + +Some deployments split the two roles a forge normally holds: the code is +*browsed* on one host (a mirror) while the reviews live on a separate review +server, where the extension must send its queries. Such an instance is +configured with three pieces: the mirror hostname (matched against the page), +the software the mirror runs (which URL layout to parse — any supported +family, or the mirror-only Gitiles layout of the `*.googlesource.com` code +browsers), and the review server URL (stored as +`{ type, hostname, mirrorType, reviewUrl }`). + +Two interface members carry the mechanism, and are not specific to any review +tool: + +- `findPRsForFile` — a review server can answer "which open changes touch this + file" in a single query, so the adapter skips the PR list + per-PR files + fan-out entirely (`background.js` takes this fast path whenever the member + exists); +- `permissionHostname` — the queried host is the review server, not the + browsed page, so the host permission requested on add (and checked by the + background) targets it. + +For Gerrit specifically, `buildGerritForge()` implements the interface: it +queries `GET {reviewUrl}/changes/?q=status:open project: file:""` +(the mirror's repo path is the review project name), strips the `)]}'` XSSI +prefix Gerrit puts before its JSON, and links each change to +`{reviewUrl}/c//+/`. A truncated result page is flagged by +`_more_changes` on its last change and followed with the `S` offset parameter, +so more than a page's worth of open changes is still counted in full. With +credentials configured (`username:HTTP-password`, sent as HTTP Basic), the +query goes through the authenticated `/a/` endpoint prefix instead. + +#### Configuring a pairing (example: OpenDev) + +OpenDev (where OpenStack development happens) hosts its code on a Gitea +mirror, `opendev.org`, and reviews it on a separate Gerrit, +`review.opendev.org`. To register the pairing: + +1. open the extension's **options page**; +2. under *Self-hosted forges*, pick the **Gerrit** type — two extra fields + appear; +3. enter the **code-hosting hostname** you browse files on: `opendev.org`; +4. pick the software that site runs — **Forgejo / Gitea** here — so the + extension can parse its file URLs; +5. enter the **review server URL**: `https://review.opendev.org`; +6. click *Add* and **grant the permission** the browser asks for — it targets + the review server, the only host the extension queries. + +Then browse any file on the mirror, e.g. +`https://opendev.org/openstack/nova/src/branch/master/nova/compute/api.py`: +the toolbar badge shows the number of open changes touching it, and the popup +links each one into the review UI. Public review servers like OpenDev's answer +anonymously; for one that requires sign-in, put `username:HTTP-password` (the +HTTP credentials from the Gerrit account settings) in the instance's +credentials field. + +The same process covers Gitiles-fronted deployments — for the Go project, pair +`go.googlesource.com` (software: **Gitiles**) with +`https://go-review.googlesource.com`. To support new self-hosted software, define its family and add it to `selfHostedFamilies`. diff --git a/README.md b/README.md index 5ae2963..959ed28 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,6 @@ API rate limit and list pull requests from private repositories. - GitLab - self-hosted instances (GitLab CE/EE, Forgejo/Gitea, Bitbucket Server/Data Center) — add them in the extension's options +- Gerrit — a review server paired with a code-hosting mirror (the file is + browsed on the mirror, the open changes are queried from the review + server) — add the pairing in the extension's options diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 71d90e7..7c420cd 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -52,7 +52,7 @@ "description": "Title of the self-hosted forges section on the options page" }, "optionsSelfHostedHint": { - "message": "Add your own self-hosted forge instance. Adding one asks for permission to access that site.", + "message": "Add your own self-hosted forge instance. For a review tool paired with a separate code-hosting site (Gerrit), also pick the code-hosting software and give the review server URL. Adding an instance asks for permission to access the queried site.", "description": "Hint explaining the self-hosted forges section" }, "optionsAddInstance": { @@ -86,5 +86,17 @@ "optionsPermissionDenied": { "message": "Permission to access this instance was denied", "description": "Status message when the host permission request is denied" + }, + "optionsReviewUrlPlaceholder": { + "message": "review.example.com (review server URL)", + "description": "Placeholder for the review server URL input of a Gerrit instance" + }, + "optionsInvalidReviewUrl": { + "message": "Enter a valid review server URL", + "description": "Status message when the entered review server URL is invalid" + }, + "optionsGerritTokenHint": { + "message": "Optional. Enter username:HTTP-password (the HTTP credentials generated in the account settings) to query the review server as your user. It is stored only on this device.", + "description": "Hint explaining the credentials input of a Gerrit instance" } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 3a07476..85de3f5 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -52,7 +52,7 @@ "description": "Titre de la section des forges auto-hébergées sur la page d'options" }, "optionsSelfHostedHint": { - "message": "Ajoutez votre propre instance de forge auto-hébergée. L'ajout demande l'autorisation d'accéder à ce site.", + "message": "Ajoutez votre propre instance de forge auto-hébergée. Pour un outil de revue associé à un site d'hébergement de code séparé (Gerrit), choisissez aussi le logiciel d'hébergement et indiquez l'URL du serveur de revue. L'ajout d'une instance demande l'autorisation d'accéder au site interrogé.", "description": "Aide expliquant la section des forges auto-hébergées" }, "optionsAddInstance": { @@ -86,5 +86,17 @@ "optionsPermissionDenied": { "message": "L'autorisation d'accéder à cette instance a été refusée", "description": "Message affiché quand la demande d'autorisation d'hôte est refusée" + }, + "optionsReviewUrlPlaceholder": { + "message": "review.exemple.com (URL du serveur de revue)", + "description": "Texte indicatif du champ d'URL du serveur de revue d'une instance Gerrit" + }, + "optionsInvalidReviewUrl": { + "message": "Saisissez une URL de serveur de revue valide", + "description": "Message affiché quand l'URL du serveur de revue saisie est invalide" + }, + "optionsGerritTokenHint": { + "message": "Optionnel. Saisissez utilisateur:mot-de-passe-HTTP (les identifiants HTTP générés dans les paramètres du compte) pour interroger le serveur de revue avec votre utilisateur. Ils ne sont stockés que sur cet appareil.", + "description": "Aide expliquant le champ d'identifiants d'une instance Gerrit" } } diff --git a/_locales/template.json b/_locales/template.json index 70aded5..7b3b7ed 100644 --- a/_locales/template.json +++ b/_locales/template.json @@ -86,5 +86,17 @@ "optionsPermissionDenied": { "message": "", "description": "" + }, + "optionsReviewUrlPlaceholder": { + "message": "", + "description": "" + }, + "optionsInvalidReviewUrl": { + "message": "", + "description": "" + }, + "optionsGerritTokenHint": { + "message": "", + "description": "" } } diff --git a/background.js b/background.js index f3cd8ea..a3b77af 100644 --- a/background.js +++ b/background.js @@ -164,9 +164,12 @@ async function main(tab) { } // self-hosted instances need an optional host permission before the - // extension may call their API; skip quietly until the user grants it + // extension may call their API; skip quietly until the user grants it. + // The permission covers the queried host — the page hostname, unless the + // forge queries a separate host (a review server like Gerrit) + const queriedHostname = forge.permissionHostname ?? url.hostname if (forge.selfHosted && - !(await browser.permissions.contains({ origins: [`*://${url.hostname}/*`] }))) { + !(await browser.permissions.contains({ origins: [`*://${queriedHostname}/*`] }))) { return } @@ -188,6 +191,14 @@ async function main(tab) { // load the stored API token const requestHeaders = await loadAuthHeaders(forge) + // fast path: a forge that answers "which open PRs/changes touch this + // file" in one query (a review server like Gerrit) skips the PR list + // and the per-PR fan-out below + if (forge.findPRsForFile) { + render(await forge.findPRsForFile(urlInfo, requestHeaders), tab.id) + return + } + // list pull requests (all pages), then keep those that touch the file const { items: prs, response } = await listAllPRs(forge, urlInfo, requestHeaders) checkRateLimitFromPRs(forge, prs, response) diff --git a/forges.js b/forges.js index 3554161..6d2c4f3 100644 --- a/forges.js +++ b/forges.js @@ -388,6 +388,142 @@ const bitbucketServerFamily = { }, } +// +// Gerrit +// +// Gerrit is a code-review tool, not a code host: the code is *browsed* on a +// separate forge (a mirror running one of the families above) while the open +// changes live on the Gerrit review server. A Gerrit adapter therefore pairs +// two hosts — it borrows the mirror family's parseUrl to read the browse page, +// and queries the review server for the changes. Gerrit answers "which open +// changes touch this file" in a single query, exposed through the +// findPRsForFile fast-path interface member (see below); background.js calls +// it instead of the listPRsUrl + per-PR filesUrl fan-out. +// +// Additional interface members introduced for review-backed forges: +// findPRsForFile optional async (info, requestHeaders) -> [prNumbers]; +// a direct query bypassing the list + per-PR fan-out +// permissionHostname optional hostname whose permission the adapter needs +// when it queries a host other than the browsed page's +// +// API docs: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html + +// Gerrit prefixes its JSON responses with a )]}' line against XSSI; strip it +// before parsing +export function parseGerritJson(text) { + return JSON.parse(text.replace(/^\)\]\}'/, "")) +} + +// the Gerrit project name of a browse page: the mirror's full repo path +// (owner/repo, or the nested project path on a GitLab or Gitiles mirror) +function gerritProject(info) { + return info.projectPath ?? `${info.projectKey}/${info.repoSlug}` +} + +// Gitiles — the plain code browser usually deployed next to a Gerrit (the +// *.googlesource.com hosts, e.g. the Go and Android projects). Mirror-only: it +// exposes no pull requests of its own, so it is offered as a Gerrit mirror +// layout but not as a standalone self-hosted forge type. +const gitilesMirror = { + label: "Gitiles", + + // file view: /{project}/+/{ref}/{filepath} — the project may span several + // path segments (platform/frameworks/base) and the ref is either a full + // refs/... name (refs/heads/main) or a single segment (branch, tag or + // commit). The lookahead keeps a ref-only tree URL from misparsing. + parseUrl(url) { + const m = url.pathname.match( + "^/(?.+?)/\\+/(?:refs/[^/]+/[^/]+|(?!refs/)[^/]+)/(?.+)$") + if (!m) return null + return { ...m.groups, origin: url.origin } + }, +} + +// URL layouts a Gerrit mirror can use: every self-hosted family, plus the +// mirror-only Gitiles +function gerritMirrorFor(type) { + return type === "gitiles" ? gitilesMirror : selfHostedFamilies[type] +} + +// build a Gerrit adapter from a configured instance: `hostname` is the browse +// mirror matched against the page, `reviewUrl` the Gerrit origin queried for +// changes, `mirrorType` the software the mirror runs (selects the URL parsing). +// Returns null when the config is incomplete or the mirror type is unknown. +export function buildGerritForge({ hostname, reviewUrl, mirrorType }) { + const mirror = gerritMirrorFor(mirrorType) + if (!mirror || !reviewUrl) return null + + let reviewHostname + try { + reviewHostname = new URL(reviewUrl).hostname + } catch { + return null + } + + return { + label: "Gerrit", + hostnames: [hostname], + base_url: reviewUrl, + // the adapter queries the review server, not the browsed mirror + permissionHostname: reviewHostname, + rateLimit: null, + tokenStorageKey: selfHostedTokenKey(hostname), + + // browse pages have the mirror family's URL layout + parseUrl(url) { + return mirror.parseUrl(url) + }, + + // the query for the open changes touching the file — `start` skips the + // changes already seen (Gerrit's S offset pagination); authenticated + // requests go through Gerrit's /a/ endpoint prefix + changesUrl(info, authenticated = false, start = 0) { + const q = `status:open project:${gerritProject(info)} file:"${info.filepath}"` + const path = authenticated ? "/a/changes/" : "/changes/" + const offset = start > 0 ? `&S=${start}` : "" + return `${this.base_url}${path}?q=${encodeURIComponent(q)}&n=100${offset}` + }, + + // fast path replacing the PR list + per-PR files fan-out. Gerrit flags + // a truncated result on its last change (_more_changes); the next page + // is the same query offset by the changes already fetched + async findPRsForFile(info, requestHeaders = {}) { + const authenticated = "Authorization" in requestHeaders + const prs = [] + let more = true + while (more) { + const response = await fetch(new Request( + this.changesUrl(info, authenticated, prs.length), + { headers: requestHeaders })) + if (!response.ok) { + throw new Error(`Failed to query the review server: [${response.status}] ${response.statusText}`) + } + const changes = parseGerritJson(await response.text()) + prs.push(...changes.map(change => this.prNumber(change))) + more = changes.at(-1)?._more_changes === true + } + return prs + }, + + // _number is the change number shown in URLs (id is a long triplet) + prNumber(change) { + return change._number + }, + + // change links point at the Gerrit review UI + prWebUrl(info, prNumber) { + return `${this.base_url}/c/${gerritProject(info)}/+/${prNumber}` + }, + + // Gerrit authenticates REST calls with HTTP Basic credentials: the + // username and the HTTP password generated in the account settings, + // stored together as "user:http-password" + authHeader(token) { + return { Authorization: `Basic ${btoa(token)}` } + }, + } +} + // registry of forges matched by a static hostname const staticForges = [github, bitbucket, codeberg, gitlab] @@ -414,12 +550,29 @@ const selfHostedFamilies = { } // software types selectable when adding a self-hosted instance, for the options -// page dropdown: [{ type, label }, …] +// page dropdown: [{ type, label }, …] — the families plus the review-backed +// Gerrit type (built by buildGerritForge rather than a family) export function selfHostedTypes() { - return Object.entries(selfHostedFamilies).map(([type, family]) => ({ - type, - label: family.label, - })) + return [ + ...Object.entries(selfHostedFamilies).map(([type, family]) => ({ + type, + label: family.label, + })), + { type: "gerrit", label: "Gerrit" }, + ] +} + +// mirror softwares a Gerrit instance can pair with (the layouts the browse +// mirror may use: the families, plus the mirror-only Gitiles), for the options +// page dropdown +export function gerritMirrorTypes() { + return [ + ...Object.entries(selfHostedFamilies).map(([type, family]) => ({ + type, + label: family.label, + })), + { type: "gitiles", label: "Gitiles" }, + ] } // storage.local key holding the (optional) token for a self-hosted instance; @@ -428,17 +581,22 @@ export function selfHostedTokenKey(hostname) { return `selfHostedToken:${hostname}` } -// build a forge adapter for a configured instance ({ type, hostname }); the -// same instanceOf() used for the public instances, tagged so the background can -// tell it needs an optional host permission. Returns null for an unknown type. -export function buildSelfHostedForge({ type, hostname }) { - const family = selfHostedFamilies[type] - if (!family) return null +// build a forge adapter for a configured instance; tagged so the background +// can tell it needs an optional host permission. A family type is the same +// instanceOf() used for the public instances; the gerrit type pairs two hosts +// and has its own factory. Returns null for an unknown type (or an incomplete +// gerrit config). +export function buildSelfHostedForge(instance) { + const { type, hostname } = instance + + const forge = type === "gerrit" + ? buildGerritForge(instance) + : selfHostedFamilies[type] && instanceOf(selfHostedFamilies[type], { + hostname, + tokenStorageKey: selfHostedTokenKey(hostname), + }) + if (!forge) return null - const forge = instanceOf(family, { - hostname, - tokenStorageKey: selfHostedTokenKey(hostname), - }) return Object.assign(forge, { selfHosted: true, type }) } diff --git a/options/options.html b/options/options.html index 947b11d..aface77 100644 --- a/options/options.html +++ b/options/options.html @@ -27,6 +27,10 @@

__MSG_optionsSelfHostedTitle__

+ + + diff --git a/options/options.js b/options/options.js index 8e6e356..5b7470f 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { browser } from "../browser.js" -import { authForges, selfHostedTypes, selfHostedTokenKey } from "../forges.js" +import { authForges, gerritMirrorTypes, selfHostedTypes, selfHostedTokenKey } from "../forges.js" import { localize } from "../i18n.js" import { loadInstances, saveInstances } from "../storage.js" @@ -135,10 +135,33 @@ function normalizeHostname(value) { } } -// populate the software-type dropdown for the "add instance" form -function renderTypeOptions() { - const select = document.getElementById("instance-type") - for (const { type, label } of selfHostedTypes()) { +// normalize a user-entered review server address to an origin URL; accepts a +// full URL or a bare hostname, returns null when it cannot be parsed +function normalizeOrigin(value) { + let raw = value.trim() + if (!raw) return null + if (!raw.includes("://")) raw = `https://${raw}` + try { + return new URL(raw).origin || null + } catch { + return null + } +} + +// the hostname whose permission an instance needs: the host it queries — the +// review server for a Gerrit instance, the instance itself otherwise +function permissionHostname(instance) { + try { + return instance.reviewUrl ? new URL(instance.reviewUrl).hostname : instance.hostname + } catch { + return instance.hostname + } +} + +// fill a select element with { type, label } options +function fillTypeSelect(id, types) { + const select = document.getElementById(id) + for (const { type, label } of types) { const option = document.createElement("option") option.value = type option.textContent = label @@ -146,6 +169,13 @@ function renderTypeOptions() { } } +// populate the software-type dropdowns for the "add instance" form: the forge +// type, and the mirror software a Gerrit instance pairs with +function renderTypeOptions() { + fillTypeSelect("instance-type", selfHostedTypes()) + fillTypeSelect("instance-mirror-type", gerritMirrorTypes()) +} + // render one fieldset per configured self-hosted instance: its label, a token // field (tagged with the shared data-storage-key so load/save/clear handle it) // and a remove button @@ -157,9 +187,14 @@ async function renderInstances() { const typeLabels = Object.fromEntries(selfHostedTypes().map(t => [t.type, t.label])) for (const instance of instances) { + // a Gerrit instance shows its browse-mirror → review-server pairing + const hosts = instance.reviewUrl + ? `${instance.hostname} → ${instance.reviewUrl}` + : instance.hostname const fieldset = tokenFieldset({ - legendText: `${instance.hostname} — ${typeLabels[instance.type] || instance.type}`, + legendText: `${hosts} — ${typeLabels[instance.type] || instance.type}`, storageKey: selfHostedTokenKey(instance.hostname), + hintKey: instance.type === "gerrit" ? "optionsGerritTokenHint" : undefined, }) const remove = document.createElement("button") @@ -173,8 +208,9 @@ async function renderInstances() { } } -// add a self-hosted instance: validate the hostname, request the host -// permission (this click is the required user gesture), then persist it +// add a self-hosted instance: validate the hostname (and, for Gerrit, the +// review server URL), request the queried host's permission (this click is the +// required user gesture), then persist it async function addInstance() { const type = document.getElementById("instance-type").value const hostname = normalizeHostname(document.getElementById("instance-hostname").value) @@ -183,6 +219,19 @@ async function addInstance() { return } + const instance = { type, hostname } + if (type === "gerrit") { + // a Gerrit instance pairs the browsed mirror with the review server + // it queries, and needs the mirror's URL layout to parse pages + const reviewUrl = normalizeOrigin(document.getElementById("instance-review-url").value) + if (!reviewUrl) { + showStatus("optionsInvalidReviewUrl", "instance-status") + return + } + instance.reviewUrl = reviewUrl + instance.mirrorType = document.getElementById("instance-mirror-type").value + } + try { const instances = await loadInstances() @@ -191,17 +240,19 @@ async function addInstance() { return } - // the extension may only call the instance API once the user grants - // access to its origin - const granted = await browser.permissions.request({ origins: [`*://${hostname}/*`] }) + // the extension may only call the queried API (the review server for + // a Gerrit instance) once the user grants access to its origin + const granted = await browser.permissions.request( + { origins: [`*://${permissionHostname(instance)}/*`] }) if (!granted) { showStatus("optionsPermissionDenied", "instance-status") return } - instances.push({ type, hostname }) + instances.push(instance) await saveInstances(instances) document.getElementById("instance-hostname").value = "" + document.getElementById("instance-review-url").value = "" await renderInstances() await loadTokens() showStatus("optionsInstanceAdded", "instance-status") @@ -211,14 +262,18 @@ async function addInstance() { } // remove a self-hosted instance: drop it from storage, forget its token and -// give back the host permission we no longer need +// give back the queried host's permission we no longer need async function removeInstance(hostname) { try { - const instances = (await loadInstances()).filter(i => i.hostname !== hostname) + const instances = await loadInstances() + const removed = instances.find(i => i.hostname === hostname) - await saveInstances(instances) + await saveInstances(instances.filter(i => i.hostname !== hostname)) await browser.storage.local.remove(selfHostedTokenKey(hostname)) - await browser.permissions.remove({ origins: [`*://${hostname}/*`] }) + if (removed) { + await browser.permissions.remove( + { origins: [`*://${permissionHostname(removed)}/*`] }) + } await renderInstances() showStatus("optionsInstanceRemoved", "instance-status") @@ -235,6 +290,19 @@ renderForges() renderTypeOptions() document.getElementById("instance-hostname").placeholder = browser.i18n.getMessage("optionsHostnamePlaceholder") +document.getElementById("instance-review-url").placeholder = + browser.i18n.getMessage("optionsReviewUrlPlaceholder") + +// the mirror-type and review-URL fields only apply to the gerrit type +const typeSelect = document.getElementById("instance-type") +function syncGerritFields() { + const gerrit = typeSelect.value === "gerrit" + document.getElementById("instance-mirror-type").hidden = !gerrit + document.getElementById("instance-review-url").hidden = !gerrit +} +typeSelect.addEventListener("change", syncGerritFields) +syncGerritFields() + // fill token fields once both the static forges and the instances are rendered renderInstances().then(loadTokens) document.getElementById("options-form").addEventListener("submit", saveTokens) diff --git a/tests/forges.test.js b/tests/forges.test.js index 614babb..d89fc20 100644 --- a/tests/forges.test.js +++ b/tests/forges.test.js @@ -5,6 +5,7 @@ import { github, bitbucket, codeberg, gitlab, forgeForHostname, selfHostedTypes, selfHostedTokenKey, buildSelfHostedForge, + buildGerritForge, gerritMirrorTypes, parseGerritJson, } from "../forges.js" // @@ -386,6 +387,7 @@ test("selfHostedTypes lists the supported software with labels", () => { { type: "gitlab", label: "GitLab" }, { type: "forgejo", label: "Forgejo / Gitea" }, { type: "bitbucket-server", label: "Bitbucket Server / Data Center" }, + { type: "gerrit", label: "Gerrit" }, ]) }) @@ -515,3 +517,267 @@ test("forgeForHostname matches a self-hosted instance by exact hostname only", ( const instances = [{ type: "forgejo", hostname: "git.example.com" }] assert.equal(forgeForHostname("other.example.com", instances), null) }) + +// +// Gerrit (review server paired with a code-hosting mirror) +// +const gerritInstance = { + type: "gerrit", + hostname: "git.example.com", + reviewUrl: "https://review.example.com", + mirrorType: "forgejo", +} + +test("buildGerritForge returns null on an incomplete or invalid config", () => { + assert.equal(buildGerritForge({ ...gerritInstance, reviewUrl: undefined }), null) + assert.equal(buildGerritForge({ ...gerritInstance, reviewUrl: "not a url" }), null) + assert.equal(buildGerritForge({ ...gerritInstance, mirrorType: "unknown" }), null) +}) + +test("gerrit parses browse URLs with the mirror family's layout", () => { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/roles/web/tasks.yml")) + assert.deepEqual(info, { + projectKey: "ops", + repoSlug: "deploy", + filepath: "roles/web/tasks.yml", + origin: "https://git.example.com", + }) +}) + +test("gerrit takes the project name from a GitLab mirror's nested path", () => { + const forge = buildGerritForge({ ...gerritInstance, mirrorType: "gitlab" }) + const info = forge.parseUrl( + new URL("https://git.example.com/group/sub/proj/-/blob/main/a/b.py")) + assert.equal(forge.prWebUrl(info, 42), + "https://review.example.com/c/group/sub/proj/+/42") +}) + +test("gerrit.changesUrl queries the review server for the open changes touching the file", () => { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/roles/web/tasks.yml")) + assert.equal(forge.changesUrl(info), + "https://review.example.com/changes/?q=" + + encodeURIComponent('status:open project:ops/deploy file:"roles/web/tasks.yml"') + + "&n=100") +}) + +test("gerrit.changesUrl uses the authenticated /a/ endpoint with credentials", () => { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + assert.match(forge.changesUrl(info, true), + /^https:\/\/review\.example\.com\/a\/changes\/\?q=/) +}) + +test("gerrit.changesUrl offsets a follow-up page with the S parameter", () => { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + assert.match(forge.changesUrl(info, false, 100), /&n=100&S=100$/) + // the first page carries no offset + assert.doesNotMatch(forge.changesUrl(info), /&S=/) +}) + +test("parseGerritJson strips the XSSI prefix", () => { + const body = ")]}'\n" + JSON.stringify([{ _number: 4211 }, { _number: 199 }]) + assert.deepEqual(parseGerritJson(body), [{ _number: 4211 }, { _number: 199 }]) +}) + +test("parseGerritJson handles an empty change list", () => { + assert.deepEqual(parseGerritJson(")]}'\n[]"), []) +}) + +test("gerrit.findPRsForFile returns the change numbers in one query", async () => { + const realFetch = globalThis.fetch + const realRequest = globalThis.Request + + const requested = [] + globalThis.Request = class { constructor(url, opts) { this.url = url; this.opts = opts } } + globalThis.fetch = async request => { + requested.push(request) + return { + ok: true, + text: async () => ")]}'\n" + JSON.stringify([{ _number: 4211 }, { _number: 199 }]), + } + } + + try { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + + // anonymous: /changes/ endpoint + assert.deepEqual(await forge.findPRsForFile(info), [4211, 199]) + assert.match(requested[0].url, /\/changes\/\?q=/) + + // authenticated: /a/changes/ endpoint, headers forwarded + const headers = forge.authHeader("user:http-password") + assert.deepEqual(await forge.findPRsForFile(info, headers), [4211, 199]) + assert.match(requested[1].url, /\/a\/changes\/\?q=/) + assert.deepEqual(requested[1].opts.headers, headers) + } finally { + globalThis.fetch = realFetch + globalThis.Request = realRequest + } +}) + +test("gerrit.findPRsForFile follows the _more_changes pagination", async () => { + const realFetch = globalThis.fetch + const realRequest = globalThis.Request + + // two pages: the first flags a truncated result on its last change + const pages = [ + [{ _number: 1 }, { _number: 2, _more_changes: true }], + [{ _number: 3 }], + ] + const requested = [] + globalThis.Request = class { constructor(url) { this.url = url } } + globalThis.fetch = async request => { + requested.push(request.url) + return { + ok: true, + text: async () => ")]}'\n" + JSON.stringify(pages[requested.length - 1]), + } + } + + try { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + assert.deepEqual(await forge.findPRsForFile(info), [1, 2, 3]) + // the second page is requested with the offset of the changes seen + assert.equal(requested.length, 2) + assert.doesNotMatch(requested[0], /&S=/) + assert.match(requested[1], /&S=2$/) + } finally { + globalThis.fetch = realFetch + globalThis.Request = realRequest + } +}) + +test("gerrit.findPRsForFile throws on a non-ok response", async () => { + const realFetch = globalThis.fetch + const realRequest = globalThis.Request + + globalThis.Request = class { constructor(url) { this.url = url } } + globalThis.fetch = async () => ({ ok: false, status: 403, statusText: "Forbidden" }) + + try { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + await assert.rejects(forge.findPRsForFile(info), + /Failed to query the review server: \[403\] Forbidden/) + } finally { + globalThis.fetch = realFetch + globalThis.Request = realRequest + } +}) + +test("gerrit.prWebUrl links to the change on the review server", () => { + const forge = buildGerritForge(gerritInstance) + const info = forge.parseUrl( + new URL("https://git.example.com/ops/deploy/src/branch/main/f.js")) + assert.equal(forge.prWebUrl(info, 4211), "https://review.example.com/c/ops/deploy/+/4211") +}) + +test("gerrit.authHeader sends the credentials as HTTP Basic", () => { + const forge = buildGerritForge(gerritInstance) + assert.deepEqual(forge.authHeader("user:http-password"), + { Authorization: `Basic ${btoa("user:http-password")}` }) +}) + +test("gerrit queries the review host, not the browsed mirror", () => { + const forge = buildGerritForge(gerritInstance) + assert.equal(forge.permissionHostname, "review.example.com") +}) + +test("selfHostedTypes offers gerrit; gerritMirrorTypes offers the mirror layouts", () => { + assert.ok(selfHostedTypes().some(t => t.type === "gerrit")) + // gerrit itself cannot be a mirror; gitiles is mirror-only, so it is + // offered here but not as a standalone self-hosted type + const mirrors = gerritMirrorTypes().map(t => t.type) + assert.deepEqual(mirrors, ["gitlab", "forgejo", "bitbucket-server", "gitiles"]) + assert.ok(!selfHostedTypes().some(t => t.type === "gitiles")) +}) + +// +// Gitiles mirror layout +// +const gitilesInstance = { + type: "gerrit", + hostname: "go.googlesource.com", + reviewUrl: "https://go-review.googlesource.com", + mirrorType: "gitiles", +} + +test("gitiles mirror parses a full-ref file URL", () => { + const forge = buildGerritForge(gitilesInstance) + const info = forge.parseUrl( + new URL("https://go.googlesource.com/go/+/refs/heads/master/src/net/http/server.go")) + assert.deepEqual(info, { + projectPath: "go", + filepath: "src/net/http/server.go", + origin: "https://go.googlesource.com", + }) +}) + +test("gitiles mirror parses a single-segment ref (branch, tag or commit)", () => { + const forge = buildGerritForge(gitilesInstance) + const byBranch = forge.parseUrl( + new URL("https://go.googlesource.com/go/+/master/src/net/http/server.go")) + assert.equal(byBranch.filepath, "src/net/http/server.go") + + const byCommit = forge.parseUrl( + new URL("https://go.googlesource.com/go/+/2c1b5130aa3c30e11146dbcf7fca4bcf6de74dd2/README.md")) + assert.equal(byCommit.filepath, "README.md") +}) + +test("gitiles mirror keeps a multi-segment project path", () => { + const forge = buildGerritForge({ + ...gitilesInstance, + hostname: "android.googlesource.com", + reviewUrl: "https://android-review.googlesource.com", + }) + const info = forge.parseUrl(new URL( + "https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/app/Activity.java")) + assert.equal(info.projectPath, "platform/frameworks/base") + assert.equal(info.filepath, "core/java/android/app/Activity.java") + assert.equal(forge.prWebUrl(info, 321), + "https://android-review.googlesource.com/c/platform/frameworks/base/+/321") +}) + +test("gitiles mirror rejects non-file pages", () => { + const forge = buildGerritForge(gitilesInstance) + // repo home, ref-only tree root, log view + assert.equal(forge.parseUrl(new URL("https://go.googlesource.com/go")), null) + assert.equal(forge.parseUrl(new URL("https://go.googlesource.com/go/+/refs/heads/master")), null) + assert.equal(forge.parseUrl(new URL("https://go.googlesource.com/go/+log/refs/heads/master/src")), null) +}) + +test("gitiles mirror queries the review project by its full path", () => { + const forge = buildGerritForge(gitilesInstance) + const info = forge.parseUrl( + new URL("https://go.googlesource.com/go/+/refs/heads/master/src/net/http/server.go")) + assert.equal(forge.changesUrl(info), + "https://go-review.googlesource.com/changes/?q=" + + encodeURIComponent('status:open project:go file:"src/net/http/server.go"') + + "&n=100") + assert.equal(forge.prWebUrl(info, 628575), + "https://go-review.googlesource.com/c/go/+/628575") +}) + +test("forgeForHostname matches a gerrit instance by its mirror hostname", () => { + const forge = forgeForHostname("git.example.com", [gerritInstance]) + assert.equal(forge.selfHosted, true) + assert.equal(forge.type, "gerrit") + assert.equal(forge.base_url, "https://review.example.com") +}) + +test("forgeForHostname returns null for an incomplete gerrit instance", () => { + const instances = [{ type: "gerrit", hostname: "git.example.com" }] + assert.equal(forgeForHostname("git.example.com", instances), null) +})