From b3424c626cb8e44b922b91f669826a466ccee5c8 Mon Sep 17 00:00:00 2001
From: Adam Setch <adam.setch@outlook.com>
Date: Mon, 3 Jul 2023 07:23:53 -0400
Subject: [PATCH] feat(platform/bitbucket): add support for fetching release
 notes (#22094)

Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
---
 lib/constants/platforms.ts                    |   6 +-
 lib/modules/platform/bitbucket/schema.ts      |  22 ++++
 .../repository/update/pr/changelog/api.ts     |   2 +
 .../pr/changelog/bitbucket/index.spec.ts      | 112 ++++++++++++++++++
 .../update/pr/changelog/bitbucket/index.ts    |  78 ++++++++++++
 .../update/pr/changelog/bitbucket/source.ts   |  21 ++++
 .../update/pr/changelog/release-notes.ts      |  11 +-
 .../repository/update/pr/changelog/source.ts  |   4 +-
 .../repository/update/pr/changelog/types.ts   |   7 +-
 9 files changed, 256 insertions(+), 7 deletions(-)
 create mode 100644 lib/modules/platform/bitbucket/schema.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/bitbucket/index.spec.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/bitbucket/index.ts
 create mode 100644 lib/workers/repository/update/pr/changelog/bitbucket/source.ts

diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts
index 049155bd3b..47f16640de 100644
--- a/lib/constants/platforms.ts
+++ b/lib/constants/platforms.ts
@@ -26,4 +26,8 @@ export const GITLAB_API_USING_HOST_TYPES = [
   'gitlab-changelog',
 ];
 
-export const BITBUCKET_API_USING_HOST_TYPES = ['bitbucket', 'bitbucket-tags'];
+export const BITBUCKET_API_USING_HOST_TYPES = [
+  'bitbucket',
+  'bitbucket-changelog',
+  'bitbucket-tags',
+];
diff --git a/lib/modules/platform/bitbucket/schema.ts b/lib/modules/platform/bitbucket/schema.ts
new file mode 100644
index 0000000000..48104f2dc5
--- /dev/null
+++ b/lib/modules/platform/bitbucket/schema.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+const BitbucketSourceTypeSchema = z.enum(['commit_directory', 'commit_file']);
+
+const SourceResultsSchema = z.object({
+  path: z.string(),
+  type: BitbucketSourceTypeSchema,
+  commit: z.object({
+    hash: z.string(),
+  }),
+});
+
+const PagedSchema = z.object({
+  page: z.number().optional(),
+  pagelen: z.number(),
+  size: z.number().optional(),
+  next: z.string().optional(),
+});
+
+export const PagedSourceResultsSchema = PagedSchema.extend({
+  values: z.array(SourceResultsSchema),
+});
diff --git a/lib/workers/repository/update/pr/changelog/api.ts b/lib/workers/repository/update/pr/changelog/api.ts
index 55809bef98..d4fa3edb6d 100644
--- a/lib/workers/repository/update/pr/changelog/api.ts
+++ b/lib/workers/repository/update/pr/changelog/api.ts
@@ -1,3 +1,4 @@
+import { BitbucketChangeLogSource } from './bitbucket/source';
 import type { ChangeLogSource } from './source';
 import { GitHubChangeLogSource } from './source-github';
 import { GitLabChangeLogSource } from './source-gitlab';
@@ -5,5 +6,6 @@ import { GitLabChangeLogSource } from './source-gitlab';
 const api = new Map<string, ChangeLogSource>();
 export default api;
 
+api.set('bitbucket', new BitbucketChangeLogSource());
 api.set('github', new GitHubChangeLogSource());
 api.set('gitlab', new GitLabChangeLogSource());
diff --git a/lib/workers/repository/update/pr/changelog/bitbucket/index.spec.ts b/lib/workers/repository/update/pr/changelog/bitbucket/index.spec.ts
new file mode 100644
index 0000000000..a0ba419de7
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/bitbucket/index.spec.ts
@@ -0,0 +1,112 @@
+import type { ChangeLogProject, ChangeLogRelease } from '..';
+import { Fixtures } from '../../../../../../../test/fixtures';
+import * as httpMock from '../../../../../../../test/http-mock';
+import { partial } from '../../../../../../../test/util';
+import type { BranchUpgradeConfig } from '../../../../../types';
+import { getReleaseList, getReleaseNotesMdFile } from '../release-notes';
+import { BitbucketChangeLogSource } from './source';
+
+const baseUrl = 'https://bitbucket.org/';
+const apiBaseUrl = 'https://api.bitbucket.org/';
+
+const changelogMd = Fixtures.get('jest.md', '../..');
+
+const upgrade = partial<BranchUpgradeConfig>({
+  manager: 'some-manager',
+  packageName: 'some-repo',
+});
+
+const bitbucketTreeResponse = {
+  values: [
+    {
+      type: 'commit_directory',
+      path: 'lib',
+      commit: {
+        hash: '1234',
+      },
+    },
+    {
+      type: 'commit_file',
+      path: 'CHANGELOG.md',
+      commit: {
+        hash: 'abcd',
+      },
+    },
+    {
+      type: 'commit_file',
+      path: 'RELEASE_NOTES.md',
+      commit: {
+        hash: 'asdf',
+      },
+    },
+  ],
+};
+
+const bitbucketTreeResponseNoChangelogFiles = {
+  values: [
+    {
+      type: 'commit_directory',
+      path: 'lib',
+      commit: {
+        hash: '1234',
+      },
+    },
+  ],
+};
+
+const bitbucketProject = partial<ChangeLogProject>({
+  type: 'bitbucket',
+  repository: 'some-org/some-repo',
+  baseUrl,
+  apiBaseUrl,
+});
+
+describe('workers/repository/update/pr/changelog/bitbucket/index', () => {
+  it('handles release notes', async () => {
+    httpMock
+      .scope(apiBaseUrl)
+      .get('/2.0/repositories/some-org/some-repo/src?pagelen=100')
+      .reply(200, bitbucketTreeResponse)
+      .get('/2.0/repositories/some-org/some-repo/src/abcd/CHANGELOG.md')
+      .reply(200, changelogMd);
+    const res = await getReleaseNotesMdFile(bitbucketProject);
+
+    expect(res).toMatchObject({
+      changelogFile: 'CHANGELOG.md',
+      changelogMd: changelogMd + '\n#\n##',
+    });
+  });
+
+  it('handles missing release notes', async () => {
+    httpMock
+      .scope(apiBaseUrl)
+      .get('/2.0/repositories/some-org/some-repo/src?pagelen=100')
+      .reply(200, bitbucketTreeResponseNoChangelogFiles);
+    const res = await getReleaseNotesMdFile(bitbucketProject);
+    expect(res).toBeNull();
+  });
+
+  it('handles release list', async () => {
+    const res = await getReleaseList(
+      bitbucketProject,
+      partial<ChangeLogRelease>({})
+    );
+    expect(res).toBeEmptyArray();
+  });
+
+  describe('source', () => {
+    it('returns api base url', () => {
+      const source = new BitbucketChangeLogSource();
+      expect(source.getAPIBaseUrl(upgrade)).toBe(apiBaseUrl);
+    });
+
+    it('returns get ref comparison url', () => {
+      const source = new BitbucketChangeLogSource();
+      expect(
+        source.getCompareURL(baseUrl, 'some-org/some-repo', 'abc', 'xzy')
+      ).toBe(
+        'https://bitbucket.org/some-org/some-repo/branches/compare/xzy%0Dabc'
+      );
+    });
+  });
+});
diff --git a/lib/workers/repository/update/pr/changelog/bitbucket/index.ts b/lib/workers/repository/update/pr/changelog/bitbucket/index.ts
new file mode 100644
index 0000000000..594f32befa
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/bitbucket/index.ts
@@ -0,0 +1,78 @@
+import is from '@sindresorhus/is';
+import changelogFilenameRegex from 'changelog-filename-regex';
+import { logger } from '../../../../../../logger';
+import { PagedSourceResultsSchema } from '../../../../../../modules/platform/bitbucket/schema';
+import { BitbucketHttp } from '../../../../../../util/http/bitbucket';
+import { joinUrlParts } from '../../../../../../util/url';
+import type {
+  ChangeLogFile,
+  ChangeLogNotes,
+  ChangeLogProject,
+  ChangeLogRelease,
+} from '../types';
+
+export const id = 'bitbucket-changelog';
+const bitbucketHttp = new BitbucketHttp(id);
+
+export async function getReleaseNotesMd(
+  repository: string,
+  apiBaseUrl: string,
+  _sourceDirectory?: string
+): Promise<ChangeLogFile | null> {
+  logger.trace('bitbucket.getReleaseNotesMd()');
+
+  const repositorySourceURl = joinUrlParts(
+    apiBaseUrl,
+    `2.0/repositories`,
+    repository,
+    'src'
+  );
+
+  const rootFiles = (
+    await bitbucketHttp.getJson(
+      repositorySourceURl,
+      {
+        paginate: true,
+      },
+      PagedSourceResultsSchema
+    )
+  ).body.values;
+
+  const allFiles = rootFiles.filter((f) => f.type === 'commit_file');
+
+  const files = allFiles.filter((f) => changelogFilenameRegex.test(f.path));
+
+  const changelogFile = files.shift();
+  if (is.nullOrUndefined(changelogFile)) {
+    logger.trace('no changelog file found');
+    return null;
+  }
+
+  if (files.length !== 0) {
+    logger.debug(
+      `Multiple candidates for changelog file, using ${changelogFile.path}`
+    );
+  }
+
+  const fileRes = await bitbucketHttp.get(
+    joinUrlParts(
+      repositorySourceURl,
+      changelogFile.commit.hash,
+      changelogFile.path
+    )
+  );
+
+  const changelogMd = `${fileRes.body}\n#\n##`;
+  return { changelogFile: changelogFile.path, changelogMd };
+}
+
+export function getReleaseList(
+  _project: ChangeLogProject,
+  _release: ChangeLogRelease
+): ChangeLogNotes[] {
+  logger.trace('bitbucket.getReleaseList()');
+  logger.info(
+    'Unsupported Bitbucket Cloud feature.  Skipping release fetching.'
+  );
+  return [];
+}
diff --git a/lib/workers/repository/update/pr/changelog/bitbucket/source.ts b/lib/workers/repository/update/pr/changelog/bitbucket/source.ts
new file mode 100644
index 0000000000..bfbc12ea6d
--- /dev/null
+++ b/lib/workers/repository/update/pr/changelog/bitbucket/source.ts
@@ -0,0 +1,21 @@
+import type { BranchUpgradeConfig } from '../../../../../types';
+import { ChangeLogSource } from '../source';
+
+export class BitbucketChangeLogSource extends ChangeLogSource {
+  constructor() {
+    super('bitbucket', 'bitbucket-tags');
+  }
+
+  getAPIBaseUrl(_config: BranchUpgradeConfig): string {
+    return 'https://api.bitbucket.org/';
+  }
+
+  getCompareURL(
+    baseUrl: string,
+    repository: string,
+    prevHead: string,
+    nextHead: string
+  ): string {
+    return `${baseUrl}${repository}/branches/compare/${nextHead}%0D${prevHead}`;
+  }
+}
diff --git a/lib/workers/repository/update/pr/changelog/release-notes.ts b/lib/workers/repository/update/pr/changelog/release-notes.ts
index fa9b565d03..b4db11ab34 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 { detectPlatform } from '../../../../../util/common';
 import { linkify } from '../../../../../util/markdown';
 import { newlineRegex, regEx } from '../../../../../util/regex';
 import type { BranchUpgradeConfig } from '../../../../types';
+import * as bitbucket from './bitbucket';
 import * as github from './github';
 import * as gitlab from './gitlab';
 import type {
@@ -35,7 +36,8 @@ export async function getReleaseList(
         return await gitlab.getReleaseList(project, release);
       case 'github':
         return await github.getReleaseList(project, release);
-
+      case 'bitbucket':
+        return bitbucket.getReleaseList(project, release);
       default:
         logger.warn({ apiBaseUrl, repository, type }, 'Invalid project type');
         return [];
@@ -262,7 +264,12 @@ export async function getReleaseNotesMdFileInner(
           apiBaseUrl,
           sourceDirectory
         );
-
+      case 'bitbucket':
+        return await bitbucket.getReleaseNotesMd(
+          repository,
+          apiBaseUrl,
+          sourceDirectory
+        );
       default:
         logger.warn({ apiBaseUrl, repository, type }, 'Invalid project type');
         return null;
diff --git a/lib/workers/repository/update/pr/changelog/source.ts b/lib/workers/repository/update/pr/changelog/source.ts
index c9e32568e8..7a1498b183 100644
--- a/lib/workers/repository/update/pr/changelog/source.ts
+++ b/lib/workers/repository/update/pr/changelog/source.ts
@@ -23,8 +23,8 @@ export abstract class ChangeLogSource {
   private cacheNamespace: string;
 
   constructor(
-    platform: 'github' | 'gitlab',
-    datasource: 'github-tags' | 'gitlab-tags'
+    platform: 'bitbucket' | 'github' | 'gitlab',
+    datasource: 'bitbucket-tags' | 'github-tags' | 'gitlab-tags'
   ) {
     this.platform = platform;
     this.datasource = datasource;
diff --git a/lib/workers/repository/update/pr/changelog/types.ts b/lib/workers/repository/update/pr/changelog/types.ts
index 23c4747a05..34f4d35939 100644
--- a/lib/workers/repository/update/pr/changelog/types.ts
+++ b/lib/workers/repository/update/pr/changelog/types.ts
@@ -25,7 +25,7 @@ export interface ChangeLogRelease {
 
 export interface ChangeLogProject {
   packageName?: string;
-  type: 'github' | 'gitlab';
+  type: 'bitbucket' | 'github' | 'gitlab';
   apiBaseUrl?: string;
   baseUrl: string;
   repository: string;
@@ -33,7 +33,10 @@ export interface ChangeLogProject {
   sourceDirectory?: string;
 }
 
-export type ChangeLogError = 'MissingGithubToken' | 'MissingGitlabToken';
+export type ChangeLogError =
+  | 'MissingBitbucketToken'
+  | 'MissingGithubToken'
+  | 'MissingGitlabToken';
 
 export interface ChangeLogResult {
   hasReleaseNotes?: boolean;
-- 
GitLab