diff --git a/core/server/server.js b/core/server/server.js index 8f92e4fd28fd9..fb3c5ef090d69 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -128,6 +128,7 @@ const publicConfigSchema = Joi.object({ }, restApiVersion: Joi.date().raw().required(), }, + gitea: defaultService, gitlab: defaultService, jira: defaultService, jenkins: Joi.object({ @@ -168,6 +169,7 @@ const privateConfigSchema = Joi.object({ gh_client_id: Joi.string(), gh_client_secret: Joi.string(), gh_token: Joi.string(), + gitea_token: Joi.string(), gitlab_token: Joi.string(), jenkins_user: Joi.string(), jenkins_pass: Joi.string(), diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 3ce330628b35c..636a7dcb47ade 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -167,6 +167,15 @@ These settings are used by shields.io for GitHub OAuth app authorization but will not be necessary for most self-hosted installations. See [production-hosting.md](./production-hosting.md). +### Gitea + +- `GITEA_ORIGINS` (yml: `public.services.gitea.authorizedOrigins`) +- `GITEA_TOKEN` (yml: `private.gitea_token`) + +A Gitea [Personal Access Token][gitea-pat] is required for accessing private content. If you need a Gitea token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using. + +[gitea-pat]: https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens + ### GitLab - `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`) diff --git a/services/gitea/gitea-base.js b/services/gitea/gitea-base.js new file mode 100644 index 0000000000000..2a14ba846ef01 --- /dev/null +++ b/services/gitea/gitea-base.js @@ -0,0 +1,19 @@ +import { BaseJsonService } from '../index.js' + +export default class GiteaBase extends BaseJsonService { + static auth = { + passKey: 'gitea_token', + serviceKey: 'gitea', + } + + async fetch({ url, options, schema, httpErrors }) { + return this._requestJson( + this.authHelper.withBearerAuthHeader({ + schema, + url, + options, + httpErrors, + }), + ) + } +} diff --git a/services/gitea/gitea-base.spec.js b/services/gitea/gitea-base.spec.js new file mode 100644 index 0000000000000..868e964920080 --- /dev/null +++ b/services/gitea/gitea-base.spec.js @@ -0,0 +1,48 @@ +import Joi from 'joi' +import { expect } from 'chai' +import nock from 'nock' +import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import GiteaBase from './gitea-base.js' + +class DummyGiteaService extends GiteaBase { + static route = { base: 'fake-base' } + + async handle() { + const data = await this.fetch({ + schema: Joi.any(), + url: 'https://codeberg.org/api/v1/repos/CanisHelix/shields-badge-test/releases', + }) + return { message: data.message } + } +} + +describe('GiteaBase', function () { + describe('auth', function () { + cleanUpNockAfterEach() + + const config = { + public: { + services: { + gitea: { + authorizedOrigins: ['https://codeberg.org'], + }, + }, + }, + private: { + gitea_token: 'fake-key', + }, + } + + it('sends the auth information as configured', async function () { + const scope = nock('https://codeberg.org') + .get('/api/v1/repos/CanisHelix/shields-badge-test/releases') + .matchHeader('Authorization', 'Bearer fake-key') + .reply(200, { message: 'fake message' }) + expect( + await DummyGiteaService.invoke(defaultContext, config, {}), + ).to.not.have.property('isError') + + scope.done() + }) + }) +}) diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js new file mode 100644 index 0000000000000..51242d819d508 --- /dev/null +++ b/services/gitea/gitea-helper.js @@ -0,0 +1,12 @@ +const documentation = ` +Note that the gitea_url parameter is required because there is canonical hosted gitea service provided by Gitea. +` + +function httpErrorsFor() { + return { + 403: 'private repo', + 404: 'user or repo not found', + } +} + +export { documentation, httpErrorsFor } diff --git a/services/gitea/gitea-languages-count.service.js b/services/gitea/gitea-languages-count.service.js new file mode 100644 index 0000000000000..819198521b647 --- /dev/null +++ b/services/gitea/gitea-languages-count.service.js @@ -0,0 +1,77 @@ +import Joi from 'joi' +import { nonNegativeInteger, optionalUrl } from '../validators.js' +import { metric } from '../text-formatters.js' +import { pathParam, queryParam } from '../index.js' +import { documentation, httpErrorsFor } from './gitea-helper.js' +import GiteaBase from './gitea-base.js' + +/* +We're expecting a response like { "Python": 39624, "Shell": 104 } +The keys could be anything and {} is a valid response (e.g: for an empty repo) +*/ +const schema = Joi.object().pattern(/./, nonNegativeInteger) + +const queryParamSchema = Joi.object({ + gitea_url: optionalUrl.required(), +}).required() + +export default class GiteaLanguageCount extends GiteaBase { + static category = 'analysis' + + static route = { + base: 'gitea/languages/count', + pattern: ':user/:repo', + queryParamSchema, + } + + static openApi = { + '/gitea/languages/count/{user}/{repo}': { + get: { + summary: 'Gitea language count', + description: documentation, + parameters: [ + pathParam({ + name: 'user', + example: 'forgejo', + }), + pathParam({ + name: 'repo', + example: 'forgejo', + }), + queryParam({ + name: 'gitea_url', + example: 'https://codeberg.org', + required: true, + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'languages' } + + static render({ languagesCount }) { + return { + message: metric(languagesCount), + color: 'blue', + } + } + + async fetch({ user, repo, baseUrl }) { + // https://try.gitea.io/api/swagger#/repository/repoGetLanguages + return super.fetch({ + schema, + url: `${baseUrl}/api/v1/repos/${user}/${repo}/languages`, + httpErrors: httpErrorsFor('user or repo not found'), + }) + } + + async handle({ user, repo }, { gitea_url: baseUrl }) { + const data = await this.fetch({ + user, + repo, + baseUrl, + }) + return this.constructor.render({ languagesCount: Object.keys(data).length }) + } +} diff --git a/services/gitea/gitea-languages-count.tester.js b/services/gitea/gitea-languages-count.tester.js new file mode 100644 index 0000000000000..9d4f6a965e327 --- /dev/null +++ b/services/gitea/gitea-languages-count.tester.js @@ -0,0 +1,27 @@ +import Joi from 'joi' +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +t.create('language count (empty repo)') + .get( + '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'languages', + message: '0', + }) + +t.create('language count') + .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'languages', + message: Joi.number().integer().positive(), + }) + +t.create('language count (user or repo not found)') + .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'languages', + message: 'user or repo not found', + }) diff --git a/services/gitea/gitea-release.service.js b/services/gitea/gitea-release.service.js new file mode 100644 index 0000000000000..2188f0e135fcf --- /dev/null +++ b/services/gitea/gitea-release.service.js @@ -0,0 +1,147 @@ +import Joi from 'joi' +import { optionalUrl } from '../validators.js' +import { latest, renderVersionBadge } from '../version.js' +import { NotFound, pathParam, queryParam } from '../index.js' +import { documentation, httpErrorsFor } from './gitea-helper.js' +import GiteaBase from './gitea-base.js' + +const schema = Joi.array().items( + Joi.object({ + name: Joi.string().required(), + tag_name: Joi.string().required(), + prerelease: Joi.boolean().required(), + }), +) + +const sortEnum = ['date', 'semver'] +const displayNameEnum = ['tag', 'release'] +const dateOrderByEnum = ['created_at', 'published_at'] + +const queryParamSchema = Joi.object({ + gitea_url: optionalUrl.required(), + include_prereleases: Joi.equal(''), + sort: Joi.string() + .valid(...sortEnum) + .default('date'), + display_name: Joi.string() + .valid(...displayNameEnum) + .default('tag'), + date_order_by: Joi.string() + .valid(...dateOrderByEnum) + .default('created_at'), +}).required() + +export default class GiteaRelease extends GiteaBase { + static category = 'version' + + static route = { + base: 'gitea/v/release', + pattern: ':user/:repo', + queryParamSchema, + } + + static openApi = { + '/gitea/v/release/{user}/{repo}': { + get: { + summary: 'Gitea Release', + description: documentation, + parameters: [ + pathParam({ + name: 'user', + example: 'forgejo', + }), + pathParam({ + name: 'repo', + example: 'forgejo', + }), + queryParam({ + name: 'gitea_url', + example: 'https://codeberg.org', + required: true, + }), + queryParam({ + name: 'include_prereleases', + schema: { type: 'boolean' }, + example: null, + }), + queryParam({ + name: 'sort', + schema: { type: 'string', enum: sortEnum }, + example: 'semver', + }), + queryParam({ + name: 'display_name', + schema: { type: 'string', enum: displayNameEnum }, + example: 'release', + }), + queryParam({ + name: 'date_order_by', + schema: { type: 'string', enum: dateOrderByEnum }, + example: 'created_at', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'release' } + + async fetch({ user, repo, baseUrl }) { + // https://try.gitea.io/api/swagger#/repository/repoGetRelease + return super.fetch({ + schema, + url: `${baseUrl}/api/v1/repos/${user}/${repo}/releases`, + httpErrors: httpErrorsFor(), + }) + } + + static transform({ releases, isSemver, includePrereleases, displayName }) { + if (releases.length === 0) { + throw new NotFound({ prettyMessage: 'no releases found' }) + } + + const displayKey = displayName === 'tag' ? 'tag_name' : 'name' + + if (isSemver) { + return latest( + releases.map(t => t[displayKey]), + { pre: includePrereleases }, + ) + } + + if (!includePrereleases) { + const stableReleases = releases.filter(release => !release.prerelease) + if (stableReleases.length > 0) { + return stableReleases[0][displayKey] + } + } + + return releases[0][displayKey] + } + + async handle( + { user, repo }, + { + gitea_url: baseUrl, + include_prereleases: pre, + sort, + display_name: displayName, + date_order_by: orderBy, + }, + ) { + const isSemver = sort === 'semver' + const releases = await this.fetch({ + user, + repo, + baseUrl, + isSemver, + }) + const version = this.constructor.transform({ + releases, + isSemver, + includePrereleases: pre !== undefined, + displayName, + }) + return renderVersionBadge({ version }) + } +} diff --git a/services/gitea/gitea-release.tester.js b/services/gitea/gitea-release.tester.js new file mode 100644 index 0000000000000..1f6894e6215cf --- /dev/null +++ b/services/gitea/gitea-release.tester.js @@ -0,0 +1,40 @@ +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +t.create('Release (latest by date)') + .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') + .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' }) + +t.create('Release (latest by date, order by created_at)') + .get( + '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=created_at', + ) + .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' }) + +t.create('Release (latest by date, order by published_at)') + .get( + '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=published_at', + ) + .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' }) + +t.create('Release (latest by semver)') + .get( + '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver', + ) + .expectBadge({ label: 'release', message: 'v4.0.0', color: 'blue' }) + +t.create('Release (latest by semver pre-release)') + .get( + '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver&include_prereleases', + ) + .expectBadge({ label: 'release', message: 'v5.0.0-rc1', color: 'orange' }) + +t.create('Release (project not found)') + .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org') + .expectBadge({ label: 'release', message: 'user or repo not found' }) + +t.create('Release (no tags)') + .get( + '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ label: 'release', message: 'no releases found' })