Skip to content

Commit f7bcc1e

Browse files
committed
feat: add self-hosted forges support
A self-hosted forge is described as a "family" adapter, holding the forge logic, then built with the `instanceOf` function. Public instances are new instances of a family: Codeberg for Forgejo, GitLab for GitLab (CE/EE). About configuration for self-hosted instances: - the configuration is described into the options page - host permissions are optional and requested via the options page when an instance is added. Forgejo and GitLab CE/EE are the two self-hosted forges covered as adapters already exists. Ref #1 Ref #9 Ref #10
1 parent 01e7df9 commit f7bcc1e

13 files changed

Lines changed: 548 additions & 49 deletions

File tree

‎DOCUMENTATION.md‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ as an `Authorization` header to that forge's API.
102102

103103
The options page is forge-agnostic: it renders one token field per forge that
104104
declares authentication support (see `authForges()` in `forges.js`), so a new
105-
authenticated forge needs no options-page change.
105+
authenticated forge needs no options-page change. Self-hosted instances (see
106+
[Forges](#forges)) get their own token field too, keyed per instance.
106107

107108
For GitHub, create a [personal access token][gh-token] (classic or
108109
fine-grained) — no scope is required for public repositories; grant repository
@@ -121,15 +122,18 @@ in your account settings with repository read scope. For GitLab, create a
121122

122123
Forge endpoints are configured in `forges.js`, which defines one **adapter**
123124
per forge that normalizes its API and URL quirks behind a common interface,
124-
plus a `forgeForHostname()` registry lookup.
125+
plus a `forgeForHostname()` registry lookup. Single-host forges (GitHub,
126+
Bitbucket) are plain adapter objects; multi-host forges (GitLab, Forgejo/Gitea)
127+
are *families* whose instances — public and self-hosted alike — are built with
128+
`instanceOf()` (see [Families and self-hosted forges](#families-and-self-hosted-forges)).
125129

126130
Here is the interface for each forge:
127131

128132
| Member | Purpose |
129133
| ------------ | ------------------------------------------------------------------ |
130134
| `label` | human-readable forge name |
131-
| `hostnames` | array of hostnames (static forges only) |
132-
| `base_url` | API URL (static forges only) |
135+
| `hostnames` | array of hostnames matched against the page (one per family instance) |
136+
| `base_url` | API root URL the endpoint builders are hung off of |
133137
| `parseUrl` | `(URL) → { projectKey, repoSlug, filepath, origin }` or `null` |
134138
| `listPRsUrl` | API endpoint listing the repo's open PRs |
135139
| `pullRequests` | `(listResponse) → array of PR objects` (unwraps paginated envelopes) |
@@ -157,6 +161,32 @@ Supported forges:
157161
- **Codeberg** (Forgejo, Gitea-compatible API): https://forgejo.org/docs/latest/user/api-usage/
158162
- **GitLab**: https://docs.gitlab.com/ee/api/merge_requests.html.
159163

164+
### Families and self-hosted forges
165+
166+
A forge's API and URL layout are the same across every host running the same
167+
software, so `forges.js` factors that shared logic into a *family* and builds
168+
concrete adapters from it with `instanceOf(family, { hostname, … })`. Both the
169+
built-in public forges and the user's self-hosted forges are built this way, so
170+
a self-hosted forge is just an `instanceOf` created at runtime by
171+
`buildSelfHostedForge()` from an instance the user configured.
172+
173+
Users add instances (a hostname and which software family it runs) on the
174+
options page; they are stored under `selfHostedInstances` in
175+
`browser.storage.local`, and `forgeForHostname()` matches a page against the
176+
built-in forges first, then those instances.
177+
178+
Because Manifest V3 cannot declare arbitrary hosts up front, the options page
179+
**requests the host permission** for an instance when it is added and releases
180+
it on removal (both need `optional_host_permissions`); the background script
181+
skips an instance until its permission is granted.
182+
183+
Supported self-hosted software:
184+
- **GitLab** (CE/EE): https://docs.gitlab.com/ee/api/merge_requests.html
185+
- **Forgejo / Gitea**: https://forgejo.org/docs/latest/user/api-usage/
186+
187+
To support new self-hosted software, define its family and add it to
188+
`selfHostedFamilies`.
189+
160190
## Internationalization
161191

162192
Translations live in `_locales/<lang>/messages.json`. The default locale is

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +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)

‎_locales/en/messages.json‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,45 @@
4646
"optionsCleared": {
4747
"message": "Token cleared",
4848
"description": "Status message shown after clearing the token"
49+
},
50+
"optionsSelfHostedTitle": {
51+
"message": "Self-hosted forges",
52+
"description": "Title of the self-hosted forges section on the options page"
53+
},
54+
"optionsSelfHostedHint": {
55+
"message": "Add your own self-hosted forge instance. Adding one asks for permission to access that site.",
56+
"description": "Hint explaining the self-hosted forges section"
57+
},
58+
"optionsAddInstance": {
59+
"message": "Add",
60+
"description": "Label of the button that adds a self-hosted instance"
61+
},
62+
"optionsHostnamePlaceholder": {
63+
"message": "git.example.com",
64+
"description": "Placeholder for the self-hosted instance hostname input"
65+
},
66+
"optionsRemove": {
67+
"message": "Remove",
68+
"description": "Label of the button that removes a self-hosted instance"
69+
},
70+
"optionsInvalidHostname": {
71+
"message": "Enter a valid hostname",
72+
"description": "Status message when the entered instance hostname is invalid"
73+
},
74+
"optionsInstanceExists": {
75+
"message": "This instance is already configured",
76+
"description": "Status message when adding an instance that already exists"
77+
},
78+
"optionsInstanceAdded": {
79+
"message": "Instance added",
80+
"description": "Status message shown after adding a self-hosted instance"
81+
},
82+
"optionsInstanceRemoved": {
83+
"message": "Instance removed",
84+
"description": "Status message shown after removing a self-hosted instance"
85+
},
86+
"optionsPermissionDenied": {
87+
"message": "Permission to access this instance was denied",
88+
"description": "Status message when the host permission request is denied"
4989
}
5090
}

‎_locales/fr/messages.json‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,45 @@
4646
"optionsCleared": {
4747
"message": "Jeton effacé",
4848
"description": "Message affiché après l'effacement du jeton"
49+
},
50+
"optionsSelfHostedTitle": {
51+
"message": "Forges auto-hébergées",
52+
"description": "Titre de la section des forges auto-hébergées sur la page d'options"
53+
},
54+
"optionsSelfHostedHint": {
55+
"message": "Ajoutez votre propre instance de forge auto-hébergée. L'ajout demande l'autorisation d'accéder à ce site.",
56+
"description": "Aide expliquant la section des forges auto-hébergées"
57+
},
58+
"optionsAddInstance": {
59+
"message": "Ajouter",
60+
"description": "Libellé du bouton d'ajout d'une instance auto-hébergée"
61+
},
62+
"optionsHostnamePlaceholder": {
63+
"message": "git.exemple.com",
64+
"description": "Texte indicatif du champ de nom d'hôte de l'instance auto-hébergée"
65+
},
66+
"optionsRemove": {
67+
"message": "Supprimer",
68+
"description": "Libellé du bouton de suppression d'une instance auto-hébergée"
69+
},
70+
"optionsInvalidHostname": {
71+
"message": "Saisissez un nom d'hôte valide",
72+
"description": "Message affiché quand le nom d'hôte saisi est invalide"
73+
},
74+
"optionsInstanceExists": {
75+
"message": "Cette instance est déjà configurée",
76+
"description": "Message affiché lors de l'ajout d'une instance déjà présente"
77+
},
78+
"optionsInstanceAdded": {
79+
"message": "Instance ajoutée",
80+
"description": "Message affiché après l'ajout d'une instance auto-hébergée"
81+
},
82+
"optionsInstanceRemoved": {
83+
"message": "Instance supprimée",
84+
"description": "Message affiché après la suppression d'une instance auto-hébergée"
85+
},
86+
"optionsPermissionDenied": {
87+
"message": "L'autorisation d'accéder à cette instance a été refusée",
88+
"description": "Message affiché quand la demande d'autorisation d'hôte est refusée"
4989
}
5090
}

‎_locales/template.json‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,45 @@
4646
"optionsCleared": {
4747
"message": "",
4848
"description": ""
49+
},
50+
"optionsSelfHostedTitle": {
51+
"message": "",
52+
"description": ""
53+
},
54+
"optionsSelfHostedHint": {
55+
"message": "",
56+
"description": ""
57+
},
58+
"optionsAddInstance": {
59+
"message": "",
60+
"description": ""
61+
},
62+
"optionsHostnamePlaceholder": {
63+
"message": "",
64+
"description": ""
65+
},
66+
"optionsRemove": {
67+
"message": "",
68+
"description": ""
69+
},
70+
"optionsInvalidHostname": {
71+
"message": "",
72+
"description": ""
73+
},
74+
"optionsInstanceExists": {
75+
"message": "",
76+
"description": ""
77+
},
78+
"optionsInstanceAdded": {
79+
"message": "",
80+
"description": ""
81+
},
82+
"optionsInstanceRemoved": {
83+
"message": "",
84+
"description": ""
85+
},
86+
"optionsPermissionDenied": {
87+
"message": "",
88+
"description": ""
4989
}
5090
}

