gitlab-badge-proxy 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +266 -2
- package/dist/src/app.d.ts +8 -0
- package/dist/src/app.d.ts.map +1 -0
- package/dist/src/app.js +480 -0
- package/dist/src/badge.d.ts +28 -0
- package/dist/src/badge.d.ts.map +1 -0
- package/dist/src/badge.js +151 -0
- package/dist/src/config.d.ts +21 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +20 -0
- package/dist/src/gitlab.d.ts +31 -0
- package/dist/src/gitlab.d.ts.map +1 -0
- package/dist/src/gitlab.js +116 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ArdensLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,267 @@
|
|
|
1
|
-
#
|
|
1
|
+
# gitlab-badge-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tiny [Hono](https://hono.dev) app that turns a **private** GitLab project's pipeline/coverage status
|
|
4
|
+
into public SVG badges, without ever exposing the GitLab instance's hostname or an access token to
|
|
5
|
+
the browser. Everything that touches the real GitLab URL happens server-side, inside the deployed
|
|
6
|
+
function; the client only ever sees your own domain.
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
GitLab's own badge images (`/<namespace>/<project>/badges/<branch>/pipeline.svg`) require a logged-in
|
|
11
|
+
session to view on a private project — there is no supported token query parameter for them (that was
|
|
12
|
+
disabled upstream for security reasons). This proxy instead calls the GitLab **REST API** (which does
|
|
13
|
+
accept a `PRIVATE-TOKEN` header) to read the latest pipeline status/coverage, then re-renders that as a
|
|
14
|
+
[shields.io](https://shields.io) static badge, fetched and streamed server-side. The GitLab URL and
|
|
15
|
+
token never appear in any client-visible response, header, or redirect.
|
|
16
|
+
|
|
17
|
+
## Routes
|
|
18
|
+
|
|
19
|
+
- `GET /pipeline.svg` — latest pipeline status on the configured ref (`passing` / `failing` / `running` / `unknown`).
|
|
20
|
+
- `GET /coverage.svg` — coverage percentage of the latest pipeline on that ref, color-graded. Add
|
|
21
|
+
`?job=<job-name>` to scope it to one job's coverage instead of the pipeline's aggregate, same as
|
|
22
|
+
GitLab's own native badge query param.
|
|
23
|
+
- `GET /release.svg` — latest release's tag (version badge, blue for stable / orange for
|
|
24
|
+
0.x or pre-release-like tags), same coloring convention as shields.io's version badges.
|
|
25
|
+
- `GET /tag.svg` — most recently updated tag, same version-badge coloring as `release.svg`.
|
|
26
|
+
- `GET /stars.svg`, `GET /forks.svg`, `GET /contributors.svg` — repository counts (abbreviated
|
|
27
|
+
for large numbers, e.g. `12.3k`).
|
|
28
|
+
- `GET /issues.svg?state=opened|closed|all&labels=<comma,separated>` — issue count for the given
|
|
29
|
+
state (default `all`); green when the count is zero, yellow otherwise.
|
|
30
|
+
- `GET /merge-requests.svg?state=opened|closed|merged|locked|all&labels=<comma,separated>` —
|
|
31
|
+
merge request count for the given state (default `opened`); shows "more than 10k" past
|
|
32
|
+
GitLab's own count-header limit.
|
|
33
|
+
- `GET /last-commit.svg?ref=<ref>&path=<file>` — how long ago the most recent matching commit
|
|
34
|
+
landed (`ref` defaults to the configured `GITLAB_REF`).
|
|
35
|
+
- `GET /license.svg` — the project's detected license name, or "not specified".
|
|
36
|
+
- `GET /languages.svg` — the top language by percentage of code. `GET /languages-count.svg` —
|
|
37
|
+
how many distinct languages GitLab detected.
|
|
38
|
+
- `GET /labels.svg?name=<label-name>` — renders that label's own color from GitLab.
|
|
39
|
+
- `GET /go-mod.svg?filename=go.mod&ref=<ref>` — the Go version declared in `go.mod` (or another
|
|
40
|
+
path given via `filename`), as a version badge.
|
|
41
|
+
- `GET /health` — liveness check; does not read GitLab configuration.
|
|
42
|
+
|
|
43
|
+
On any failure (GitLab unreachable, bad token, pipeline has no coverage) the routes degrade to a grey
|
|
44
|
+
"unknown" badge instead of erroring, so a broken/rotated token shows up as a stale badge, not a broken
|
|
45
|
+
image or a leaked error message.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
All configuration is env-only — nothing GitLab-specific is hardcoded in the source, so this package
|
|
50
|
+
stays safe to publish and reuse. Copy [`.env.example`](.env.example) and fill in your own values (as
|
|
51
|
+
Vercel project env vars in production, never committed):
|
|
52
|
+
|
|
53
|
+
| Variable | Required | Description |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `GITLAB_URL` | yes | Base URL of your GitLab instance. |
|
|
56
|
+
| `GITLAB_TOKEN` | yes | Token with the `read_api` scope on the target project only. |
|
|
57
|
+
| `GITLAB_PROJECT` | yes | Project path (`group/name`) or numeric ID. |
|
|
58
|
+
| `GITLAB_REF` | no | Branch/ref to report on. Defaults to `master`. |
|
|
59
|
+
| `CACHE_MAX_AGE_SECONDS` | no | Edge cache lifetime for successful responses. Defaults to `60`. |
|
|
60
|
+
| `GITLAB_PROJECTS` | no | Comma-separated whitelist for the GitLab-shaped routes below. Empty by default. |
|
|
61
|
+
|
|
62
|
+
## GitLab-compatible badge URLs
|
|
63
|
+
|
|
64
|
+
GitLab's own badges live at `<gitlab-url>/<namespace>/<project>/badges/<ref>/pipeline.svg` (and
|
|
65
|
+
`coverage.svg`), plus `<gitlab-url>/<namespace>/<project>/-/badges/release.svg` for the latest
|
|
66
|
+
release (no ref — releases aren't tied to a branch). This proxy serves the same path shapes, so
|
|
67
|
+
existing badge links keep working when you swap the GitLab host for this proxy's — e.g.
|
|
68
|
+
`https://badges.yourdomain.tld/ardenslab/pm2-dash/badges/main/coverage.svg` or
|
|
69
|
+
`https://badges.yourdomain.tld/ardenslab/pm2-dash/-/badges/release.svg`.
|
|
70
|
+
|
|
71
|
+
For security, only project paths listed in `GITLAB_PROJECTS` are served this way; any other path
|
|
72
|
+
(or one that doesn't match `/badges/<ref>/pipeline.svg`, `/badges/<ref>/coverage.svg`, or
|
|
73
|
+
`/-/badges/release.svg`) returns a plain 404, the same as GitLab's own behavior for an unknown
|
|
74
|
+
project — the token is never used to probe or confirm the existence of a project you haven't
|
|
75
|
+
explicitly whitelisted. The ref segment is free-form (any branch of a whitelisted project), unlike
|
|
76
|
+
the fixed `GITLAB_REF` used by the flat `/pipeline.svg` / `/coverage.svg` routes above. The coverage
|
|
77
|
+
route also accepts `?job=<job-name>`, same as the flat route.
|
|
78
|
+
|
|
79
|
+
## Badge preview page
|
|
80
|
+
|
|
81
|
+
`GET /debug/<group/project>?ref=<branch>` renders an HTML page with every badge this proxy can
|
|
82
|
+
produce for that project: its own GitLab-shaped routes, the fixed-default-project flat routes, and
|
|
83
|
+
the shields.io badges that work through the `gitlab_url` emulation below — all as live `<img>` tags,
|
|
84
|
+
so you can eyeball everything at once instead of constructing and testing each URL by hand. `GET
|
|
85
|
+
/debug` alone redirects to the configured default project. Each embedded badge still goes through its
|
|
86
|
+
own route's whitelist check when the browser loads it, so an unlisted project just shows 404s in
|
|
87
|
+
place, same as using that badge URL directly.
|
|
88
|
+
|
|
89
|
+
## GitLab API emulation (for shields.io and similar)
|
|
90
|
+
|
|
91
|
+
Some badge generators — notably [shields.io](https://shields.io)'s generic `gitlab` badges
|
|
92
|
+
(`/gitlab/v/release/<project>`, `/gitlab/v/tag/<project>`, etc.) — don't fetch a pre-rendered badge;
|
|
93
|
+
instead they take a `gitlab_url` query param and build their own GitLab REST API calls against it
|
|
94
|
+
(`<gitlab_url>/api/v4/projects/<project>/releases`, etc.). Pointing `gitlab_url` at your real GitLab
|
|
95
|
+
instance would require that instance to be publicly reachable. Point it at this proxy instead, and it
|
|
96
|
+
answers those specific calls on the real GitLab's behalf:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
https://img.shields.io/gitlab/v/release/ardenslab%2Fpm2-dash?gitlab_url=https%3A%2F%2Fbadges.yourdomain.tld
|
|
100
|
+
https://img.shields.io/gitlab/v/tag/ardenslab%2Fpm2-dash?gitlab_url=https%3A%2F%2Fbadges.yourdomain.tld
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
(Leave off the trailing slash on `gitlab_url` — with one, shields.io's own path gets appended with a
|
|
104
|
+
doubled `/`; the proxy redirects to collapse it, but it's one fewer hop to just omit it.)
|
|
105
|
+
|
|
106
|
+
Only a narrow, read-only slice of the real API is implemented — `releases`, `repository/tags`,
|
|
107
|
+
`languages`, `issues_statistics`, and `pipelines`/`pipelines/:id`/`pipelines/:id/jobs` — covering the
|
|
108
|
+
version/tag/pipeline/coverage/issue-count badge shapes. Same whitelist as the GitLab-shaped badge
|
|
109
|
+
routes above (`GITLAB_PROJECTS`): an unlisted project, or any subpath not on that list (file contents,
|
|
110
|
+
full issue/MR bodies, commit messages, contributor emails — anything with actual content rather than
|
|
111
|
+
metadata), 404s instead of being proxied. Every response body also has the real `GITLAB_URL` stripped
|
|
112
|
+
out first, since GitLab embeds it in `web_url`/`_links` on nearly every resource.
|
|
113
|
+
|
|
114
|
+
## Installing as a library
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install gitlab-badge-proxy
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The package exports a ready-made [Hono](https://hono.dev) app plus the pieces used to build it:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { app } from "gitlab-badge-proxy";
|
|
124
|
+
// app.request(...) / app.fetch(...) — mount it directly, or compose it into a bigger Hono app.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { createApp, loadConfig, type Config } from "gitlab-badge-proxy";
|
|
129
|
+
|
|
130
|
+
// Supply config explicitly instead of reading env vars:
|
|
131
|
+
const config: Config = {
|
|
132
|
+
gitlabUrl: "https://gitlab.example.net",
|
|
133
|
+
gitlabToken: process.env.MY_TOKEN!,
|
|
134
|
+
project: "group/project",
|
|
135
|
+
ref: "main",
|
|
136
|
+
cacheMaxAgeSeconds: 60,
|
|
137
|
+
};
|
|
138
|
+
const app = createApp(() => config);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Hosting your own proxy
|
|
142
|
+
|
|
143
|
+
Whichever platform you pick, two things stay true everywhere: set the env vars from the
|
|
144
|
+
[Configuration](#configuration) table above as that platform's secrets/environment feature (never
|
|
145
|
+
commit them), and attach a domain that doesn't hint at the real GitLab host (e.g.
|
|
146
|
+
`badges.yourdomain.tld`, not `gitlab-proxy-for-<realcompany>.vercel.app`) — that's the whole point of
|
|
147
|
+
this proxy.
|
|
148
|
+
|
|
149
|
+
### On Vercel
|
|
150
|
+
|
|
151
|
+
Either install the package in your own project, or fork/clone this repo directly and deploy it as-is —
|
|
152
|
+
both end up running the same `app`.
|
|
153
|
+
|
|
154
|
+
**As a dependency in your own project:**
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// api/index.ts
|
|
158
|
+
import { handle } from "hono/vercel";
|
|
159
|
+
import { app } from "gitlab-badge-proxy";
|
|
160
|
+
|
|
161
|
+
export const GET = handle(app);
|
|
162
|
+
export const runtime = "nodejs";
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
// vercel.json
|
|
167
|
+
{ "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Set the env vars in the Vercel project settings (Production + Preview), then `vercel deploy --prod`.
|
|
171
|
+
|
|
172
|
+
**Forking/cloning this repo instead:** it already has `api/index.ts` and `vercel.json` set up the same
|
|
173
|
+
way — `vercel link`, set the same env vars, `vercel deploy --prod`, done.
|
|
174
|
+
|
|
175
|
+
### On Cloudflare Workers
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// src/index.ts
|
|
179
|
+
import { app } from "gitlab-badge-proxy";
|
|
180
|
+
|
|
181
|
+
export default app; // Hono apps are already a `fetch(request, env, ctx)` handler
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```toml
|
|
185
|
+
# wrangler.toml
|
|
186
|
+
name = "gitlab-badge-proxy"
|
|
187
|
+
main = "src/index.ts"
|
|
188
|
+
compatibility_date = "2024-01-01"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Set `GITLAB_URL` / `GITLAB_PROJECT` / etc. with `wrangler secret put <NAME>` (or in the dashboard
|
|
192
|
+
under Settings > Variables) rather than plaintext `[vars]`, since `GITLAB_TOKEN` is a credential. The
|
|
193
|
+
config module reads `process.env`, so wire Worker bindings into that shape before calling
|
|
194
|
+
`createApp`/`loadConfig` — e.g. `createApp(() => loadConfig(env as unknown as Record<string, string>))`
|
|
195
|
+
inside your `fetch` handler, since Workers pass env as a per-request argument rather than a global.
|
|
196
|
+
|
|
197
|
+
### On plain Node.js (bare metal, a VM, or Docker)
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// server.mjs
|
|
201
|
+
import { serve } from "@hono/node-server";
|
|
202
|
+
import { app } from "gitlab-badge-proxy";
|
|
203
|
+
|
|
204
|
+
serve({ fetch: app.fetch, port: Number(process.env.PORT) || 3000 });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```dockerfile
|
|
208
|
+
# Dockerfile
|
|
209
|
+
FROM node:22-alpine
|
|
210
|
+
WORKDIR /app
|
|
211
|
+
COPY package*.json ./
|
|
212
|
+
RUN npm install gitlab-badge-proxy @hono/node-server
|
|
213
|
+
COPY server.mjs ./
|
|
214
|
+
EXPOSE 3000
|
|
215
|
+
CMD ["node", "server.mjs"]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Run with `docker run -p 3000:3000 --env-file .env your-image-name`, then put a reverse proxy
|
|
219
|
+
(Caddy/nginx/your platform's own ingress) with TLS in front of it on your domain.
|
|
220
|
+
|
|
221
|
+
### Anywhere else
|
|
222
|
+
|
|
223
|
+
`app` is a standard Hono instance, and Hono ships adapters for most other JS runtimes — Deno, Bun, AWS
|
|
224
|
+
Lambda, Netlify Functions, Fastly Compute — covered in
|
|
225
|
+
[Hono's own deployment docs](https://hono.dev/docs/getting-started/basic). The app itself (`src/app.ts`)
|
|
226
|
+
has no platform-specific code; only the few lines that call `serve()`/`handle()` change per target.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npm install
|
|
232
|
+
cp .env.example .env.local # fill in real values, never commit this file
|
|
233
|
+
npm run typecheck
|
|
234
|
+
npm test
|
|
235
|
+
npm run dev # plain Node server on :3000, serves the routes locally
|
|
236
|
+
npm run dev:vercel # optional: vercel dev, if you're testing the Vercel integration specifically
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Releasing
|
|
240
|
+
|
|
241
|
+
CI/CD lives in [`.gitlab-ci.yml`](.gitlab-ci.yml) (this repo's origin is GitLab, not GitHub). Every
|
|
242
|
+
push/MR runs `typecheck` + `test` + `build` on Node 22 and 24. Publishing uses the shared
|
|
243
|
+
[`npm-publish`](https://gitlab.cloudnetwork.one/matteo/ci-templates) CI/CD component — the same one
|
|
244
|
+
`pm2-dash` uses — instead of a bespoke publish job: it publishes whenever someone pushes to the
|
|
245
|
+
`deploy-npm` branch, relying on the existing `prepublishOnly` script to typecheck/test/build first and
|
|
246
|
+
on npm itself refusing to publish over a version that already exists (no separate version check).
|
|
247
|
+
|
|
248
|
+
One-time setup:
|
|
249
|
+
1. Generate an npm **Granular Access Token** (https://www.npmjs.com/settings/~/tokens), scoped to this
|
|
250
|
+
package, **read+write**, with **"bypass 2FA"** enabled (CI can't answer an OTP prompt).
|
|
251
|
+
2. GitLab: Settings > CI/CD > Variables → add `NPM_TOKEN`, flagged **Masked** + **Protected**.
|
|
252
|
+
3. GitLab: Settings > Repository > Protected branches → protect `deploy-npm` (protected variables are
|
|
253
|
+
only sent to protected branches/tags).
|
|
254
|
+
|
|
255
|
+
To cut a release:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
npm version patch --no-git-tag-version # or minor / major — bumps package.json only
|
|
259
|
+
git commit -am "1.2.3"
|
|
260
|
+
git push origin HEAD:deploy-npm
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The pipeline publishes automatically once that push lands; watch it under CI/CD > Pipelines.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT © ArdensLab
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { type Config } from "./config.js";
|
|
3
|
+
export { loadConfig, type Config } from "./config.js";
|
|
4
|
+
import { buildShieldsUrl } from "./badge.js";
|
|
5
|
+
export declare function createApp(getConfig?: () => Config): Hono;
|
|
6
|
+
export { buildShieldsUrl };
|
|
7
|
+
export declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
8
|
+
//# sourceMappingURL=app.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAgB,MAAM,MAAM,CAAC;AAC1C,OAAO,EAAc,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAoBtD,OAAO,EACL,eAAe,EAkBhB,MAAM,YAAY,CAAC;AAuWpB,wBAAgB,SAAS,CAAC,SAAS,GAAE,MAAM,MAAmB,GAAG,IAAI,CAqNpE;AAED,OAAO,EAAE,eAAe,EAAE,CAAC;AAC3B,eAAO,MAAM,GAAG,4EAAc,CAAC"}
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
export { loadConfig } from "./config.js";
|
|
4
|
+
import { getContributorsCount, getForksCount, getGoModVersion, getIssuesCount, getJobCoverage, getLabelColor, getLanguages, getLastCommitDate, getLatestPipeline, getLatestRelease, getLatestTag, getLicenseName, getMergeRequestsCount, getPipelineCoverage, getStarsCount, } from "./gitlab.js";
|
|
5
|
+
import { buildShieldsUrl, contributorsToBadge, coverageToBadge, fetchBadgeSvg, forksToBadge, goModVersionToBadge, issuesToBadge, labelToBadge, languagesCountToBadge, lastCommitToBadge, licenseToBadge, mergeRequestsToBadge, pipelineStatusToBadge, releaseToBadge, starsToBadge, tagToBadge, topLanguageToBadge, } from "./badge.js";
|
|
6
|
+
const UNKNOWN_PIPELINE = { label: "pipeline", message: "unknown", color: "lightgrey" };
|
|
7
|
+
const UNKNOWN_COVERAGE = { label: "coverage", message: "unknown", color: "lightgrey" };
|
|
8
|
+
const UNKNOWN_RELEASE = { label: "release", message: "unknown", color: "lightgrey" };
|
|
9
|
+
const UNKNOWN = { label: "gitlab", message: "unknown", color: "lightgrey" };
|
|
10
|
+
/** Matches GitLab's own badge URL shape: `<project path>/badges/<ref>/<pipeline|coverage>.svg`. */
|
|
11
|
+
const BADGE_PATH_PATTERN = /^(.+)\/badges\/([^/]+)\/(pipeline|coverage)\.svg$/;
|
|
12
|
+
/** Matches GitLab's latest-release badge shape: `<project path>/-/badges/release.svg` (no ref). */
|
|
13
|
+
const RELEASE_PATH_PATTERN = /^(.+)\/-\/badges\/release\.svg$/;
|
|
14
|
+
/**
|
|
15
|
+
* Known-safe `/api/v4/projects/:id/<subpath>` suffixes: just enough of the real GitLab REST API
|
|
16
|
+
* for shields.io-style "point gitlab_url at a different host" badges (release/tag/pipeline/
|
|
17
|
+
* coverage/language/issue-count) to work unmodified. Deliberately excludes anything whose response
|
|
18
|
+
* body carries content rather than metadata (file contents, commit messages, full issue/MR bodies,
|
|
19
|
+
* contributor emails) even though the token could read it — this stays a badge proxy, not an open
|
|
20
|
+
* GitLab API mirror.
|
|
21
|
+
*/
|
|
22
|
+
const API_LITERAL_SUBPATHS = ["releases", "repository/tags", "languages", "issues_statistics"];
|
|
23
|
+
/** Splits a `/api/v4/projects/<rest>` tail into the (still possibly `%2F`-encoded) project id and
|
|
24
|
+
* the matched subpath (empty string for the bare project-root lookup). A `rest` that doesn't end in
|
|
25
|
+
* one of the known-safe shapes falls through to "whole thing is the id", which then simply fails the
|
|
26
|
+
* project whitelist check below — same 404 GitLab itself would give for a route shape it doesn't know. */
|
|
27
|
+
function splitProjectApiPath(rest) {
|
|
28
|
+
for (const subpath of API_LITERAL_SUBPATHS) {
|
|
29
|
+
const suffix = `/${subpath}`;
|
|
30
|
+
if (rest.endsWith(suffix)) {
|
|
31
|
+
return { projectRaw: rest.slice(0, -suffix.length), subpath };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const jobsMatch = /^(.+)\/pipelines\/(\d+)\/jobs$/.exec(rest);
|
|
35
|
+
if (jobsMatch) {
|
|
36
|
+
return { projectRaw: jobsMatch[1], subpath: `pipelines/${jobsMatch[2]}/jobs` };
|
|
37
|
+
}
|
|
38
|
+
const pipelineMatch = /^(.+)\/pipelines\/(\d+)$/.exec(rest);
|
|
39
|
+
if (pipelineMatch) {
|
|
40
|
+
return { projectRaw: pipelineMatch[1], subpath: `pipelines/${pipelineMatch[2]}` };
|
|
41
|
+
}
|
|
42
|
+
if (rest.endsWith("/pipelines")) {
|
|
43
|
+
return { projectRaw: rest.slice(0, -"/pipelines".length), subpath: "pipelines" };
|
|
44
|
+
}
|
|
45
|
+
// No known subpath suffix matched: `rest` is just the (bare) project id, e.g. the project-root
|
|
46
|
+
// lookup GitLab's own stars/forks/license fields live on.
|
|
47
|
+
return { projectRaw: rest, subpath: "" };
|
|
48
|
+
}
|
|
49
|
+
function apiError(message, status) {
|
|
50
|
+
return new Response(JSON.stringify({ message }), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Projects a safe-listed subpath's upstream JSON down to just the fields the badges it's meant for
|
|
57
|
+
* actually use. GitLab nests far more than that in these responses even where it looks harmless —
|
|
58
|
+
* `/releases` items carry a full `commit` object (author/committer email, commit message) and a free-
|
|
59
|
+
* text `description` (release notes), and the project root carries `namespace`, `permissions`, etc.
|
|
60
|
+
* None of that is "the GitLab host", so the plain text-scrub in proxyGitlabApi doesn't catch it; this
|
|
61
|
+
* allowlists fields instead of blocklisting them, so a new upstream field can't leak by default.
|
|
62
|
+
*/
|
|
63
|
+
function minimizeApiResponse(subpath, data) {
|
|
64
|
+
if (subpath === "") {
|
|
65
|
+
const project = data;
|
|
66
|
+
return {
|
|
67
|
+
star_count: project.star_count ?? null,
|
|
68
|
+
forks_count: project.forks_count ?? null,
|
|
69
|
+
license: project.license ? { name: project.license.name } : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (subpath === "releases") {
|
|
73
|
+
return data.map(({ name, tag_name }) => ({ name, tag_name }));
|
|
74
|
+
}
|
|
75
|
+
if (subpath === "repository/tags") {
|
|
76
|
+
return data.map(({ name }) => ({ name }));
|
|
77
|
+
}
|
|
78
|
+
if (subpath === "pipelines") {
|
|
79
|
+
return data.map(({ id, status }) => ({ id, status }));
|
|
80
|
+
}
|
|
81
|
+
if (/^pipelines\/\d+$/.test(subpath)) {
|
|
82
|
+
const pipeline = data;
|
|
83
|
+
return { id: pipeline.id, status: pipeline.status, coverage: pipeline.coverage ?? null };
|
|
84
|
+
}
|
|
85
|
+
if (/^pipelines\/\d+\/jobs$/.test(subpath)) {
|
|
86
|
+
return data.map(({ id, name, coverage }) => ({ id, name, coverage: coverage ?? null }));
|
|
87
|
+
}
|
|
88
|
+
// languages (name -> percentage) and issues_statistics (aggregate counts only) are already exactly
|
|
89
|
+
// as minimal as their upstream shape gets — nothing to drop.
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
/** Proxies one whitelisted, known-safe GitLab API call server-side: projects the response down to
|
|
93
|
+
* minimizeApiResponse's allowlisted fields on success (see there for why), and as a second layer,
|
|
94
|
+
* scrubs any remaining literal occurrence of the real GitLab URL — this proxy's whole point is to
|
|
95
|
+
* never let that host, or anything beyond a badge's worth of data, leak to the client. */
|
|
96
|
+
async function proxyGitlabApi(config, projectPath, subpath, search) {
|
|
97
|
+
const upstream = `${config.gitlabUrl}/api/v4/projects/${encodeURIComponent(projectPath)}${subpath ? `/${subpath}` : ""}${search}`;
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(upstream, { headers: { "PRIVATE-TOKEN": config.gitlabToken } });
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
const maxAge = response.ok ? config.cacheMaxAgeSeconds : 30;
|
|
102
|
+
let body = text;
|
|
103
|
+
if (response.ok) {
|
|
104
|
+
try {
|
|
105
|
+
body = JSON.stringify(minimizeApiResponse(subpath, JSON.parse(text)));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
console.error("gitlab-badge-proxy: unexpected non-JSON response for a safe-listed subpath", error);
|
|
109
|
+
return apiError("502 Bad Gateway", 502);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
body = body.split(config.gitlabUrl).join("");
|
|
113
|
+
return new Response(body, {
|
|
114
|
+
status: response.status,
|
|
115
|
+
headers: {
|
|
116
|
+
"content-type": "application/json; charset=utf-8",
|
|
117
|
+
"cache-control": `public, max-age=${maxAge}, s-maxage=${maxAge}`,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.error("gitlab-badge-proxy: failed to proxy GitLab API request", error);
|
|
123
|
+
return apiError("502 Bad Gateway", 502);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const HTML_ESCAPES = {
|
|
127
|
+
"&": "&",
|
|
128
|
+
"<": "<",
|
|
129
|
+
">": ">",
|
|
130
|
+
'"': """,
|
|
131
|
+
"'": "'",
|
|
132
|
+
};
|
|
133
|
+
function escapeHtml(value) {
|
|
134
|
+
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
135
|
+
}
|
|
136
|
+
function renderBadgeSection(title, note, badges) {
|
|
137
|
+
const items = badges
|
|
138
|
+
.map((badge) => `
|
|
139
|
+
<div class="badge">
|
|
140
|
+
<img src="${escapeHtml(badge.src)}" alt="${escapeHtml(badge.label)}" loading="lazy" />
|
|
141
|
+
<code>${escapeHtml(badge.src)}</code>
|
|
142
|
+
</div>`)
|
|
143
|
+
.join("");
|
|
144
|
+
return `
|
|
145
|
+
<section>
|
|
146
|
+
<h2>${escapeHtml(title)}</h2>
|
|
147
|
+
${note ? `<p class="note">${note}</p>` : ""}
|
|
148
|
+
<div class="grid">${items}</div>
|
|
149
|
+
</section>`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renders a page of every badge this proxy knows how to produce for one project: its own
|
|
153
|
+
* GitLab-shaped routes, the fixed-default-project flat routes, and the shields.io badges that work
|
|
154
|
+
* through this proxy's `/api/v4` emulation (see API_LITERAL_SUBPATHS). Pure HTML generation — every
|
|
155
|
+
* `<img>` still goes through that route's own whitelist check when the browser loads it, so this page
|
|
156
|
+
* never needs its own authorization beyond what's already enforced downstream.
|
|
157
|
+
*/
|
|
158
|
+
function renderDebugPage(project, ref, origin) {
|
|
159
|
+
const encodedProject = encodeURIComponent(project);
|
|
160
|
+
const encodedOrigin = encodeURIComponent(origin);
|
|
161
|
+
const encodedRef = encodeURIComponent(ref);
|
|
162
|
+
const nativeProjectBadges = [
|
|
163
|
+
{ label: "pipeline", src: `/${project}/badges/${ref}/pipeline.svg` },
|
|
164
|
+
{ label: "coverage", src: `/${project}/badges/${ref}/coverage.svg` },
|
|
165
|
+
{ label: "release", src: `/${project}/-/badges/release.svg` },
|
|
166
|
+
];
|
|
167
|
+
const nativeDefaultBadges = [
|
|
168
|
+
{ label: "pipeline", src: "/pipeline.svg" },
|
|
169
|
+
{ label: "coverage", src: "/coverage.svg" },
|
|
170
|
+
{ label: "release", src: "/release.svg" },
|
|
171
|
+
{ label: "stars", src: "/stars.svg" },
|
|
172
|
+
{ label: "forks", src: "/forks.svg" },
|
|
173
|
+
{ label: "contributors", src: "/contributors.svg" },
|
|
174
|
+
{ label: "issues", src: "/issues.svg" },
|
|
175
|
+
{ label: "merge requests", src: "/merge-requests.svg" },
|
|
176
|
+
{ label: "last commit", src: "/last-commit.svg" },
|
|
177
|
+
{ label: "license", src: "/license.svg" },
|
|
178
|
+
{ label: "tag", src: "/tag.svg" },
|
|
179
|
+
{ label: "top language", src: "/languages.svg" },
|
|
180
|
+
{ label: "language count", src: "/languages-count.svg" },
|
|
181
|
+
{ label: "label color (bug)", src: "/labels.svg?name=bug" },
|
|
182
|
+
{ label: "go.mod version", src: "/go-mod.svg" },
|
|
183
|
+
];
|
|
184
|
+
const shieldsBadges = [
|
|
185
|
+
{
|
|
186
|
+
label: "release",
|
|
187
|
+
src: `https://img.shields.io/gitlab/v/release/${encodedProject}?gitlab_url=${encodedOrigin}`,
|
|
188
|
+
},
|
|
189
|
+
{ label: "tag", src: `https://img.shields.io/gitlab/v/tag/${encodedProject}?gitlab_url=${encodedOrigin}` },
|
|
190
|
+
{ label: "stars", src: `https://img.shields.io/gitlab/stars/${encodedProject}?gitlab_url=${encodedOrigin}` },
|
|
191
|
+
{ label: "forks", src: `https://img.shields.io/gitlab/forks/${encodedProject}?gitlab_url=${encodedOrigin}` },
|
|
192
|
+
{
|
|
193
|
+
label: "license",
|
|
194
|
+
src: `https://img.shields.io/gitlab/license/${encodedProject}?gitlab_url=${encodedOrigin}`,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
label: "top language",
|
|
198
|
+
src: `https://img.shields.io/gitlab/languages/${encodedProject}?gitlab_url=${encodedOrigin}`,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
label: "language count",
|
|
202
|
+
src: `https://img.shields.io/gitlab/languages/count/${encodedProject}?gitlab_url=${encodedOrigin}`,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
label: "issues (all)",
|
|
206
|
+
src: `https://img.shields.io/gitlab/issues/all/${encodedProject}?gitlab_url=${encodedOrigin}`,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
label: "pipeline status",
|
|
210
|
+
src: `https://img.shields.io/gitlab/pipeline-status/${encodedProject}?gitlab_url=${encodedOrigin}&branch=${encodedRef}`,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
label: "pipeline coverage",
|
|
214
|
+
src: `https://img.shields.io/gitlab/pipeline-coverage/${encodedProject}?gitlab_url=${encodedOrigin}&branch=${encodedRef}`,
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
const escapedProject = escapeHtml(project);
|
|
218
|
+
const escapedRef = escapeHtml(ref);
|
|
219
|
+
return `<!doctype html>
|
|
220
|
+
<html lang="en">
|
|
221
|
+
<head>
|
|
222
|
+
<meta charset="utf-8" />
|
|
223
|
+
<title>gitlab-badge-proxy — badge preview</title>
|
|
224
|
+
<style>
|
|
225
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; background: #0f172a; color: #e2e8f0; }
|
|
226
|
+
h1 { font-size: 1.1rem; font-weight: 600; }
|
|
227
|
+
h2 { font-size: 0.95rem; margin-top: 2rem; border-bottom: 1px solid #334155; padding-bottom: 0.35rem; }
|
|
228
|
+
.note { color: #94a3b8; font-size: 0.8rem; max-width: 60ch; }
|
|
229
|
+
.grid { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
|
|
230
|
+
.badge { background: #1e293b; border-radius: 6px; padding: 0.6rem 0.75rem; display: flex; align-items: center; gap: 0.75rem; }
|
|
231
|
+
code { font-size: 0.7rem; color: #64748b; word-break: break-all; max-width: 32ch; }
|
|
232
|
+
img { height: 20px; flex-shrink: 0; }
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<h1>Badge preview for <code>${escapedProject}</code> @ <code>${escapedRef}</code></h1>
|
|
237
|
+
<p class="note">Project-scoped sections 404 unless <code>${escapedProject}</code> is listed in
|
|
238
|
+
<code>GITLAB_PROJECTS</code>. Change the project/ref via the URL: <code>/debug/<group/project>?ref=<branch></code>.</p>
|
|
239
|
+
${renderBadgeSection("Native — this project", null, nativeProjectBadges)}
|
|
240
|
+
${renderBadgeSection("Native — default configured project", "Always targets the fixed <code>GITLAB_PROJECT</code> / <code>GITLAB_REF</code> from this deployment's config, regardless of the project above.", nativeDefaultBadges)}
|
|
241
|
+
${renderBadgeSection("shields.io (via gitlab_url pointed at this proxy)", "Only the badge types this proxy's read-only <code>/api/v4</code> emulation supports. contributors/merge-requests/last-commit/labels/go-mod aren't exposed that way by design (their GitLab API responses carry emails, commit messages, or full titles) — use the native badges above for those instead.", shieldsBadges)}
|
|
242
|
+
</body>
|
|
243
|
+
</html>`;
|
|
244
|
+
}
|
|
245
|
+
function svgResponse(svg, maxAgeSeconds) {
|
|
246
|
+
return new Response(svg, {
|
|
247
|
+
headers: {
|
|
248
|
+
"content-type": "image/svg+xml; charset=utf-8",
|
|
249
|
+
"cache-control": `public, max-age=${maxAgeSeconds}, s-maxage=${maxAgeSeconds}, stale-while-revalidate=${maxAgeSeconds * 5}`,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function resolveSpec(config, fallback, context, compute) {
|
|
254
|
+
try {
|
|
255
|
+
return { spec: await compute(), maxAge: config.cacheMaxAgeSeconds };
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
console.error(`gitlab-badge-proxy: failed to resolve ${context}`, error);
|
|
259
|
+
return { spec: fallback, maxAge: 30 };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function resolvePipelineSpec(config, projectPath, ref) {
|
|
263
|
+
return resolveSpec(config, UNKNOWN_PIPELINE, "pipeline status", async () => {
|
|
264
|
+
const pipeline = await getLatestPipeline(config, projectPath, ref);
|
|
265
|
+
return pipeline ? pipelineStatusToBadge(pipeline.status) : UNKNOWN_PIPELINE;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** `jobName` scopes to one job's coverage (GitLab's own `?job=` param); omitted means the pipeline's aggregate coverage. */
|
|
269
|
+
async function resolveCoverageSpec(config, projectPath, ref, jobName) {
|
|
270
|
+
return resolveSpec(config, UNKNOWN_COVERAGE, "coverage", async () => {
|
|
271
|
+
const pipeline = await getLatestPipeline(config, projectPath, ref);
|
|
272
|
+
const coverage = pipeline
|
|
273
|
+
? jobName
|
|
274
|
+
? await getJobCoverage(config, projectPath, pipeline.id, jobName)
|
|
275
|
+
: await getPipelineCoverage(config, projectPath, pipeline.id)
|
|
276
|
+
: null;
|
|
277
|
+
return coverageToBadge(coverage);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function resolveReleaseSpec(config, projectPath) {
|
|
281
|
+
return resolveSpec(config, UNKNOWN_RELEASE, "release", async () => {
|
|
282
|
+
const release = await getLatestRelease(config, projectPath);
|
|
283
|
+
return releaseToBadge(release);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** Registers a flat `/<path>.svg` route fixed to the configured default project (and ref, where relevant). */
|
|
287
|
+
function registerBadgeRoute(app, getConfig, path, fallback, context, compute) {
|
|
288
|
+
app.get(path, async (c) => {
|
|
289
|
+
let config;
|
|
290
|
+
try {
|
|
291
|
+
config = getConfig();
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
295
|
+
return svgResponse(await fetchBadgeSvg(fallback), 30);
|
|
296
|
+
}
|
|
297
|
+
const { spec, maxAge } = await resolveSpec(config, fallback, context, () => compute(config, c));
|
|
298
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
export function createApp(getConfig = loadConfig) {
|
|
302
|
+
const app = new Hono();
|
|
303
|
+
// Collapses accidental double slashes (e.g. a caller's `gitlab_url` ending in `/`, concatenated
|
|
304
|
+
// with a path that also starts with `/`) instead of 404ing on them.
|
|
305
|
+
app.use(async (c, next) => {
|
|
306
|
+
const url = new URL(c.req.url);
|
|
307
|
+
if (url.pathname.includes("//")) {
|
|
308
|
+
url.pathname = url.pathname.replace(/\/{2,}/g, "/");
|
|
309
|
+
return c.redirect(url.pathname + url.search, 308);
|
|
310
|
+
}
|
|
311
|
+
await next();
|
|
312
|
+
});
|
|
313
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
314
|
+
app.get("/pipeline.svg", async (c) => {
|
|
315
|
+
let config;
|
|
316
|
+
try {
|
|
317
|
+
config = getConfig();
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
321
|
+
return svgResponse(await fetchBadgeSvg(UNKNOWN_PIPELINE), 30);
|
|
322
|
+
}
|
|
323
|
+
const { spec, maxAge } = await resolvePipelineSpec(config, config.project, config.ref);
|
|
324
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
325
|
+
});
|
|
326
|
+
app.get("/coverage.svg", async (c) => {
|
|
327
|
+
let config;
|
|
328
|
+
try {
|
|
329
|
+
config = getConfig();
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
333
|
+
return svgResponse(await fetchBadgeSvg(UNKNOWN_COVERAGE), 30);
|
|
334
|
+
}
|
|
335
|
+
const { spec, maxAge } = await resolveCoverageSpec(config, config.project, config.ref, c.req.query("job"));
|
|
336
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
337
|
+
});
|
|
338
|
+
app.get("/release.svg", async (c) => {
|
|
339
|
+
let config;
|
|
340
|
+
try {
|
|
341
|
+
config = getConfig();
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
345
|
+
return svgResponse(await fetchBadgeSvg(UNKNOWN_RELEASE), 30);
|
|
346
|
+
}
|
|
347
|
+
const { spec, maxAge } = await resolveReleaseSpec(config, config.project);
|
|
348
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
349
|
+
});
|
|
350
|
+
registerBadgeRoute(app, getConfig, "/stars.svg", UNKNOWN, "stars", async (config) => starsToBadge(await getStarsCount(config, config.project)));
|
|
351
|
+
registerBadgeRoute(app, getConfig, "/forks.svg", UNKNOWN, "forks", async (config) => forksToBadge(await getForksCount(config, config.project)));
|
|
352
|
+
registerBadgeRoute(app, getConfig, "/contributors.svg", UNKNOWN, "contributors", async (config) => contributorsToBadge(await getContributorsCount(config, config.project)));
|
|
353
|
+
registerBadgeRoute(app, getConfig, "/issues.svg", UNKNOWN, "issues", async (config, c) => {
|
|
354
|
+
const state = c.req.query("state") ?? "all";
|
|
355
|
+
const labels = c.req.query("labels");
|
|
356
|
+
return issuesToBadge(await getIssuesCount(config, config.project, state, labels), state, labels);
|
|
357
|
+
});
|
|
358
|
+
registerBadgeRoute(app, getConfig, "/merge-requests.svg", UNKNOWN, "merge requests", async (config, c) => {
|
|
359
|
+
const state = c.req.query("state") ?? "opened";
|
|
360
|
+
const labels = c.req.query("labels");
|
|
361
|
+
return mergeRequestsToBadge(await getMergeRequestsCount(config, config.project, state, labels), state, labels);
|
|
362
|
+
});
|
|
363
|
+
registerBadgeRoute(app, getConfig, "/last-commit.svg", UNKNOWN, "last commit", async (config, c) => {
|
|
364
|
+
const ref = c.req.query("ref") ?? config.ref;
|
|
365
|
+
const path = c.req.query("path");
|
|
366
|
+
return lastCommitToBadge(await getLastCommitDate(config, config.project, ref, path));
|
|
367
|
+
});
|
|
368
|
+
registerBadgeRoute(app, getConfig, "/license.svg", UNKNOWN, "license", async (config) => licenseToBadge(await getLicenseName(config, config.project)));
|
|
369
|
+
registerBadgeRoute(app, getConfig, "/tag.svg", UNKNOWN, "tag", async (config) => tagToBadge(await getLatestTag(config, config.project)));
|
|
370
|
+
registerBadgeRoute(app, getConfig, "/languages.svg", UNKNOWN, "top language", async (config) => topLanguageToBadge(await getLanguages(config, config.project)));
|
|
371
|
+
registerBadgeRoute(app, getConfig, "/languages-count.svg", UNKNOWN, "language count", async (config) => languagesCountToBadge(await getLanguages(config, config.project)));
|
|
372
|
+
registerBadgeRoute(app, getConfig, "/labels.svg", UNKNOWN, "label", async (config, c) => {
|
|
373
|
+
const name = c.req.query("name");
|
|
374
|
+
if (!name) {
|
|
375
|
+
throw new Error("missing required ?name= query param");
|
|
376
|
+
}
|
|
377
|
+
return labelToBadge(name, await getLabelColor(config, config.project, name));
|
|
378
|
+
});
|
|
379
|
+
registerBadgeRoute(app, getConfig, "/go-mod.svg", UNKNOWN, "go.mod version", async (config, c) => {
|
|
380
|
+
const ref = c.req.query("ref") ?? config.ref;
|
|
381
|
+
const filename = c.req.query("filename") ?? "go.mod";
|
|
382
|
+
return goModVersionToBadge(await getGoModVersion(config, config.project, ref, filename));
|
|
383
|
+
});
|
|
384
|
+
// Visual test page: every native and shields.io badge shape this proxy can produce, for one
|
|
385
|
+
// project/ref given in the URL. /debug alone falls back to the fixed default project.
|
|
386
|
+
app.get("/debug", (c) => {
|
|
387
|
+
let config;
|
|
388
|
+
try {
|
|
389
|
+
config = getConfig();
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
393
|
+
return c.text("gitlab-badge-proxy: config not loaded", 500);
|
|
394
|
+
}
|
|
395
|
+
const ref = c.req.query("ref") ?? config.ref;
|
|
396
|
+
return c.redirect(`/debug/${config.project}?ref=${encodeURIComponent(ref)}`);
|
|
397
|
+
});
|
|
398
|
+
app.get("/debug/:project{.+}", (c) => {
|
|
399
|
+
let config;
|
|
400
|
+
try {
|
|
401
|
+
config = getConfig();
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
405
|
+
return c.text("gitlab-badge-proxy: config not loaded", 500);
|
|
406
|
+
}
|
|
407
|
+
const project = c.req.param("project");
|
|
408
|
+
const ref = c.req.query("ref") ?? config.ref;
|
|
409
|
+
const origin = new URL(c.req.url).origin;
|
|
410
|
+
return c.html(renderDebugPage(project, ref, origin));
|
|
411
|
+
});
|
|
412
|
+
// Minimal, read-only GitLab API emulation: lets badge generators that build their own GitLab API
|
|
413
|
+
// URLs (e.g. shields.io's generic `/gitlab/v/release/<project>?gitlab_url=...` badges) point
|
|
414
|
+
// straight at this proxy instead of the real GitLab host. See API_LITERAL_SUBPATHS above for the
|
|
415
|
+
// exact set of endpoints this covers.
|
|
416
|
+
app.get("/api/v4/projects/:rest{.+}", async (c) => {
|
|
417
|
+
let config;
|
|
418
|
+
try {
|
|
419
|
+
config = getConfig();
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
423
|
+
return apiError("500 Internal Server Error", 500);
|
|
424
|
+
}
|
|
425
|
+
const { projectRaw, subpath } = splitProjectApiPath(c.req.param("rest"));
|
|
426
|
+
const projectPath = decodeURIComponent(projectRaw);
|
|
427
|
+
if (!config.allowedProjects.includes(projectPath)) {
|
|
428
|
+
return apiError("404 Project Not Found", 404);
|
|
429
|
+
}
|
|
430
|
+
return proxyGitlabApi(config, projectPath, subpath, new URL(c.req.url).search);
|
|
431
|
+
});
|
|
432
|
+
// GitLab-shaped badge URLs, e.g. /ardenslab/pm2-dash/badges/master/coverage.svg — drop-in
|
|
433
|
+
// compatible with GitLab's own badge paths so existing README links keep working when the
|
|
434
|
+
// GitLab host is swapped for this proxy's. Only projects explicitly whitelisted via
|
|
435
|
+
// GITLAB_PROJECTS are served; anything else 404s, same as a project GitLab doesn't know about.
|
|
436
|
+
app.get("/:rest{.+}", async (c) => {
|
|
437
|
+
const rest = c.req.param("rest");
|
|
438
|
+
const releaseMatch = RELEASE_PATH_PATTERN.exec(rest);
|
|
439
|
+
if (releaseMatch) {
|
|
440
|
+
const [, projectPath] = releaseMatch;
|
|
441
|
+
let config;
|
|
442
|
+
try {
|
|
443
|
+
config = getConfig();
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
447
|
+
return svgResponse(await fetchBadgeSvg(UNKNOWN_RELEASE), 30);
|
|
448
|
+
}
|
|
449
|
+
if (!config.allowedProjects.includes(projectPath)) {
|
|
450
|
+
return c.notFound();
|
|
451
|
+
}
|
|
452
|
+
const { spec, maxAge } = await resolveReleaseSpec(config, projectPath);
|
|
453
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
454
|
+
}
|
|
455
|
+
const match = BADGE_PATH_PATTERN.exec(rest);
|
|
456
|
+
if (!match) {
|
|
457
|
+
return c.notFound();
|
|
458
|
+
}
|
|
459
|
+
const [, projectPath, ref, kind] = match;
|
|
460
|
+
let config;
|
|
461
|
+
try {
|
|
462
|
+
config = getConfig();
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
console.error("gitlab-badge-proxy: failed to load config", error);
|
|
466
|
+
const fallback = kind === "pipeline" ? UNKNOWN_PIPELINE : UNKNOWN_COVERAGE;
|
|
467
|
+
return svgResponse(await fetchBadgeSvg(fallback), 30);
|
|
468
|
+
}
|
|
469
|
+
if (!config.allowedProjects.includes(projectPath)) {
|
|
470
|
+
return c.notFound();
|
|
471
|
+
}
|
|
472
|
+
const { spec, maxAge } = kind === "pipeline"
|
|
473
|
+
? await resolvePipelineSpec(config, projectPath, ref)
|
|
474
|
+
: await resolveCoverageSpec(config, projectPath, ref, c.req.query("job"));
|
|
475
|
+
return svgResponse(await fetchBadgeSvg(spec), maxAge);
|
|
476
|
+
});
|
|
477
|
+
return app;
|
|
478
|
+
}
|
|
479
|
+
export { buildShieldsUrl };
|
|
480
|
+
export const app = createApp();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface BadgeSpec {
|
|
2
|
+
label: string;
|
|
3
|
+
message: string;
|
|
4
|
+
color: string;
|
|
5
|
+
}
|
|
6
|
+
/** Status values documented at https://docs.gitlab.com/ee/api/pipelines.html */
|
|
7
|
+
export declare function pipelineStatusToBadge(status: string): BadgeSpec;
|
|
8
|
+
export declare function coverageToBadge(coverage: number | null): BadgeSpec;
|
|
9
|
+
export declare function releaseToBadge(release: {
|
|
10
|
+
tagName: string;
|
|
11
|
+
} | null): BadgeSpec;
|
|
12
|
+
export declare function tagToBadge(tag: {
|
|
13
|
+
name: string;
|
|
14
|
+
} | null): BadgeSpec;
|
|
15
|
+
export declare function goModVersionToBadge(version: string | null): BadgeSpec;
|
|
16
|
+
export declare function starsToBadge(count: number): BadgeSpec;
|
|
17
|
+
export declare function forksToBadge(count: number): BadgeSpec;
|
|
18
|
+
export declare function contributorsToBadge(count: number): BadgeSpec;
|
|
19
|
+
export declare function issuesToBadge(count: number, state: string, labels: string | undefined): BadgeSpec;
|
|
20
|
+
export declare function mergeRequestsToBadge(count: number, state: string, labels: string | undefined): BadgeSpec;
|
|
21
|
+
export declare function lastCommitToBadge(committedDate: string | null): BadgeSpec;
|
|
22
|
+
export declare function licenseToBadge(name: string | null): BadgeSpec;
|
|
23
|
+
export declare function topLanguageToBadge(languages: Record<string, number>): BadgeSpec;
|
|
24
|
+
export declare function languagesCountToBadge(languages: Record<string, number>): BadgeSpec;
|
|
25
|
+
export declare function labelToBadge(name: string, color: string): BadgeSpec;
|
|
26
|
+
export declare function buildShieldsUrl(spec: BadgeSpec): string;
|
|
27
|
+
export declare function fetchBadgeSvg(spec: BadgeSpec): Promise<string>;
|
|
28
|
+
//# sourceMappingURL=badge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"badge.d.ts","sourceRoot":"","sources":["../../src/badge.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAqB/D;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAMlE;AAaD,wBAAgB,cAAc,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAK7E;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAKlE;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAKrE;AAaD,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAErD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAErD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAE5D;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAGjG;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAIxG;AAcD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAKzE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAK7D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAO/E;AAED,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAElF;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAEnE;AAOD,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAGvD;AASD,wBAAsB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAUpE"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Status values documented at https://docs.gitlab.com/ee/api/pipelines.html */
|
|
2
|
+
export function pipelineStatusToBadge(status) {
|
|
3
|
+
switch (status) {
|
|
4
|
+
case "success":
|
|
5
|
+
return { label: "pipeline", message: "passing", color: "brightgreen" };
|
|
6
|
+
case "failed":
|
|
7
|
+
return { label: "pipeline", message: "failing", color: "red" };
|
|
8
|
+
case "running":
|
|
9
|
+
case "pending":
|
|
10
|
+
case "preparing":
|
|
11
|
+
case "waiting_for_resource":
|
|
12
|
+
case "created":
|
|
13
|
+
return { label: "pipeline", message: "running", color: "yellow" };
|
|
14
|
+
case "canceled":
|
|
15
|
+
case "skipped":
|
|
16
|
+
return { label: "pipeline", message: status, color: "lightgrey" };
|
|
17
|
+
case "manual":
|
|
18
|
+
case "scheduled":
|
|
19
|
+
return { label: "pipeline", message: "pending", color: "yellow" };
|
|
20
|
+
default:
|
|
21
|
+
return { label: "pipeline", message: "unknown", color: "lightgrey" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function coverageToBadge(coverage) {
|
|
25
|
+
if (coverage === null) {
|
|
26
|
+
return { label: "coverage", message: "unknown", color: "lightgrey" };
|
|
27
|
+
}
|
|
28
|
+
const color = coverage >= 90 ? "brightgreen" : coverage >= 75 ? "green" : coverage >= 50 ? "yellow" : "red";
|
|
29
|
+
return { label: "coverage", message: `${Math.round(coverage * 10) / 10}%`, color };
|
|
30
|
+
}
|
|
31
|
+
/** Mirrors shields.io's version-badge coloring: 0.x and alpha/beta/rc-like tags render orange, everything else blue. */
|
|
32
|
+
function versionColor(tagName) {
|
|
33
|
+
const bare = tagName.replace(/^v/, "");
|
|
34
|
+
const isPrerelease = bare.startsWith("0") || /alpha|beta|snapshot|dev|pre|rc|scm|cvs/i.test(tagName);
|
|
35
|
+
return isPrerelease ? "orange" : "blue";
|
|
36
|
+
}
|
|
37
|
+
function withVPrefix(tagName) {
|
|
38
|
+
return tagName.startsWith("v") ? tagName : `v${tagName}`;
|
|
39
|
+
}
|
|
40
|
+
export function releaseToBadge(release) {
|
|
41
|
+
if (release === null) {
|
|
42
|
+
return { label: "release", message: "unknown", color: "lightgrey" };
|
|
43
|
+
}
|
|
44
|
+
return { label: "release", message: withVPrefix(release.tagName), color: versionColor(release.tagName) };
|
|
45
|
+
}
|
|
46
|
+
export function tagToBadge(tag) {
|
|
47
|
+
if (tag === null) {
|
|
48
|
+
return { label: "tag", message: "unknown", color: "lightgrey" };
|
|
49
|
+
}
|
|
50
|
+
return { label: "tag", message: withVPrefix(tag.name), color: versionColor(tag.name) };
|
|
51
|
+
}
|
|
52
|
+
export function goModVersionToBadge(version) {
|
|
53
|
+
if (version === null) {
|
|
54
|
+
return { label: "Go", message: "unknown", color: "lightgrey" };
|
|
55
|
+
}
|
|
56
|
+
return { label: "Go", message: withVPrefix(version), color: versionColor(version) };
|
|
57
|
+
}
|
|
58
|
+
/** Abbreviates large counts the way shields.io's badges do, e.g. 12345 -> "12.3k". */
|
|
59
|
+
function metric(count) {
|
|
60
|
+
if (count >= 1_000_000) {
|
|
61
|
+
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
62
|
+
}
|
|
63
|
+
if (count >= 1_000) {
|
|
64
|
+
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
65
|
+
}
|
|
66
|
+
return String(count);
|
|
67
|
+
}
|
|
68
|
+
export function starsToBadge(count) {
|
|
69
|
+
return { label: "stars", message: metric(count), color: "blue" };
|
|
70
|
+
}
|
|
71
|
+
export function forksToBadge(count) {
|
|
72
|
+
return { label: "forks", message: metric(count), color: "blue" };
|
|
73
|
+
}
|
|
74
|
+
export function contributorsToBadge(count) {
|
|
75
|
+
return { label: "contributors", message: metric(count), color: "blue" };
|
|
76
|
+
}
|
|
77
|
+
export function issuesToBadge(count, state, labels) {
|
|
78
|
+
const label = labels ? `${labels} issues` : "issues";
|
|
79
|
+
return { label, message: `${metric(count)} ${state}`, color: count > 0 ? "yellow" : "brightgreen" };
|
|
80
|
+
}
|
|
81
|
+
export function mergeRequestsToBadge(count, state, labels) {
|
|
82
|
+
const label = labels ? `${labels} merge requests` : "merge requests";
|
|
83
|
+
const message = `${count > 10000 ? "more than " : ""}${metric(count)} ${state}`;
|
|
84
|
+
return { label, message, color: "blue" };
|
|
85
|
+
}
|
|
86
|
+
/** Coarse relative-time formatting, e.g. "today" / "3 days ago" / "2 months ago". */
|
|
87
|
+
function relativeDate(iso) {
|
|
88
|
+
const diffDays = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
|
89
|
+
if (diffDays < 1)
|
|
90
|
+
return "today";
|
|
91
|
+
if (diffDays === 1)
|
|
92
|
+
return "yesterday";
|
|
93
|
+
if (diffDays < 30)
|
|
94
|
+
return `${diffDays} days ago`;
|
|
95
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
96
|
+
if (diffMonths < 12)
|
|
97
|
+
return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`;
|
|
98
|
+
const diffYears = Math.floor(diffMonths / 12);
|
|
99
|
+
return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`;
|
|
100
|
+
}
|
|
101
|
+
export function lastCommitToBadge(committedDate) {
|
|
102
|
+
if (committedDate === null) {
|
|
103
|
+
return { label: "last commit", message: "unknown", color: "lightgrey" };
|
|
104
|
+
}
|
|
105
|
+
return { label: "last commit", message: relativeDate(committedDate), color: "blue" };
|
|
106
|
+
}
|
|
107
|
+
export function licenseToBadge(name) {
|
|
108
|
+
if (name === null) {
|
|
109
|
+
return { label: "license", message: "not specified", color: "lightgrey" };
|
|
110
|
+
}
|
|
111
|
+
return { label: "license", message: name, color: "blue" };
|
|
112
|
+
}
|
|
113
|
+
export function topLanguageToBadge(languages) {
|
|
114
|
+
const entries = Object.entries(languages);
|
|
115
|
+
if (entries.length === 0) {
|
|
116
|
+
return { label: "language", message: "unknown", color: "lightgrey" };
|
|
117
|
+
}
|
|
118
|
+
const [topLanguage, percentage] = entries.reduce((a, b) => (b[1] > a[1] ? b : a));
|
|
119
|
+
return { label: topLanguage.toLowerCase(), message: `${percentage.toFixed(1)}%`, color: "blue" };
|
|
120
|
+
}
|
|
121
|
+
export function languagesCountToBadge(languages) {
|
|
122
|
+
return { label: "languages", message: metric(Object.keys(languages).length), color: "blue" };
|
|
123
|
+
}
|
|
124
|
+
export function labelToBadge(name, color) {
|
|
125
|
+
return { label: "label", message: name, color: color.replace(/^#/, "") };
|
|
126
|
+
}
|
|
127
|
+
/** Escaping rules for shields.io's static badge endpoint. */
|
|
128
|
+
function escapeSegment(segment) {
|
|
129
|
+
return segment.replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "_");
|
|
130
|
+
}
|
|
131
|
+
export function buildShieldsUrl(spec) {
|
|
132
|
+
const segments = [spec.label, spec.message, spec.color].map(escapeSegment).map(encodeURIComponent);
|
|
133
|
+
return `https://img.shields.io/badge/${segments.join("-")}.svg`;
|
|
134
|
+
}
|
|
135
|
+
/** Static fallback badge, used when shields.io itself is unreachable so we never throw from here. */
|
|
136
|
+
const STATIC_UNKNOWN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="98" height="20" role="img" aria-label="status: unknown">' +
|
|
137
|
+
'<rect width="98" height="20" fill="#9f9f9f" rx="3"/>' +
|
|
138
|
+
'<text x="49" y="14" fill="#fff" font-family="Verdana,sans-serif" font-size="11" text-anchor="middle">status: unknown</text>' +
|
|
139
|
+
"</svg>";
|
|
140
|
+
export async function fetchBadgeSvg(spec) {
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(buildShieldsUrl(spec));
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
return STATIC_UNKNOWN_SVG;
|
|
145
|
+
}
|
|
146
|
+
return await response.text();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return STATIC_UNKNOWN_SVG;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
/** Base URL of the private GitLab instance, e.g. https://gitlab.example.net. Never logged or echoed back. */
|
|
3
|
+
gitlabUrl: string;
|
|
4
|
+
/** Token with the read_api scope on the target project. Never logged or echoed back. */
|
|
5
|
+
gitlabToken: string;
|
|
6
|
+
/** Project path (`group/name`) or numeric project ID. */
|
|
7
|
+
project: string;
|
|
8
|
+
/** Branch/ref the badges are computed for. */
|
|
9
|
+
ref: string;
|
|
10
|
+
/** Edge/CDN cache lifetime in seconds for successful responses. */
|
|
11
|
+
cacheMaxAgeSeconds: number;
|
|
12
|
+
/**
|
|
13
|
+
* Project paths (`group/name`, nested groups allowed) that may be requested through the
|
|
14
|
+
* GitLab-shaped `/:project/badges/:ref/*.svg` routes. Empty by default: those routes stay
|
|
15
|
+
* disabled until a project is explicitly whitelisted here, so the proxy never turns into an
|
|
16
|
+
* open oracle for whatever projects the token happens to have read access to.
|
|
17
|
+
*/
|
|
18
|
+
allowedProjects: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function loadConfig(env?: Record<string, string | undefined>): Config;
|
|
21
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,MAAM;IACrB,6GAA6G;IAC7G,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAUD,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,MAAM,CAYxF"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function required(env, name) {
|
|
2
|
+
const value = env[name];
|
|
3
|
+
if (!value) {
|
|
4
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function loadConfig(env = process.env) {
|
|
9
|
+
return {
|
|
10
|
+
gitlabUrl: required(env, "GITLAB_URL").replace(/\/+$/, ""),
|
|
11
|
+
gitlabToken: required(env, "GITLAB_TOKEN"),
|
|
12
|
+
project: required(env, "GITLAB_PROJECT"),
|
|
13
|
+
ref: env.GITLAB_REF || "master",
|
|
14
|
+
cacheMaxAgeSeconds: env.CACHE_MAX_AGE_SECONDS ? Number(env.CACHE_MAX_AGE_SECONDS) : 60,
|
|
15
|
+
allowedProjects: (env.GITLAB_PROJECTS ?? "")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter((entry) => entry.length > 0),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export interface PipelineSummary {
|
|
3
|
+
id: number;
|
|
4
|
+
status: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ReleaseSummary {
|
|
7
|
+
name: string;
|
|
8
|
+
tagName: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getLatestPipeline(config: Config, projectPath: string, ref: string): Promise<PipelineSummary | null>;
|
|
11
|
+
export declare function getPipelineCoverage(config: Config, projectPath: string, pipelineId: number): Promise<number | null>;
|
|
12
|
+
/** Coverage reported by one specific job in the pipeline, mirroring GitLab's own `?job=` badge param. */
|
|
13
|
+
export declare function getJobCoverage(config: Config, projectPath: string, pipelineId: number, jobName: string): Promise<number | null>;
|
|
14
|
+
export declare function getLatestRelease(config: Config, projectPath: string): Promise<ReleaseSummary | null>;
|
|
15
|
+
export declare function getStarsCount(config: Config, projectPath: string): Promise<number>;
|
|
16
|
+
export declare function getForksCount(config: Config, projectPath: string): Promise<number>;
|
|
17
|
+
export declare function getContributorsCount(config: Config, projectPath: string): Promise<number>;
|
|
18
|
+
export type IssueState = "opened" | "closed" | "all";
|
|
19
|
+
export declare function getIssuesCount(config: Config, projectPath: string, state: IssueState, labels: string | undefined): Promise<number>;
|
|
20
|
+
export type MergeRequestState = "opened" | "closed" | "merged" | "locked" | "all";
|
|
21
|
+
export declare function getMergeRequestsCount(config: Config, projectPath: string, state: MergeRequestState, labels: string | undefined): Promise<number>;
|
|
22
|
+
export declare function getLastCommitDate(config: Config, projectPath: string, ref: string | undefined, path: string | undefined): Promise<string | null>;
|
|
23
|
+
export declare function getLicenseName(config: Config, projectPath: string): Promise<string | null>;
|
|
24
|
+
export declare function getLatestTag(config: Config, projectPath: string): Promise<{
|
|
25
|
+
name: string;
|
|
26
|
+
} | null>;
|
|
27
|
+
/** Language name -> percentage of the codebase, e.g. `{ TypeScript: 92.1, JavaScript: 7.9 }`. */
|
|
28
|
+
export declare function getLanguages(config: Config, projectPath: string): Promise<Record<string, number>>;
|
|
29
|
+
export declare function getLabelColor(config: Config, projectPath: string, name: string): Promise<string>;
|
|
30
|
+
export declare function getGoModVersion(config: Config, projectPath: string, ref: string, filename: string): Promise<string | null>;
|
|
31
|
+
//# sourceMappingURL=gitlab.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gitlab.d.ts","sourceRoot":"","sources":["../../src/gitlab.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAiCD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAIjC;AAED,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAED,yGAAyG;AACzG,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAcxB;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAQ1G;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGxF;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGxF;AAED,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO/F;AAED,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,GAAG,SAAS,GACzB,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAC;AAElF,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,iBAAiB,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAQxB;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKhG;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOxG;AAED,iGAAiG;AACjG,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAEvG;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGtG;AAID,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
async function gitlabRequest(config, projectPath, path) {
|
|
2
|
+
const projectSegment = encodeURIComponent(projectPath);
|
|
3
|
+
const url = `${config.gitlabUrl}/api/v4/projects/${projectSegment}${path}`;
|
|
4
|
+
const response = await fetch(url, {
|
|
5
|
+
headers: { "PRIVATE-TOKEN": config.gitlabToken },
|
|
6
|
+
});
|
|
7
|
+
if (!response.ok) {
|
|
8
|
+
throw new Error(`GitLab API responded with ${response.status}`);
|
|
9
|
+
}
|
|
10
|
+
return response;
|
|
11
|
+
}
|
|
12
|
+
async function gitlabGet(config, projectPath, path) {
|
|
13
|
+
return (await gitlabRequest(config, projectPath, path)).json();
|
|
14
|
+
}
|
|
15
|
+
async function gitlabGetText(config, projectPath, path) {
|
|
16
|
+
return (await gitlabRequest(config, projectPath, path)).text();
|
|
17
|
+
}
|
|
18
|
+
/** GitLab exposes some aggregate counts (contributors, merge requests) only via the `x-total` header. */
|
|
19
|
+
async function gitlabGetTotalHeader(config, projectPath, path) {
|
|
20
|
+
const response = await gitlabRequest(config, projectPath, path);
|
|
21
|
+
const total = response.headers.get("x-total");
|
|
22
|
+
if (total === null) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const value = Number.parseInt(total, 10);
|
|
26
|
+
return Number.isFinite(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
export async function getLatestPipeline(config, projectPath, ref) {
|
|
29
|
+
const params = new URLSearchParams({ ref, order_by: "id", sort: "desc", per_page: "1" });
|
|
30
|
+
const pipelines = (await gitlabGet(config, projectPath, `/pipelines?${params}`));
|
|
31
|
+
return pipelines[0] ?? null;
|
|
32
|
+
}
|
|
33
|
+
export async function getPipelineCoverage(config, projectPath, pipelineId) {
|
|
34
|
+
const pipeline = (await gitlabGet(config, projectPath, `/pipelines/${pipelineId}`));
|
|
35
|
+
if (!pipeline.coverage) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const value = Number.parseFloat(pipeline.coverage);
|
|
39
|
+
return Number.isFinite(value) ? value : null;
|
|
40
|
+
}
|
|
41
|
+
/** Coverage reported by one specific job in the pipeline, mirroring GitLab's own `?job=` badge param. */
|
|
42
|
+
export async function getJobCoverage(config, projectPath, pipelineId, jobName) {
|
|
43
|
+
const params = new URLSearchParams({ per_page: "100" });
|
|
44
|
+
const jobs = (await gitlabGet(config, projectPath, `/pipelines/${pipelineId}/jobs?${params}`));
|
|
45
|
+
const matches = jobs.filter((job) => job.name === jobName && job.coverage != null);
|
|
46
|
+
if (matches.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const latest = matches.reduce((a, b) => (b.id > a.id ? b : a));
|
|
50
|
+
const value = typeof latest.coverage === "string" ? Number.parseFloat(latest.coverage) : latest.coverage;
|
|
51
|
+
return Number.isFinite(value) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
export async function getLatestRelease(config, projectPath) {
|
|
54
|
+
const params = new URLSearchParams({ order_by: "released_at", sort: "desc", per_page: "1" });
|
|
55
|
+
const releases = (await gitlabGet(config, projectPath, `/releases?${params}`));
|
|
56
|
+
const release = releases[0];
|
|
57
|
+
return release ? { name: release.name, tagName: release.tag_name } : null;
|
|
58
|
+
}
|
|
59
|
+
export async function getStarsCount(config, projectPath) {
|
|
60
|
+
const project = (await gitlabGet(config, projectPath, ""));
|
|
61
|
+
return project.star_count;
|
|
62
|
+
}
|
|
63
|
+
export async function getForksCount(config, projectPath) {
|
|
64
|
+
const project = (await gitlabGet(config, projectPath, ""));
|
|
65
|
+
return project.forks_count;
|
|
66
|
+
}
|
|
67
|
+
export async function getContributorsCount(config, projectPath) {
|
|
68
|
+
const total = await gitlabGetTotalHeader(config, projectPath, `/repository/contributors?${new URLSearchParams({ page: "1", per_page: "1" })}`);
|
|
69
|
+
return total ?? 0;
|
|
70
|
+
}
|
|
71
|
+
export async function getIssuesCount(config, projectPath, state, labels) {
|
|
72
|
+
const params = labels ? `?${new URLSearchParams({ labels })}` : "";
|
|
73
|
+
const { statistics } = (await gitlabGet(config, projectPath, `/issues_statistics${params}`));
|
|
74
|
+
return statistics.counts[state];
|
|
75
|
+
}
|
|
76
|
+
export async function getMergeRequestsCount(config, projectPath, state, labels) {
|
|
77
|
+
const params = new URLSearchParams({ state, page: "1", per_page: "1" });
|
|
78
|
+
if (labels) {
|
|
79
|
+
params.set("labels", labels);
|
|
80
|
+
}
|
|
81
|
+
const total = await gitlabGetTotalHeader(config, projectPath, `/merge_requests?${params}`);
|
|
82
|
+
// GitLab omits x-total once a query matches more than 10k records.
|
|
83
|
+
return total ?? 10001;
|
|
84
|
+
}
|
|
85
|
+
export async function getLastCommitDate(config, projectPath, ref, path) {
|
|
86
|
+
const params = new URLSearchParams({ per_page: "1" });
|
|
87
|
+
if (ref)
|
|
88
|
+
params.set("ref_name", ref);
|
|
89
|
+
if (path)
|
|
90
|
+
params.set("path", path);
|
|
91
|
+
const commits = (await gitlabGet(config, projectPath, `/repository/commits?${params}`));
|
|
92
|
+
return commits[0]?.committed_date ?? null;
|
|
93
|
+
}
|
|
94
|
+
export async function getLicenseName(config, projectPath) {
|
|
95
|
+
const project = (await gitlabGet(config, projectPath, `?${new URLSearchParams({ license: "1" })}`));
|
|
96
|
+
return project.license?.name ?? null;
|
|
97
|
+
}
|
|
98
|
+
export async function getLatestTag(config, projectPath) {
|
|
99
|
+
const tags = (await gitlabGet(config, projectPath, `/repository/tags?${new URLSearchParams({ order_by: "updated" })}`));
|
|
100
|
+
return tags[0] ?? null;
|
|
101
|
+
}
|
|
102
|
+
/** Language name -> percentage of the codebase, e.g. `{ TypeScript: 92.1, JavaScript: 7.9 }`. */
|
|
103
|
+
export async function getLanguages(config, projectPath) {
|
|
104
|
+
return (await gitlabGet(config, projectPath, "/languages"));
|
|
105
|
+
}
|
|
106
|
+
export async function getLabelColor(config, projectPath, name) {
|
|
107
|
+
const label = (await gitlabGet(config, projectPath, `/labels/${encodeURIComponent(name)}`));
|
|
108
|
+
return label.color;
|
|
109
|
+
}
|
|
110
|
+
const GO_VERSION_PATTERN = /^go ([^/\s]+)(\s*\/.+)?$/m;
|
|
111
|
+
export async function getGoModVersion(config, projectPath, ref, filename) {
|
|
112
|
+
const params = new URLSearchParams({ ref });
|
|
113
|
+
const content = await gitlabGetText(config, projectPath, `/repository/files/${encodeURIComponent(filename)}/raw?${params}`);
|
|
114
|
+
const match = GO_VERSION_PATTERN.exec(content);
|
|
115
|
+
return match ? match[1] : null;
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitlab-badge-proxy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server-side proxy that turns private GitLab pipeline/coverage status into public SVG badges, without ever exposing the GitLab instance's URL to clients.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ArdensLab",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ArdensLab/gitlab-badge-proxy.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"gitlab",
|
|
14
|
+
"badge",
|
|
15
|
+
"shields.io",
|
|
16
|
+
"pipeline",
|
|
17
|
+
"coverage",
|
|
18
|
+
"svg",
|
|
19
|
+
"proxy",
|
|
20
|
+
"hono"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/src/app.d.ts",
|
|
25
|
+
"default": "./dist/src/app.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"types": "./dist/src/app.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"dev": "npm run build && node --env-file=.env.local scripts/dev-server.mjs",
|
|
40
|
+
"dev:vercel": "vercel dev",
|
|
41
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"hono": "^4.6.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@hono/node-server": "^2.1.5",
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"typescript": "^5.7.0",
|
|
50
|
+
"vitest": "^5.0.5"
|
|
51
|
+
}
|
|
52
|
+
}
|