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
32 changes: 32 additions & 0 deletions services/gitea/gitea-base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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,
}),
)
}

async fetchPage({ page, requestParams, schema }) {
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
const { res, buffer } = await this._request(
this.authHelper.withBearerAuthHeader({
...requestParams,
...{ options: { searchParams: { page } } },
}),
)

const json = this._parseJson(buffer)
const data = this.constructor._validate(json, schema)
return { res, data }
}
}
13 changes: 13 additions & 0 deletions services/gitea/gitea-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const documentation = `
Note that only internet-accessible Gitea compatible instances are supported, for example
[https://codeberg.org](https://codeberg.org).
`

function httpErrorsFor(notFoundMessage = 'user or repo not found') {
return {
401: notFoundMessage,
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
404: notFoundMessage,
}
}

export { documentation, httpErrorsFor }
79 changes: 79 additions & 0 deletions services/gitea/gitea-languages-count.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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,
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
}).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: 'go-gitea',
}),
pathParam({
name: 'repo',
example: 'gitea',
}),
queryParam({
name: 'gitea_url',
example: 'https://codeberg.org',
}),
],
},
},
}

static defaultBadgeData = { label: 'languages' }

static render({ languagesCount }) {
return {
message: metric(languagesCount),
color: 'blue',
}
}

async fetch({ user, repo, baseUrl }) {
// https://codeberg.org/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 = 'https://codeberg.org' },
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
) {
const data = await this.fetch({
user,
repo,
baseUrl,
})
return this.constructor.render({ languagesCount: Object.keys(data).length })
}
}
37 changes: 37 additions & 0 deletions services/gitea/gitea-languages-count.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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')
.expectBadge({
label: 'languages',
message: '0',
})

t.create('language count')
.get('/CanisHelix/shields-badge-test.json')
.expectBadge({
label: 'languages',
message: Joi.number().integer().positive(),
})

t.create('language count (self-managed)')
.get('/go-gitea/gitea.json?gitea_url=https://gitea.example.com')
.intercept(nock =>
nock('https://gitea.example.com/')
.get('/api/v1/repos/go-gitea/gitea/languages')
.reply(200, { CPP: 500, SQL: 25 }),
)
.expectBadge({
label: 'languages',
message: Joi.number().integer().positive(),
})

t.create('language count (user or repo not found)')
.get('/CanisHelix/does-not-exist.json')
.expectBadge({
label: 'languages',
message: 'user or repo not found',
})
145 changes: 145 additions & 0 deletions services/gitea/gitea-release.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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,
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
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: 'go-gitea',
}),
pathParam({
name: 'repo',
example: 'gitea',
}),
queryParam({
name: 'gitea_url',
example: 'https://codeberg.org',
}),
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CanisHelix late to the game on this one, but I happened to stumble upon this file. It doesn't look this query parameter or the corresponding orderBy parameter lower down is used, am I missing something?

schema: { type: 'string', enum: dateOrderByEnum },
example: 'created_at',
}),
],
},
},
}

static defaultBadgeData = { label: 'release' }

async fetch({ user, repo, baseUrl }) {
// https://codeberg.org/api/swagger#/repository/repoGetRelease
return super.fetch({
schema,
url: `${baseUrl}/api/v1/repos/${user}/${repo}/releases`,
httpErrors: httpErrorsFor('user or repo not found'),
})
}

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 = 'https://codeberg.org',
CanisHelix marked this conversation as resolved.
Show resolved Hide resolved
include_prereleases: pre,
sort,
display_name: displayName,
date_order_by: orderBy,
},
) {
const isSemver = sort === 'semver'
const releases = await this.fetch({
user,
repo,
baseUrl,
})
const version = this.constructor.transform({
releases,
isSemver,
includePrereleases: pre !== undefined,
displayName,
})
return renderVersionBadge({ version })
}
}
Loading
Loading