diff --git a/services/gitea/gitea-common-fetch.js b/services/gitea/gitea-common-fetch.js new file mode 100644 index 0000000000000..84bc11a94bde4 --- /dev/null +++ b/services/gitea/gitea-common-fetch.js @@ -0,0 +1,14 @@ +async function fetchIssue( + serviceInstance, + { user, repo, baseUrl, options, httpErrors }, +) { + return serviceInstance._request( + serviceInstance.authHelper.withBearerAuthHeader({ + url: `${baseUrl}/api/v1/repos/${user}/${repo}/issues`, + options, + httpErrors, + }), + ) +} + +export { fetchIssue } diff --git a/services/gitea/gitea-forks.service.js b/services/gitea/gitea-forks.service.js new file mode 100644 index 0000000000000..ac830c85383dc --- /dev/null +++ b/services/gitea/gitea-forks.service.js @@ -0,0 +1,76 @@ +import Joi from 'joi' +import { pathParam, queryParam } from '../index.js' +import { optionalUrl, nonNegativeInteger } from '../validators.js' +import { metric } from '../text-formatters.js' +import GiteaBase from './gitea-base.js' +import { description, httpErrorsFor } from './gitea-helper.js' + +const schema = Joi.object({ + forks_count: nonNegativeInteger, +}).required() + +const queryParamSchema = Joi.object({ + gitea_url: optionalUrl, +}).required() + +export default class GiteaForks extends GiteaBase { + static category = 'social' + + static route = { + base: 'gitea/forks', + pattern: ':user/:repo', + queryParamSchema, + } + + static openApi = { + '/gitea/forks/{user}/{repo}': { + get: { + summary: 'Gitea Forks', + description, + parameters: [ + pathParam({ + name: 'user', + example: 'gitea', + }), + pathParam({ + name: 'repo', + example: 'tea', + }), + queryParam({ + name: 'gitea_url', + example: 'https://gitea.com', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'forks', namedLogo: 'gitea' } + + static render({ baseUrl, user, repo, forkCount }) { + return { + message: metric(forkCount), + style: 'social', + color: 'blue', + link: [`${baseUrl}/${user}/${repo}`, `${baseUrl}/${user}/${repo}/forks`], + } + } + + async fetch({ user, repo, baseUrl }) { + // https://gitea.com/api/swagger#/repository + return super.fetch({ + schema, + url: `${baseUrl}/api/v1/repos/${user}/${repo}`, + httpErrors: httpErrorsFor(), + }) + } + + async handle({ user, repo }, { gitea_url: baseUrl = 'https://gitea.com' }) { + const { forks_count: forkCount } = await this.fetch({ + user, + repo, + baseUrl, + }) + return this.constructor.render({ baseUrl, user, repo, forkCount }) + } +} diff --git a/services/gitea/gitea-forks.tester.js b/services/gitea/gitea-forks.tester.js new file mode 100644 index 0000000000000..1795572231fe2 --- /dev/null +++ b/services/gitea/gitea-forks.tester.js @@ -0,0 +1,32 @@ +import { isMetric } from '../test-validators.js' +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +t.create('Forks') + .get('/gitea/tea.json') + .expectBadge({ + label: 'forks', + message: isMetric, + color: 'blue', + link: ['https://gitea.com/gitea/tea', 'https://gitea.com/gitea/tea/forks'], + }) + +t.create('Forks (self-managed)') + .get('/Codeberg/forgejo.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'forks', + message: isMetric, + color: 'blue', + link: [ + 'https://codeberg.org/Codeberg/forgejo', + 'https://codeberg.org/Codeberg/forgejo/forks', + ], + }) + +t.create('Forks (project not found)') + .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'forks', + message: 'user or repo not found', + }) diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js index 684f41d6bf44f..56bba95207891 100644 --- a/services/gitea/gitea-helper.js +++ b/services/gitea/gitea-helper.js @@ -1,3 +1,5 @@ +import { metric } from '../text-formatters.js' + const description = ` By default this badge looks for repositories on [gitea.com](https://gitea.com). To specify another instance like [codeberg](https://codeberg.org/), [forgejo](https://forgejo.org/) or a self-hosted instance, use the \`gitea_url\` query param. @@ -10,4 +12,24 @@ function httpErrorsFor() { } } -export { description, httpErrorsFor } +function renderIssue({ variant, labels, defaultBadgeData, count }) { + const state = variant.split('-')[0] + const raw = variant.endsWith('-raw') + const isMultiLabel = labels && labels.includes(',') + const labelText = labels ? `${isMultiLabel ? `${labels}` : labels} ` : '' + + let labelPrefix = '' + let messageSuffix = '' + if (raw) { + labelPrefix = `${state} ` + } else { + messageSuffix = state + } + return { + label: `${labelPrefix}${labelText}${defaultBadgeData.label}`, + message: `${metric(count)}${messageSuffix ? ' ' : ''}${messageSuffix}`, + color: count > 0 ? 'yellow' : 'brightgreen', + } +} + +export { description, httpErrorsFor, renderIssue } diff --git a/services/gitea/gitea-issues.service.js b/services/gitea/gitea-issues.service.js new file mode 100644 index 0000000000000..cac75aaea869b --- /dev/null +++ b/services/gitea/gitea-issues.service.js @@ -0,0 +1,96 @@ +import Joi from 'joi' +import { pathParam, queryParam } from '../index.js' +import { optionalUrl, nonNegativeInteger } from '../validators.js' +import { fetchIssue } from './gitea-common-fetch.js' +import { description, httpErrorsFor, renderIssue } from './gitea-helper.js' +import GiteaBase from './gitea-base.js' + +const schema = Joi.object({ 'x-total-count': nonNegativeInteger }).required() + +const queryParamSchema = Joi.object({ + labels: Joi.string(), + gitea_url: optionalUrl, +}).required() + +export default class GiteaIssues extends GiteaBase { + static category = 'issue-tracking' + + static route = { + base: 'gitea/issues', + pattern: + ':variant(all|all-raw|open|open-raw|closed|closed-raw)/:user/:repo+', + queryParamSchema, + } + + static openApi = { + '/gitea/issues/{variant}/{user}/{repo}': { + get: { + summary: 'Gitea Issues', + description, + parameters: [ + pathParam({ + name: 'variant', + example: 'all', + schema: { type: 'string', enum: this.getEnum('variant') }, + }), + pathParam({ + name: 'user', + example: 'gitea', + }), + pathParam({ + name: 'repo', + example: 'tea', + }), + queryParam({ + name: 'gitea_url', + example: 'https://gitea.com', + }), + queryParam({ + name: 'labels', + example: 'test,failure::new', + description: + 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'issues', color: 'informational' } + async handle( + { variant, user, repo }, + { gitea_url: baseUrl = 'https://gitea.com', labels }, + ) { + const options = { + searchParams: { + page: '1', + limit: '1', + type: 'issues', + state: variant.replace('-raw', ''), + }, + } + if (labels) { + options.searchParams.labels = labels + } + + const { res } = await fetchIssue(this, { + user, + repo, + baseUrl, + options, + httpErrors: httpErrorsFor(), + }) + + const data = this.constructor._validate(res.headers, schema) + // The total number of issues is in the `x-total-count` field in the headers. + // Pull requests are an issue of type pulls + // https://gitea.com/api/swagger#/issue + const count = data['x-total-count'] + return renderIssue({ + variant, + labels, + defaultBadgeData: this.constructor.defaultBadgeData, + count, + }) + } +} diff --git a/services/gitea/gitea-issues.tester.js b/services/gitea/gitea-issues.tester.js new file mode 100644 index 0000000000000..cfbf71133548e --- /dev/null +++ b/services/gitea/gitea-issues.tester.js @@ -0,0 +1,167 @@ +import { createServiceTester } from '../tester.js' +import { + isMetric, + isMetricOpenIssues, + isMetricClosedIssues, + isMetricWithPattern, +} from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Issues (project not found)') + .get('/open/CanisHelix/do-not-exist.json') + .expectBadge({ + label: 'issues', + message: 'user or repo not found', + }) + +/** + * Opened issue number case + */ +t.create('Opened issues') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'issues', + message: isMetricOpenIssues, + }) + +t.create('Open issues raw') + .get( + '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'open issues', + message: isMetric, + }) + +t.create('Open issues by label is > zero') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question', + ) + .expectBadge({ + label: 'question issues', + message: isMetricOpenIssues, + }) + +t.create('Open issues by multi-word label is > zero') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement', + ) + .expectBadge({ + label: 'question,enhancement issues', + message: isMetricOpenIssues, + }) + +t.create('Open issues by label (raw)') + .get( + '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question', + ) + .expectBadge({ + label: 'open question issues', + message: isMetric, + }) + +t.create('Opened issues by Scoped labels') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement/new', + ) + .expectBadge({ + label: 'question,enhancement/new issues', + message: isMetricOpenIssues, + }) + +/** + * Closed issue number case + */ +t.create('Closed issues') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'issues', + message: isMetricClosedIssues, + }) + +t.create('Closed issues raw') + .get( + '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'closed issues', + message: isMetric, + }) + +t.create('Closed issues by label is > zero') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug', + ) + .expectBadge({ + label: 'bug issues', + message: isMetricClosedIssues, + }) + +t.create('Closed issues by multi-word label is > zero') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug,good%20first%20issue', + ) + .expectBadge({ + label: 'bug,good first issue issues', + message: isMetricClosedIssues, + }) + +t.create('Closed issues by label (raw)') + .get( + '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug', + ) + .expectBadge({ + label: 'closed bug issues', + message: isMetric, + }) + +/** + * All issue number case + */ +t.create('All issues') + .get('/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'issues', + message: isMetricWithPattern(/ all/), + }) + +t.create('All issues raw') + .get( + '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'all issues', + message: isMetric, + }) + +t.create('All issues by label is > zero') + .get( + '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question', + ) + .expectBadge({ + label: 'question issues', + message: isMetricWithPattern(/ all/), + }) + +t.create('All issues by multi-word label is > zero') + .get( + '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement', + ) + .expectBadge({ + label: 'question,enhancement issues', + message: isMetricWithPattern(/ all/), + }) + +t.create('All issues by label (raw)') + .get( + '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question', + ) + .expectBadge({ + label: 'all question issues', + message: isMetric, + }) diff --git a/services/gitea/gitea-pull-requests.service.js b/services/gitea/gitea-pull-requests.service.js new file mode 100644 index 0000000000000..5b00f2485e218 --- /dev/null +++ b/services/gitea/gitea-pull-requests.service.js @@ -0,0 +1,97 @@ +import Joi from 'joi' +import { pathParam, queryParam } from '../index.js' +import { optionalUrl, nonNegativeInteger } from '../validators.js' +import { fetchIssue } from './gitea-common-fetch.js' +import { description, httpErrorsFor, renderIssue } from './gitea-helper.js' +import GiteaBase from './gitea-base.js' + +const schema = Joi.object({ 'x-total-count': nonNegativeInteger }).required() + +const queryParamSchema = Joi.object({ + labels: Joi.string(), + gitea_url: optionalUrl, +}).required() + +export default class GiteaPullRequests extends GiteaBase { + static category = 'issue-tracking' + + static route = { + base: 'gitea/pull-requests', + pattern: + ':variant(all|all-raw|open|open-raw|closed|closed-raw)/:user/:repo+', + queryParamSchema, + } + + static openApi = { + '/gitea/pull-requests/{variant}/{user}/{repo}': { + get: { + summary: 'Gitea Pull Requests', + description, + parameters: [ + pathParam({ + name: 'variant', + example: 'all', + schema: { type: 'string', enum: this.getEnum('variant') }, + }), + pathParam({ + name: 'user', + example: 'gitea', + }), + pathParam({ + name: 'repo', + example: 'tea', + }), + queryParam({ + name: 'gitea_url', + example: 'https://gitea.com', + }), + queryParam({ + name: 'labels', + example: 'test,failure::new', + description: + 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'pull requests', color: 'informational' } + + async handle( + { variant, user, repo }, + { gitea_url: baseUrl = 'https://gitea.com', labels }, + ) { + const options = { + searchParams: { + page: '1', + limit: '1', + type: 'pulls', + state: variant.replace('-raw', ''), + }, + } + if (labels) { + options.searchParams.labels = labels + } + + const { res } = await fetchIssue(this, { + user, + repo, + baseUrl, + options, + httpErrors: httpErrorsFor(), + }) + + const data = this.constructor._validate(res.headers, schema) + // The total number of issues is in the `x-total-count` field in the headers. + // Pull requests are an issue of type pulls + // https://gitea.com/api/swagger#/issue + const count = data['x-total-count'] + return renderIssue({ + variant, + labels, + defaultBadgeData: this.constructor.defaultBadgeData, + count, + }) + } +} diff --git a/services/gitea/gitea-pull-requests.tester.js b/services/gitea/gitea-pull-requests.tester.js new file mode 100644 index 0000000000000..a2849fbc7e9aa --- /dev/null +++ b/services/gitea/gitea-pull-requests.tester.js @@ -0,0 +1,167 @@ +import { createServiceTester } from '../tester.js' +import { + isMetric, + isMetricOpenIssues, + isMetricClosedIssues, + isMetricWithPattern, +} from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Pulls (project not found)') + .get('/open/CanisHelix/do-not-exist.json') + .expectBadge({ + label: 'pull requests', + message: 'user or repo not found', + }) + +/** + * Opened pulls number case + */ +t.create('Opened pulls') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'pull requests', + message: isMetricOpenIssues, + }) + +t.create('Open pulls raw') + .get( + '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'open pull requests', + message: isMetric, + }) + +t.create('Open pulls by label is > zero') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream', + ) + .expectBadge({ + label: 'upstream pull requests', + message: isMetricOpenIssues, + }) + +t.create('Open pulls by multi-word label is > zero') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream,enhancement', + ) + .expectBadge({ + label: 'upstream,enhancement pull requests', + message: isMetricOpenIssues, + }) + +t.create('Open pulls by label (raw)') + .get( + '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream', + ) + .expectBadge({ + label: 'open upstream pull requests', + message: isMetric, + }) + +t.create('Opened pulls by Scoped label') + .get( + '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=failure/new', + ) + .expectBadge({ + label: 'failure/new pull requests', + message: isMetricOpenIssues, + }) + +/** + * Closed pulls number case + */ +t.create('Closed pulls') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'pull requests', + message: isMetricClosedIssues, + }) + +t.create('Closed pulls raw') + .get( + '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'closed pull requests', + message: isMetric, + }) + +t.create('Closed pulls by label is > zero') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug', + ) + .expectBadge({ + label: 'bug pull requests', + message: isMetricClosedIssues, + }) + +t.create('Closed pulls by multi-word label is > zero') + .get( + '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug,good%20first%20issue', + ) + .expectBadge({ + label: 'bug,good first issue pull requests', + message: isMetricClosedIssues, + }) + +t.create('Closed pulls by label (raw)') + .get( + '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug', + ) + .expectBadge({ + label: 'closed bug pull requests', + message: isMetric, + }) + +/** + * All pulls number case + */ +t.create('All pulls') + .get('/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'pull requests', + message: isMetricWithPattern(/ all/), + }) + +t.create('All pulls raw') + .get( + '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org', + ) + .expectBadge({ + label: 'all pull requests', + message: isMetric, + }) + +t.create('All pulls by label is > zero') + .get( + '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream', + ) + .expectBadge({ + label: 'upstream pull requests', + message: isMetricWithPattern(/ all/), + }) + +t.create('All pulls by multi-word label is > zero') + .get( + '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream,enhancement', + ) + .expectBadge({ + label: 'upstream,enhancement pull requests', + message: isMetricWithPattern(/ all/), + }) + +t.create('All pulls by label (raw)') + .get( + '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream', + ) + .expectBadge({ + label: 'all upstream pull requests', + message: isMetric, + }) diff --git a/services/gitea/gitea-stars.service.js b/services/gitea/gitea-stars.service.js new file mode 100644 index 0000000000000..43732b4068e59 --- /dev/null +++ b/services/gitea/gitea-stars.service.js @@ -0,0 +1,76 @@ +import Joi from 'joi' +import { pathParam, queryParam } from '../index.js' +import { optionalUrl, nonNegativeInteger } from '../validators.js' +import { metric } from '../text-formatters.js' +import GiteaBase from './gitea-base.js' +import { description, httpErrorsFor } from './gitea-helper.js' + +const schema = Joi.object({ + stars_count: nonNegativeInteger, +}).required() + +const queryParamSchema = Joi.object({ + gitea_url: optionalUrl, +}).required() + +export default class GiteaStars extends GiteaBase { + static category = 'social' + + static route = { + base: 'gitea/stars', + pattern: ':user/:repo', + queryParamSchema, + } + + static openApi = { + '/gitea/stars/{user}/{repo}': { + get: { + summary: 'Gitea Stars', + description, + parameters: [ + pathParam({ + name: 'user', + example: 'gitea', + }), + pathParam({ + name: 'repo', + example: 'tea', + }), + queryParam({ + name: 'gitea_url', + example: 'https://gitea.com', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'stars', namedLogo: 'gitea' } + + static render({ baseUrl, user, repo, starCount }) { + return { + message: metric(starCount), + style: 'social', + color: 'blue', + link: [`${baseUrl}/${user}/${repo}`, `${baseUrl}/${user}/${repo}/stars`], + } + } + + async fetch({ user, repo, baseUrl }) { + // https://gitea.com/api/swagger#/repository + return super.fetch({ + schema, + url: `${baseUrl}/api/v1/repos/${user}/${repo}`, + httpErrors: httpErrorsFor(), + }) + } + + async handle({ user, repo }, { gitea_url: baseUrl = 'https://gitea.com' }) { + const { stars_count: starCount } = await this.fetch({ + user, + repo, + baseUrl, + }) + return this.constructor.render({ baseUrl, user, repo, starCount }) + } +} diff --git a/services/gitea/gitea-stars.tester.js b/services/gitea/gitea-stars.tester.js new file mode 100644 index 0000000000000..bec444bfa74e3 --- /dev/null +++ b/services/gitea/gitea-stars.tester.js @@ -0,0 +1,32 @@ +import { isMetric } from '../test-validators.js' +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +t.create('Stars') + .get('/gitea/tea.json') + .expectBadge({ + label: 'stars', + message: isMetric, + color: 'blue', + link: ['https://gitea.com/gitea/tea', 'https://gitea.com/gitea/tea/stars'], + }) + +t.create('Stars (self-managed)') + .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'stars', + message: isMetric, + color: 'blue', + link: [ + 'https://codeberg.org/CanisHelix/shields-badge-test', + 'https://codeberg.org/CanisHelix/shields-badge-test/stars', + ], + }) + +t.create('Stars (project not found)') + .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org') + .expectBadge({ + label: 'stars', + message: 'user or repo not found', + })