From 200527b37e31f2b22b2c2d649f0349e290d08ea0 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:34:53 -0400 Subject: [PATCH 1/2] Add Gitea, Forgejo and GitLab to the Git widget The GitHub widget is now named Git and reads GitHub, Gitea, Forgejo or GitLab. Gitea and Forgejo offer pull requests and the contribution heatmap. GitLab offers merge requests. The fetch helper returns response headers on request, for the total counts. --- api/src/proxy.js | 15 ++- api/test/github-data.test.js | 164 +++++++++++++++++++++++ api/test/proxy.test.js | 17 +++ api/test/secret-scope.test.js | 8 ++ ui/test/widget-toolbox-link.test.mjs | 7 +- ui/widgets/github/contributions.html | 19 +-- ui/widgets/github/data.js | 191 ++++++++++++++++++++++++--- ui/widgets/github/i18n/de.json | 24 +++- ui/widgets/github/i18n/en.json | 24 +++- ui/widgets/github/i18n/es.json | 24 +++- ui/widgets/github/i18n/fa.json | 24 +++- ui/widgets/github/i18n/fr.json | 24 +++- ui/widgets/github/i18n/zh-Hans.json | 24 +++- ui/widgets/github/pullrequests.html | 18 ++- ui/widgets/github/widget.json | 105 ++++++++++++++- 15 files changed, 605 insertions(+), 83 deletions(-) diff --git a/api/src/proxy.js b/api/src/proxy.js index d75cf239..3e4c7c87 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -357,12 +357,17 @@ function fetchJSON(raw, opts = {}) { contentType: (res.headers['content-type'] || '').toLowerCase(), }); const body = Buffer.concat(bufs).toString('utf8'); - if (opts.raw) return done(resolve, { status: res.statusCode, data: body }); + /* Keep headers opt-in. Some widgets return the reply as is, and headers can carry cookies. */ + const reply = data => + opts.withHeaders + ? { status: res.statusCode, data, headers: res.headers } + : { status: res.statusCode, data }; + if (opts.raw) return done(resolve, reply(body)); const ct = (res.headers['content-type'] || '').toLowerCase(); try { - done(resolve, { status: res.statusCode, data: JSON.parse(body) }); + done(resolve, reply(JSON.parse(body))); } catch { - if (looksLikeMetrics(ct, body)) done(resolve, { status: res.statusCode, data: parsePrometheus(body) }); + if (looksLikeMetrics(ct, body)) done(resolve, reply(parsePrometheus(body))); else if (ct.includes('xml') || body.trimStart().startsWith('<')) { const parsed = parseXml(body); if (parsed['#truncated']) { @@ -373,8 +378,8 @@ function fetchJSON(raw, opts = {}) { bytes: body.length, }); } - done(resolve, { status: res.statusCode, data: parsed }); - } else done(resolve, { status: res.statusCode, data: body }); + done(resolve, reply(parsed)); + } else done(resolve, reply(body)); } }); }, diff --git a/api/test/github-data.test.js b/api/test/github-data.test.js index ec39b3af..876ffa2e 100644 --- a/api/test/github-data.test.js +++ b/api/test/github-data.test.js @@ -49,3 +49,167 @@ test('an empty search is a valid empty list', async () => { const r = await dataFn(ctxFor(PRS, { status: 200, data: { total_count: 0, items: [] } })); assert.deepEqual(r.items, []); }); + +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +function recordingCtx(config, reply) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts) => { + calls.push({ url: new URL(url), opts }); + return typeof reply === 'function' ? reply(url) : reply; + }, + ...errorParts(), + }; + return { ctx, calls }; +} + +const GITEA = { githubService: 'gitea', githubUrl: 'https://git.lan/', githubUser: 'me', githubToken: 't' }; + +for (const service of ['gitea', 'forgejo']) { + test(`${service} pull requests search the token owner's open pull requests`, async () => { + const { ctx, calls } = recordingCtx( + { ...GITEA, githubService: service, githubPrFilters: ['created', 'review-requested'] }, + { + status: 200, + headers: { 'x-total-count': '31' }, + data: [ + { number: 4, title: 'Fix', repository: { full_name: 'me/app' }, html_url: 'https://git.lan/me/app/pulls/4' }, + ], + }, + ); + const r = await dataFn(ctx); + const { url, opts } = calls[0]; + assert.equal(url.origin + url.pathname, 'https://git.lan/api/v1/repos/issues/search'); + assert.equal(url.searchParams.get('type'), 'pulls'); + assert.equal(url.searchParams.get('state'), 'open'); + assert.equal(url.searchParams.get('created'), 'true'); + assert.equal(url.searchParams.get('review_requested'), 'true'); + assert.equal(opts.headers.Authorization, 'token t'); + assert.equal(r.totalCount, 31); + assert.deepEqual(r.items, [{ number: 4, title: 'Fix', repo: 'me/app', url: 'https://git.lan/me/app/pulls/4' }]); + assert.equal(r.allUrl, 'https://git.lan/pulls'); + }); +} + +test('gitea counts the listed pull requests when the total header is missing', async () => { + const { ctx } = recordingCtx(GITEA, { + status: 200, + data: [ + { number: 1, title: 'a' }, + { number: 2, title: 'b' }, + ], + }); + assert.equal((await dataFn(ctx)).totalCount, 2); +}); + +test('gitea without a server URL fails as a setup problem', async () => { + const { ctx } = recordingCtx({ ...GITEA, githubUrl: '' }, { status: 200, data: [] }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND.INVALID && /URL/.test(e.message)); +}); + +for (const [status, kind] of [ + [401, 'AUTH'], + [403, 'AUTH'], + [404, 'INVALID'], +]) { + test(`gitea answering ${status} fails as ${kind}`, async () => { + const { ctx } = recordingCtx(GITEA, { status, data: { message: 'x' } }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND[kind]); + }); +} + +test('gitea contributions add the heatmap slots up per day', async () => { + const now = new Date(); + const at = (daysAgo, hour) => { + const d = new Date(now); + d.setDate(d.getDate() - daysAgo); + d.setHours(hour, 15, 0, 0); + return Math.floor(d.getTime() / 1000); + }; + const { ctx, calls } = recordingCtx( + { ...GITEA, githubView: 'contributions' }, + { + status: 200, + data: [ + { timestamp: at(0, 9), contributions: 2 }, + { timestamp: at(0, 10), contributions: 3 }, + { timestamp: at(3, 12), contributions: 4 }, + { timestamp: at(400, 12), contributions: 9 }, + ], + }, + ); + const r = await dataFn(ctx); + assert.equal(calls[0].url.pathname, '/api/v1/users/me/heatmap'); + assert.equal(r.view, 'contributions'); + assert.equal(r.totalContributions, 9); + const days = r.weeks.flatMap(w => w.contributionDays); + assert.equal(days.at(-1).contributionCount, 5); + assert.ok(r.weeks.every(w => new Date(w.contributionDays[0].date + 'T00:00:00Z').getUTCDay() === 0)); + assert.equal(r.profileUrl, 'https://git.lan/me'); +}); + +const GITLAB = { githubService: 'gitlab', githubUrl: 'https://gitlab.com', githubUser: 'me', githubToken: 't' }; + +test('gitlab lists open merge requests by the chosen filters', async () => { + const { ctx, calls } = recordingCtx( + { ...GITLAB, githubPrFilters: ['assigned', 'mentioned'] }, + { + status: 200, + headers: { 'x-total': '12' }, + data: [ + { + iid: 9, + title: 'Bump', + references: { full: 'me/app!9' }, + web_url: 'https://gitlab.com/me/app/-/merge_requests/9', + }, + ], + }, + ); + const r = await dataFn(ctx); + const { url, opts } = calls[0]; + assert.equal(url.pathname, '/api/v4/merge_requests'); + assert.equal(url.searchParams.get('state'), 'opened'); + assert.equal(url.searchParams.get('assignee_username'), 'me'); + assert.equal(url.searchParams.has('author_username'), false); + assert.equal(opts.headers['PRIVATE-TOKEN'], 't'); + assert.deepEqual(r.filters, ['assigned']); + assert.equal(r.totalCount, 12); + assert.deepEqual(r.items, [ + { number: 9, title: 'Bump', repo: 'me/app', url: 'https://gitlab.com/me/app/-/merge_requests/9' }, + ]); + assert.equal(r.allUrl, 'https://gitlab.com/dashboard/merge_requests'); +}); + +test('gitlab with a saved contributions view fails as a setup problem', async () => { + const { ctx, calls } = recordingCtx({ ...GITLAB, githubView: 'contributions' }, { status: 200, data: [] }); + await assert.rejects(dataFn(ctx), e => e.kind === ctx.KIND.INVALID && /contribution/.test(e.message)); + assert.equal(calls.length, 0); +}); + +test('gitlab with only unsupported filters searches by author', async () => { + const { ctx, calls } = recordingCtx({ ...GITLAB, githubPrFilters: ['mentioned'] }, { status: 200, data: [] }); + assert.deepEqual((await dataFn(ctx)).filters, ['created']); + assert.equal(calls[0].url.searchParams.get('author_username'), 'me'); +}); + +test('an unknown service reads GitHub', async () => { + const { ctx, calls } = recordingCtx({ ...PRS, githubService: 'svn' }, { status: 200, data: { items: [] } }); + await dataFn(ctx); + assert.equal(calls[0].url.host, 'api.github.com'); +}); + +test('github contributions link the profile unless a link URL is set', async () => { + const reply = { + status: 200, + data: { data: { user: { contributionsCollection: { contributionCalendar: { weeks: [] } } } } }, + }; + assert.equal((await dataFn(recordingCtx(CONTRIBUTIONS, reply).ctx)).profileUrl, 'https://github.com/octo'); + const own = { ...CONTRIBUTIONS, githubHref: 'https://me.example' }; + assert.equal((await dataFn(recordingCtx(own, reply).ctx)).profileUrl, 'https://me.example'); +}); diff --git a/api/test/proxy.test.js b/api/test/proxy.test.js index 3244fdd3..aa0856c1 100644 --- a/api/test/proxy.test.js +++ b/api/test/proxy.test.js @@ -202,6 +202,23 @@ test('getHostIp returns an empty string when no config file exists', () => { assert.equal(getHostIp(), ''); }); +test('fetchJSON returns response headers only when asked', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json', 'X-Total-Count': '7', 'Set-Cookie': 'sid=1' }); + res.end('[]'); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + try { + const plain = await fetchJSON(`http://127.0.0.1:${port}/`, { timeout: 3000 }); + assert.deepEqual(Object.keys(plain).sort(), ['data', 'status']); + const withHeaders = await fetchJSON(`http://127.0.0.1:${port}/`, { withHeaders: true, timeout: 3000 }); + assert.equal(withHeaders.headers['x-total-count'], '7'); + } finally { + server.close(); + } +}); + test('fetchJSON returns the untouched body with opts.raw, and auto-parses without it', async () => { const metrics = '# TYPE conduit_connected_clients gauge\nconduit_connected_clients 5\n'; const server = http.createServer((req, res) => { diff --git a/api/test/secret-scope.test.js b/api/test/secret-scope.test.js index 5fa73847..3c655dca 100644 --- a/api/test/secret-scope.test.js +++ b/api/test/secret-scope.test.js @@ -229,6 +229,14 @@ test('changing the github view, user or filters keeps the stored token in scope' } }); +test('changing the git service or server URL takes the stored token out of scope', () => { + const github = getRegistry().github; + const saved = { githubService: 'gitea', githubUrl: 'https://git.a', githubUser: 'me', githubToken: 'STORED' }; + for (const over of [{ githubService: 'forgejo' }, { githubUrl: 'https://git.b' }]) { + assert.equal(widgetConfigMatchesSaved({ ...saved, ...over }, saved, github), false, JSON.stringify(over)); + } +}); + test('a field that reaches the request still takes the stored credential out of scope', () => { const r = getRegistry(); const cases = [ diff --git a/ui/test/widget-toolbox-link.test.mjs b/ui/test/widget-toolbox-link.test.mjs index 01f06788..a32e824f 100644 --- a/ui/test/widget-toolbox-link.test.mjs +++ b/ui/test/widget-toolbox-link.test.mjs @@ -125,10 +125,9 @@ test('the DNS link is described by its summary', () => { }); test('the pull request header link is named by its visible label first', () => { - assert.match( - widget('github/pullrequests.html'), - /'aria-label',\s*`\$\{wt\('ui\.pullRequests', 'Pull requests'\)\}, /, - ); + const src = widget('github/pullrequests.html'); + assert.match(src, /lbl\.textContent = noun;/); + assert.match(src, /'aria-label',\s*`\$\{noun\}, /); }); test('a pull request row without a number is named without one', () => { diff --git a/ui/widgets/github/contributions.html b/ui/widgets/github/contributions.html index c9953fce..0fc54d90 100644 --- a/ui/widgets/github/contributions.html +++ b/ui/widgets/github/contributions.html @@ -3,7 +3,7 @@
-