From 8f1f787ceb2e14a85dccaabf53d55f14f07f4199 Mon Sep 17 00:00:00 2001
From: CanisHelix <7948640+CanisHelix@users.noreply.github.com>
Date: Mon, 18 Dec 2023 21:39:17 +0900
Subject: [PATCH] [GITEA] add new gitea service (release/languages) (#9781)

* add gitea service based on gitlab

* update gitea to use mocks

* add gitea release test

* move tests to use public repo on codeberg and fixes

* add pagination, update tests to live, set gitea_url as required

* add auth test (wip)

* fix base auth test

* fix required optionalUrl, remove default, assume semver from firstpage

* update example to use stable repository
---
 core/server/server.js                         |   2 +
 doc/server-secrets.md                         |   9 ++
 services/gitea/gitea-base.js                  |  19 +++
 services/gitea/gitea-base.spec.js             |  48 ++++++
 services/gitea/gitea-helper.js                |  12 ++
 .../gitea/gitea-languages-count.service.js    |  77 +++++++++
 .../gitea/gitea-languages-count.tester.js     |  27 ++++
 services/gitea/gitea-release.service.js       | 147 ++++++++++++++++++
 services/gitea/gitea-release.tester.js        |  40 +++++
 9 files changed, 381 insertions(+)
 create mode 100644 services/gitea/gitea-base.js
 create mode 100644 services/gitea/gitea-base.spec.js
 create mode 100644 services/gitea/gitea-helper.js
 create mode 100644 services/gitea/gitea-languages-count.service.js
 create mode 100644 services/gitea/gitea-languages-count.tester.js
 create mode 100644 services/gitea/gitea-release.service.js
 create mode 100644 services/gitea/gitea-release.tester.js

diff --git a/core/server/server.js b/core/server/server.js
index 8f92e4fd28..fb3c5ef090 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 3ce330628b..636a7dcb47 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 0000000000..2a14ba846e
--- /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 0000000000..868e964920
--- /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 0000000000..51242d819d
--- /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 0000000000..819198521b
--- /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 0000000000..9d4f6a965e
--- /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 0000000000..2188f0e135
--- /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 0000000000..1f6894e621
--- /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' })
-- 
GitLab