From d3da0bcef0fec9657fdf2ada17408970b64f691f Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Thu, 31 Aug 2023 11:41:39 +0200
Subject: [PATCH] feat(changelog): support gitea changelogs (#24144)

Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
---
 docs/usage/configuration-options.md           |   2 +-
 lib/constants/platforms.ts                    |   1 +
 .../datasource/gitea-releases/schema.ts       |   2 +
 lib/modules/platform/gitea/schema.ts          |  12 +
 lib/util/common.spec.ts                       |  11 +-
 lib/util/common.ts                            |  14 +-
 .../repository/update/pr/changelog/api.ts     |   2 +
 .../update/pr/changelog/gitea/index.spec.ts   | 497 ++++++++++++++++++
 .../update/pr/changelog/gitea/index.ts        |  92 ++++
 .../update/pr/changelog/gitea/source.ts       |  25 +
 .../update/pr/changelog/release-notes.ts      |   9 +
 .../repository/update/pr/changelog/source.ts  |  15 +-
 .../repository/update/pr/changelog/types.ts   |   4 +-
 13 files changed, 675 insertions(+), 11 deletions(-)
 create mode 100644 lib/modules/platform/gitea/schema.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/gitea/index.spec.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/gitea/index.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/gitea/source.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 328c6be951..b0fbe64344 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -2490,7 +2490,7 @@ Example setting source URL for package "dummy":
 
 <!-- prettier-ignore -->
 !!! note
-    Renovate can fetch changelogs from GitHub and GitLab platforms only, and setting the URL to an unsupported host/platform type won't change that.
+    Renovate can fetch changelogs from Bitbucket, Gitea (Forgejo), GitHub and GitLab platforms only, and setting the URL to an unsupported host/platform type won't change that.
 
 ### replacementName
 
diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts
index 8ea80fefac..4c7ef1fc51 100644
--- a/lib/constants/platforms.ts
+++ b/lib/constants/platforms.ts
@@ -10,6 +10,7 @@ export type PlatformId =
 
 export const GITEA_API_USING_HOST_TYPES = [
   'gitea',
+  'gitea-changelog',
   'gitea-releases',
   'gitea-tags',
 ];
diff --git a/lib/modules/datasource/gitea-releases/schema.ts b/lib/modules/datasource/gitea-releases/schema.ts
index 93bd77ac21..fe57281c67 100644
--- a/lib/modules/datasource/gitea-releases/schema.ts
+++ b/lib/modules/datasource/gitea-releases/schema.ts
@@ -1,7 +1,9 @@
 import { z } from 'zod';
 
 export const ReleaseSchema = z.object({
+  name: z.string(),
   tag_name: z.string(),
+  body: z.string(),
   prerelease: z.boolean(),
   published_at: z.string().datetime({ offset: true }),
 });
diff --git a/lib/modules/platform/gitea/schema.ts b/lib/modules/platform/gitea/schema.ts
new file mode 100644
index 0000000000..13d1a60d4d
--- /dev/null
+++ b/lib/modules/platform/gitea/schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const ContentsResponseSchema = z.object({
+  name: z.string(),
+  path: z.string(),
+  type: z.union([z.literal('file'), z.literal('dir')]),
+  content: z.string().nullable(),
+});
+
+export type ContentsResponse = z.infer<typeof ContentsResponseSchema>;
+
+export const ContentsListResponseSchema = z.array(ContentsResponseSchema);
diff --git a/lib/util/common.spec.ts b/lib/util/common.spec.ts
index 4ed8919ea9..8505ad2744 100644
--- a/lib/util/common.spec.ts
+++ b/lib/util/common.spec.ts
@@ -13,6 +13,8 @@ describe('util/common', () => {
       ${'https://myorg.visualstudio.com/my-project/_git/my-repo.git'}        | ${'azure'}
       ${'https://bitbucket.org/some-org/some-repo'}                          | ${'bitbucket'}
       ${'https://bitbucket.com/some-org/some-repo'}                          | ${'bitbucket'}
+      ${'https://gitea.com/semantic-release/gitlab'}                         | ${'gitea'}
+      ${'https://forgejo.example.com/semantic-release/gitlab'}               | ${'gitea'}
       ${'https://github.com/semantic-release/gitlab'}                        | ${'github'}
       ${'https://github-enterprise.example.com/chalk/chalk'}                 | ${'github'}
       ${'https://gitlab.com/chalk/chalk'}                                    | ${'gitlab'}
@@ -38,17 +40,24 @@ describe('util/common', () => {
         hostType: 'gitlab-changelog',
         matchHost: 'gl.example.com',
       });
+      hostRules.add({
+        hostType: 'unknown',
+        matchHost: 'f.example.com',
+      });
 
       expect(detectPlatform('https://bb.example.com/chalk/chalk')).toBe(
         'bitbucket'
       );
+      expect(detectPlatform('https://gt.example.com/chalk/chalk')).toBe(
+        'gitea'
+      );
       expect(detectPlatform('https://gh.example.com/chalk/chalk')).toBe(
         'github'
       );
       expect(detectPlatform('https://gl.example.com/chalk/chalk')).toBe(
         'gitlab'
       );
-      expect(detectPlatform('https://gt.example.com/chalk/chalk')).toBeNull();
+      expect(detectPlatform('https://f.example.com/chalk/chalk')).toBeNull();
     });
   });
 });
