Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<path> file:"<path>"`
(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/<project>/+/<number>`. 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`.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
}
14 changes: 13 additions & 1 deletion _locales/fr/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
}
12 changes: 12 additions & 0 deletions _locales/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,17 @@
"optionsPermissionDenied": {
"message": "",
"description": ""
},
"optionsReviewUrlPlaceholder": {
"message": "",
"description": ""
},
"optionsInvalidReviewUrl": {
"message": "",
"description": ""
},
"optionsGerritTokenHint": {
"message": "",
"description": ""
}
}
15 changes: 13 additions & 2 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
Expand Down
188 changes: 173 additions & 15 deletions forges.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"^/(?<projectPath>.+?)/\\+/(?:refs/[^/]+/[^/]+|(?!refs/)[^/]+)/(?<filepath>.+)$")
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]

Expand All @@ -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;
Expand All @@ -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 })
}

Expand Down
Loading