‎background.js‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,32 @@ function render(prs, tabId) {
163163
browser.storage.local.set({ tabId, prs })
164164
}
165165

166+
// load the user's configured self-hosted instances from storage
167+
// returns an array of { type, hostname } (empty when none configured)
168+
async function loadInstances() {
169+
const stored = await browser.storage.local.get("selfHostedInstances")
170+
return stored.selfHostedInstances || []
171+
}
172+
166173
async function main(tab) {
167174
// split the url
168175
const url = new URL(tab.url)
169176

170-
// get the forge adapter by hostname
171-
const forge = forgeForHostname(url.hostname)
177+
// get the forge adapter by hostname, considering configured self-hosted
178+
// instances alongside the built-in forges
179+
const instances = await loadInstances()
180+
const forge = forgeForHostname(url.hostname, instances)
172181
if (forge == null) {
173182
return
174183
}
184+
185+
// self-hosted instances need an optional host permission before the
186+
// extension may call their API; skip quietly until the user grants it
187+
if (forge.selfHosted &&
188+
!(await browser.permissions.contains({ origins: [`*://${url.hostname}/*`] }))) {
189+
return
190+
}
191+
175192
browser.action.enable(tab.id)
176193

177194
// get project info from the URL (forge-specific parsing)

0 commit comments

Comments
 (0)