diff --git a/lib/util/common.ts b/lib/util/common.ts
index 7693906d65..df7db5ab4a 100644
--- a/lib/util/common.ts
+++ b/lib/util/common.ts
@@ -1,5 +1,6 @@
 import {
   BITBUCKET_API_USING_HOST_TYPES,
+  GITEA_API_USING_HOST_TYPES,
   GITHUB_API_USING_HOST_TYPES,
   GITLAB_API_USING_HOST_TYPES,
 } from '../constants';
@@ -14,7 +15,7 @@ import { parseUrl } from './url';
  */
 export function detectPlatform(
   url: string
-): 'azure' | 'bitbucket' | 'github' | 'gitlab' | null {
+): 'azure' | 'bitbucket' | 'gitea' | 'github' | 'gitlab' | null {
   const { hostname } = parseUrl(url) ?? {};
   if (hostname === 'dev.azure.com' || hostname?.endsWith('.visualstudio.com')) {
     return 'azure';
@@ -22,6 +23,14 @@ export function detectPlatform(
   if (hostname === 'bitbucket.org' || hostname?.includes('bitbucket')) {
     return 'bitbucket';
   }
+  if (
+    hostname &&
+    (['gitea.com', 'codeberg.org'].includes(hostname) ||
+      hostname.includes('gitea') ||
+      hostname.includes('forgejo'))
+  ) {
+    return 'gitea';
+  }
   if (hostname === 'github.com' || hostname?.includes('github')) {
     return 'github';
   }
@@ -38,6 +47,9 @@ export function detectPlatform(
   if (BITBUCKET_API_USING_HOST_TYPES.includes(hostType)) {
     return 'bitbucket';
   }
+  if (GITEA_API_USING_HOST_TYPES.includes(hostType)) {
+    return 'gitea';
+  }
   if (GITHUB_API_USING_HOST_TYPES.includes(hostType)) {
     return 'github';
   }
diff --git a/lib/workers/repository/update/pr/changelog/api.ts b/lib/workers/repository/update/pr/changelog/api.ts
index 3abff28193..d4d0c9b9b1 100644
--- a/lib/workers/repository/update/pr/changelog/api.ts
+++ b/lib/workers/repository/update/pr/changelog/api.ts
@@ -1,4 +1,5 @@
 import { BitbucketChangeLogSource } from './bitbucket/source';
+import { GiteaChangeLogSource } from './gitea/source';
 import { GitHubChangeLogSource } from './github/source';
 import { GitLabChangeLogSource } from './gitlab/source';
 import type { ChangeLogSource } from './source';
@@ -7,5 +8,6 @@ const api = new Map<string, ChangeLogSource>();
 export default api;
 
 api.set('bitbucket', new BitbucketChangeLogSource());
+api.set('gitea', new GiteaChangeLogSource());
 api.set('github', new GitHubChangeLogSource());
 api.set('gitlab', new GitLabChangeLogSource());
diff --git a/lib/workers/repository/update/pr/changelog/gitea/index.spec.ts b/lib/workers/repository/update/pr/changelog/gitea/index.spec.ts
new file mode 100644
index 0000000000..bff07ba853
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/gitea/index.spec.ts
@@ -0,0 +1,497 @@
+import { getChangeLogJSON } from '..';
+import * as httpMock from '../../../../../../../test/http-mock';
+import { partial } from '../../../../../../../test/util';
+import * as semverVersioning from '../../../../../../modules/versioning/semver';
+import * as hostRules from '../../../../../../util/host-rules';
+import { toBase64 } from '../../../../../../util/string';
+import type { BranchUpgradeConfig } from '../../../../../types';
+import { GiteaChangeLogSource } from '../gitea/source';
+import { getReleaseNotesMd } from '.';
+
+const upgrade = partial<BranchUpgradeConfig>({
+  manager: 'some-manager',
+  branchName: '',
+  endpoint: 'https://gitea.com/api/v1/',
+  packageName: 'renovate',
+  versioning: semverVersioning.id,
+  currentVersion: '5.2.0',
+  newVersion: '5.7.0',
+  sourceUrl: 'https://gitea.com/meno/dropzone/',
+  releases: [
+    // TODO: test gitRef
+    { version: '5.2.0' },
+    {
+      version: '5.4.0',
+      releaseTimestamp: '2018-08-24T14:23:00.000Z',
+    },
+    { version: '5.5.0', gitRef: 'eba303e91c930292198b2fc57040145682162a1b' },
+    { version: '5.6.0', releaseTimestamp: '2020-02-13T15:37:00.000Z' },
+    { version: '5.6.1' },
+  ],
+});
+
+const matchHost = 'https://gitea.com/';
+
+const changelogSource = new GiteaChangeLogSource();
+
+describe('workers/repository/update/pr/changelog/gitea/index', () => {
+  beforeAll(() => {
+    // TODO: why?
+    delete process.env.GITHUB_ENDPOINT;
+  });
+
+  describe('getChangeLogJSON', () => {
+    beforeEach(() => {
+      hostRules.clear();
+      hostRules.add({
+        hostType: 'gitea',
+        matchHost,
+        token: 'abc',
+      });
+    });
+
+    it('returns null if @types', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          currentVersion: undefined,
+        })
+      ).toBeNull();
+    });
+
+    it('returns null if currentVersion equals newVersion', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          currentVersion: '1.0.0',
+          newVersion: '1.0.0',
+        })
+      ).toBeNull();
+    });
+
+    it('skips invalid repos', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          sourceUrl: 'https://gitea.com/help',
+        })
+      ).toBeNull();
+    });
+
+    it('works without gitea', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+        })
+      ).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://gitea.com/api/v1/',
+          baseUrl: 'https://gitea.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://gitea.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          { version: '5.6.1' },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+      // TODO: find right mocks
+      httpMock.clear(false);
+    });
+
+    it('uses gitea tags', async () => {
+      httpMock
+        .scope(matchHost)
+        .get('/api/v1/repos/meno/dropzone/tags')
+        .times(8)
+        .reply(200, [
+          {
+            name: 'v5.2.0',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+          {
+            name: 'v5.4.0',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+          {
+            name: 'v5.5.0',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+          {
+            name: 'v5.6.0',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+          {
+            name: 'v5.6.1',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+          {
+            name: 'v5.7.0',
+            commit: { sha: 'abc', created: '2023-07-27T06:19:02Z' },
+          },
+        ])
+        .get('/api/v1/repos/meno/dropzone/contents')
+        .times(4)
+        .reply(200, [])
+        .get('/api/v1/repos/meno/dropzone/releases?draft=false')
+        .times(4)
+        .reply(200, [
+          {
+            name: 'v5.2.0',
+            tag_name: 'v5.2.0',
+            body: '',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+          {
+            name: 'v5.4.0',
+            tag_name: 'v5.4.0',
+            body: '',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+          {
+            name: 'v5.5.0',
+            tag_name: 'v5.5.0',
+            body: '',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+          {
+            name: 'v5.6.0',
+            tag_name: 'v5.6.0',
+            body: '',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+          {
+            name: '5.6.1 - Some feature',
+            tag_name: 'v5.6.1',
+            body: 'some changes',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+          {
+            name: 'v5.7.0',
+            tag_name: 'v5.7.0',
+            body: '',
+            prerelease: false,
+            published_at: '2023-07-27T06:19:02Z',
+          },
+        ]);
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+        })
+      ).toMatchObject({
+        hasReleaseNotes: true,
+        project: {
+          apiBaseUrl: 'https://gitea.com/api/v1/',
+          baseUrl: 'https://gitea.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://gitea.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          {
+            version: '5.6.1',
+            releaseNotes: {
+              body: 'some changes\n',
+              name: '5.6.1 - Some feature',
+              notesSourceUrl:
+                'https://gitea.com/api/v1/repos/meno/dropzone/releases',
+              tag: 'v5.6.1',
+              url: 'https://gitea.com/api/v1/repos/meno/dropzone/releases/tag/v5.6.1',
+            },
+          },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+    });
+
+    it('handles empty gitea tags response', async () => {
+      httpMock
+        .scope(matchHost)
+        .get('/api/v1/repos/meno/dropzone/tags')
+        .times(8)
+        .reply(200, [])
+        .get('/api/v1/repos/meno/dropzone/contents')
+        .times(4)
+        .reply(200, [])
+        .get('/api/v1/repos/meno/dropzone/releases?draft=false')
+        .times(4)
+        .reply(200, []);
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+        })
+      ).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://gitea.com/api/v1/',
+          baseUrl: 'https://gitea.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://gitea.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          { version: '5.6.1' },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+    });
+
+    it('uses gitea tags with error', async () => {
+      httpMock
+        .scope(matchHost)
+        .get('/api/v1/repos/meno/dropzone/tags')
+        .times(8)
+        .replyWithError('Unknown gitea Repo')
+        .get('/api/v1/repos/meno/dropzone/contents')
+        .times(4)
+        .reply(200, [])
+        .get('/api/v1/repos/meno/dropzone/releases?draft=false')
+        .times(4)
+        .reply(200, []);
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+        })
+      ).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://gitea.com/api/v1/',
+          baseUrl: 'https://gitea.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://gitea.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          { version: '5.6.1' },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+    });
+
+    it('handles no sourceUrl', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          sourceUrl: undefined,
+        })
+      ).toBeNull();
+    });
+
+    it('handles invalid sourceUrl', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          sourceUrl: 'http://example.com',
+        })
+      ).toBeNull();
+    });
+
+    it('handles no releases', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          releases: [],
+        })
+      ).toBeNull();
+    });
+
+    it('handles not enough releases', async () => {
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          releases: [{ version: '0.9.0' }],
+        })
+      ).toBeNull();
+    });
+
+    it('supports gitea enterprise and gitea enterprise changelog', async () => {
+      hostRules.add({
+        hostType: 'gitea',
+        matchHost: 'https://gitea-enterprise.example.com/',
+        token: 'abc',
+      });
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          sourceUrl: 'https://gitea-enterprise.example.com/meno/dropzone/',
+          endpoint: 'https://gitea-enterprise.example.com/',
+        })
+      ).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://gitea-enterprise.example.com/api/v1/',
+          baseUrl: 'https://gitea-enterprise.example.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://gitea-enterprise.example.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          { version: '5.6.1' },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+
+      // TODO: find right mocks
+      httpMock.clear(false);
+    });
+
+    it('supports self-hosted gitea changelog', async () => {
+      httpMock.scope('https://git.test.com').persist().get(/.*/).reply(200, []);
+      hostRules.add({
+        hostType: 'gitea',
+        matchHost: 'https://git.test.com/',
+        token: 'abc',
+      });
+      expect(
+        await getChangeLogJSON({
+          ...upgrade,
+          platform: 'gitea',
+          sourceUrl: 'https://git.test.com/meno/dropzone/',
+          endpoint: 'https://git.test.com/api/v1/',
+        })
+      ).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://git.test.com/api/v1/',
+          baseUrl: 'https://git.test.com/',
+          packageName: 'renovate',
+          repository: 'meno/dropzone',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://git.test.com/meno/dropzone/',
+          type: 'gitea',
+        },
+        versions: [
+          { version: '5.6.1' },
+          { version: '5.6.0' },
+          { version: '5.5.0' },
+          { version: '5.4.0' },
+        ],
+      });
+
+      // TODO: find right mocks
+      httpMock.clear(false);
+    });
+
+    it('supports overwriting sourceUrl for self-hosted gitea changelog', async () => {
+      httpMock.scope('https://git.test.com').persist().get(/.*/).reply(200, []);
+      const sourceUrl = 'https://git.test.com/meno/dropzone/';
+      const replacementSourceUrl =
+        'https://git.test.com/replacement/sourceurl/';
+      const config = {
+        ...upgrade,
+        platform: 'gitea',
+        endpoint: 'https://git.test.com/api/v1/',
+        sourceUrl,
+        customChangelogUrl: replacementSourceUrl,
+      };
+      hostRules.add({
+        hostType: 'gitea',
+        matchHost: 'https://git.test.com/',
+        token: 'abc',
+      });
+      expect(await getChangeLogJSON(config)).toMatchObject({
+        hasReleaseNotes: false,
+        project: {
+          apiBaseUrl: 'https://git.test.com/api/v1/',
+          baseUrl: 'https://git.test.com/',
+          packageName: 'renovate',
+          repository: 'replacement/sourceurl',
+          sourceDirectory: undefined,
+          sourceUrl: 'https://git.test.com/replacement/sourceurl/',
+          type: 'gitea',
+        },
+      });
+      expect(config.sourceUrl).toBe(sourceUrl); // ensure unmodified function argument
+
+      // TODO: find right mocks
+      httpMock.clear(false);
+    });
+  });
+
+  describe('hasValidRepository', () => {
+    it('handles invalid repository', () => {
+      expect(changelogSource.hasValidRepository('foo')).toBeFalse();
+      expect(changelogSource.hasValidRepository('some/repo/name')).toBeFalse();
+    });
+
+    it('handles valid repository', () => {
+      expect(changelogSource.hasValidRepository('some/repo')).toBeTrue();
+    });
+  });
+
+  describe('getAllTags', () => {
+    it('handles endpoint', async () => {
+      httpMock
+        .scope('https://git.test.com/')
+        .get('/api/v1/repos/some/repo/tags')
+        .reply(200, [
+          { name: 'v5.2.0' },
+          { name: 'v5.4.0' },
+          { name: 'v5.5.0' },
+        ]);
+      expect(
+        await changelogSource.getAllTags('https://git.test.com/', 'some/repo')
+      ).toEqual([]);
+    });
+  });
+
+  describe('getReleaseNotesMd', () => {
+    it('works', async () => {
+      httpMock
+        .scope('https://git.test.com/')
+        .get('/api/v1/repos/some/repo/contents/charts/some')
+        .reply(200, [
+          {
+            name: 'CHANGELOG.md',
+            path: 'charts/some/CHANGELOG.md',
+            type: 'file',
+            content: null,
+          },
+        ])
+        .get('/api/v1/repos/some/repo/contents/charts/some/CHANGELOG.md')
+        .reply(200, {
+          name: 'CHANGELOG.md',
+          path: 'charts/some/CHANGELOG.md',
+          type: 'file',
+          content: toBase64('some content'),
+        });
+      expect(
+        await getReleaseNotesMd(
+          'some/repo',
+          'https://git.test.com/api/v1/',
+          'charts/some'
+        )
+      ).toEqual({
+        changelogFile: 'charts/some/CHANGELOG.md',
+        changelogMd: 'some content\n#\n##',
+      });
+    });
+  });
+});
diff --git a/lib/workers/repository/update/pr/changelog/gitea/index.ts b/lib/workers/repository/update/pr/changelog/gitea/index.ts
new file mode 100644
index 0000000000..8f354fe6f1
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/gitea/index.ts
@@ -0,0 +1,92 @@
+import changelogFilenameRegex from 'changelog-filename-regex';
+import { logger } from '../../../../../../logger';
+import { ReleasesSchema } from '../../../../../../modules/datasource/gitea-releases/schema';
+import {
+  ContentsListResponseSchema,
+  ContentsResponse,
+  ContentsResponseSchema,
+} from '../../../../../../modules/platform/gitea/schema';
+import { GiteaHttp } from '../../../../../../util/http/gitea';
+import { fromBase64 } from '../../../../../../util/string';
+import type {
+  ChangeLogFile,
+  ChangeLogNotes,
+  ChangeLogProject,
+  ChangeLogRelease,
+} from '../types';
+
+export const id = 'gitea-changelog';
+const http = new GiteaHttp(id);
+
+export async function getReleaseNotesMd(
+  repository: string,
+  apiBaseUrl: string,
+  sourceDirectory?: string
+): Promise<ChangeLogFile | null> {
+  logger.trace('gitea.getReleaseNotesMd()');
+  const apiPrefix = `${apiBaseUrl}repos/${repository}/contents`;
+
+  const sourceDir = sourceDirectory ? `/${sourceDirectory}` : '';
+  const tree = (
+    await http.getJson(
+      `${apiPrefix}${sourceDir}`,
+      {
+        paginate: false, // no pagination yet
+      },
+      ContentsListResponseSchema
+    )
+  ).body;
+  const allFiles = tree.filter((f) => f.type === 'file');
+  let files: ContentsResponse[] = [];
+  if (!files.length) {
+    files = allFiles.filter((f) => changelogFilenameRegex.test(f.name));
+  }
+  if (!files.length) {
+    logger.trace('no changelog file found');
+    return null;
+  }
+
+  const { path: changelogFile } = files.shift()!;
+  /* istanbul ignore if */
+  if (files.length !== 0) {
+    logger.debug(
+      `Multiple candidates for changelog file, using ${changelogFile}`
+    );
+  }
+
+  const fileRes = await http.getJson(
+    `${apiPrefix}/${changelogFile}`,
+    ContentsResponseSchema
+  );
+  // istanbul ignore if: should never happen
+  if (!fileRes.body.content) {
+    logger.debug(`Missing content for changelog file, using ${changelogFile}`);
+    return null;
+  }
+  const changelogMd = fromBase64(fileRes.body.content) + '\n#\n##';
+
+  return { changelogFile, changelogMd };
+}
+
+export async function getReleaseList(
+  project: ChangeLogProject,
+  _release: ChangeLogRelease
+): Promise<ChangeLogNotes[]> {
+  logger.trace('gitea.getReleaseNotesMd()');
+  const apiUrl = `${project.apiBaseUrl}repos/${project.repository}/releases`;
+
+  const res = await http.getJson(
+    `${apiUrl}?draft=false`,
+    {
+      paginate: true,
+    },
+    ReleasesSchema
+  );
+  return res.body.map((release) => ({
+    url: `${apiUrl}/tag/${release.tag_name}`,
+    notesSourceUrl: apiUrl,
+    name: release.name,
+    body: release.body,
+    tag: release.tag_name,
+  }));
+}
diff --git a/lib/workers/repository/update/pr/changelog/gitea/source.ts b/lib/workers/repository/update/pr/changelog/gitea/source.ts
new file mode 100644
index 0000000000..fd78892d78
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/gitea/source.ts
@@ -0,0 +1,25 @@
+import type { BranchUpgradeConfig } from '../../../../../types';
+import { ChangeLogSource } from '../source';
+
+export class GiteaChangeLogSource extends ChangeLogSource {
+  constructor() {
+    super('gitea', 'gitea-tags');
+  }
+
+  getAPIBaseUrl(config: BranchUpgradeConfig): string {
+    return this.getBaseUrl(config) + 'api/v1/';
+  }
+
+  getCompareURL(
+    baseUrl: string,
+    repository: string,
+    prevHead: string,
+    nextHead: string
+  ): string {
+    return `${baseUrl}${repository}/compare/${prevHead}...${nextHead}`;
+  }
+
+  override hasValidRepository(repository: string): boolean {
+    return repository.split('/').length === 2;
+  }
+}
diff --git a/lib/workers/repository/update/pr/changelog/release-notes.ts b/lib/workers/repository/update/pr/changelog/release-notes.ts
index caecaf2071..b2c4117e2d 100644
--- a/lib/workers/repository/update/pr/changelog/release-notes.ts
+++ b/lib/workers/repository/update/pr/changelog/release-notes.ts
@@ -10,6 +10,7 @@ import { newlineRegex, regEx } from '../../../../../util/regex';
 import { validateUrl } from '../../../../../util/url';
 import type { BranchUpgradeConfig } from '../../../../types';
 import * as bitbucket from './bitbucket';
