Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GITEA] add new gitea service (release/languages) #9781

Merged
merged 13 commits into from
Dec 18, 2023
Merged
2 changes: 2 additions & 0 deletions core/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const publicConfigSchema = Joi.object({
},
restApiVersion: Joi.date().raw().required(),
},
gitea: defaultService,
gitlab: defaultService,
jira: defaultService,
jenkins: Joi.object({
Expand Down Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions doc/server-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
19 changes: 19 additions & 0 deletions services/gitea/gitea-base.js
Original file line number Diff line number Diff line change
@@ -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 }) {
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
return this._requestJson(
this.authHelper.withBearerAuthHeader({
schema,
url,
options,
httpErrors,
}),
)
}
}
48 changes: 48 additions & 0 deletions services/gitea/gitea-base.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
12 changes: 12 additions & 0 deletions services/gitea/gitea-helper.js
Original file line number Diff line number Diff line change
@@ -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 }
77 changes: 77 additions & 0 deletions services/gitea/gitea-languages-count.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}
27 changes: 27 additions & 0 deletions services/gitea/gitea-languages-count.tester.js
Original file line number Diff line number Diff line change
@@ -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',
})
147 changes: 147 additions & 0 deletions services/gitea/gitea-release.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}