+import * as gitea from './gitea';
 import * as github from './github';
 import * as gitlab from './gitlab';
 import type {
@@ -33,6 +34,8 @@ export async function getReleaseList(
   const { apiBaseUrl, repository, type } = project;
   try {
     switch (type) {
+      case 'gitea':
+        return await gitea.getReleaseList(project, release);
       case 'gitlab':
         return await gitlab.getReleaseList(project, release);
       case 'github':
@@ -242,6 +245,12 @@ export async function getReleaseNotesMdFileInner(
   const sourceDirectory = project.sourceDirectory!;
   try {
     switch (type) {
+      case 'gitea':
+        return await gitea.getReleaseNotesMd(
+          repository,
+          apiBaseUrl,
+          sourceDirectory
+        );
       case 'gitlab':
         return await gitlab.getReleaseNotesMd(
           repository,
diff --git a/lib/workers/repository/update/pr/changelog/source.ts b/lib/workers/repository/update/pr/changelog/source.ts
index 2f35b16dd7..6855f1b3ad 100644
--- a/lib/workers/repository/update/pr/changelog/source.ts
+++ b/lib/workers/repository/update/pr/changelog/source.ts
@@ -12,21 +12,22 @@ import { addReleaseNotes } from './release-notes';
 import { getInRangeReleases } from './releases';
 import type {
   ChangeLogError,
+  ChangeLogPlatform,
   ChangeLogRelease,
   ChangeLogResult,
 } from './types';
 
 export abstract class ChangeLogSource {
-  private platform;
-  private datasource;
-  private cacheNamespace: string;
+  private readonly cacheNamespace: string;
 
   constructor(
-    platform: 'bitbucket' | 'github' | 'gitlab',
-    datasource: 'bitbucket-tags' | 'github-tags' | 'gitlab-tags'
+    private readonly platform: ChangeLogPlatform,
+    private readonly datasource:
+      | 'bitbucket-tags'
+      | 'gitea-tags'
+      | 'github-tags'
+      | 'gitlab-tags'
   ) {
-    this.platform = platform;
-    this.datasource = datasource;
     this.cacheNamespace = `changelog-${platform}-release`;
   }
 
diff --git a/lib/workers/repository/update/pr/changelog/types.ts b/lib/workers/repository/update/pr/changelog/types.ts
index 34f4d35939..4c14a6c83a 100644
--- a/lib/workers/repository/update/pr/changelog/types.ts
+++ b/lib/workers/repository/update/pr/changelog/types.ts
@@ -23,9 +23,11 @@ export interface ChangeLogRelease {
   gitRef: string;
 }
 
+export type ChangeLogPlatform = 'bitbucket' | 'gitea' | 'github' | 'gitlab';
+
 export interface ChangeLogProject {
   packageName?: string;
-  type: 'bitbucket' | 'github' | 'gitlab';
+  type: ChangeLogPlatform;
   apiBaseUrl?: string;
   baseUrl: string;
   repository: string;
-- 
GitLab