diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 36511eccfd4..a4b39564eaf 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/containerbase/devcontainer:13.8.11 +FROM ghcr.io/containerbase/devcontainer:13.8.12 # https://github.com/pnpm/pnpm/issues/8971 # renovate: datasource=npm diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 030da759aae..8c12c75ba09 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1490,6 +1490,7 @@ But if you're embedding changelogs in commit information, you may use `fetchChan Renovate can fetch changelogs when they are hosted on one of these platforms: - Bitbucket Cloud +- Bitbucket Server / Data Center - GitHub (.com and Enterprise Server) - GitLab (.com and CE/EE) @@ -2739,7 +2740,7 @@ To read the changelogs you must use the link. !!! note - 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. + Renovate can fetch changelogs from Bitbucket, Bitbucket Server / Data Center, Gitea (Forgejo), GitHub and GitLab platforms only, and setting the URL to an unsupported host/platform type won't change that. For more details on supported syntax see Renovate's [string pattern matching documentation](./string-pattern-matching.md). diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index aab7e538716..9c4dd7f76c5 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -516,7 +516,7 @@ const options: RenovateOptions[] = [ description: 'Change this value to override the default Renovate sidecar image.', type: 'string', - default: 'ghcr.io/containerbase/sidecar:13.8.11', + default: 'ghcr.io/containerbase/sidecar:13.8.12', globalOnly: true, }, { diff --git a/lib/constants/platform.spec.ts b/lib/constants/platform.spec.ts index 79cdf8b247a..e4d867ae26b 100644 --- a/lib/constants/platform.spec.ts +++ b/lib/constants/platform.spec.ts @@ -1,3 +1,4 @@ +import { BitbucketServerTagsDatasource } from '../modules/datasource/bitbucket-server-tags'; import { BitbucketTagsDatasource } from '../modules/datasource/bitbucket-tags'; import { GiteaTagsDatasource } from '../modules/datasource/gitea-tags'; import { GithubReleasesDatasource } from '../modules/datasource/github-releases'; @@ -11,6 +12,7 @@ import { id as GITHUB_CHANGELOG_ID } from '../workers/repository/update/pr/chang import { id as GITLAB_CHANGELOG_ID } from '../workers/repository/update/pr/changelog/gitlab'; import { BITBUCKET_API_USING_HOST_TYPES, + BITBUCKET_SERVER_API_USING_HOST_TYPES, GITEA_API_USING_HOST_TYPES, GITHUB_API_USING_HOST_TYPES, GITLAB_API_USING_HOST_TYPES, @@ -71,4 +73,15 @@ describe('constants/platform', () => { ).toBeTrue(); expect(BITBUCKET_API_USING_HOST_TYPES.includes('bitbucket')).toBeTrue(); }); + + it('should be part of the BITBUCKET_SERVER_API_USING_HOST_TYPES', () => { + expect( + BITBUCKET_SERVER_API_USING_HOST_TYPES.includes( + BitbucketServerTagsDatasource.id, + ), + ).toBeTrue(); + expect( + BITBUCKET_SERVER_API_USING_HOST_TYPES.includes('bitbucket-server'), + ).toBeTrue(); + }); }); diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index 1d8171245f8..c4644f9ecab 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -45,4 +45,8 @@ export const BITBUCKET_API_USING_HOST_TYPES = [ 'bitbucket-tags', ]; -export const BITBUCKET_SERVER_API_USING_HOST_TYPES = ['bitbucket-server']; +export const BITBUCKET_SERVER_API_USING_HOST_TYPES = [ + 'bitbucket-server', + 'bitbucket-server-changelog', + 'bitbucket-server-tags', +]; diff --git a/lib/modules/datasource/api.ts b/lib/modules/datasource/api.ts index d0b52e87c2b..082c3a7d7b6 100644 --- a/lib/modules/datasource/api.ts +++ b/lib/modules/datasource/api.ts @@ -5,6 +5,7 @@ import { AwsRdsDatasource } from './aws-rds'; import { AzureBicepResourceDatasource } from './azure-bicep-resource'; import { AzurePipelinesTasksDatasource } from './azure-pipelines-tasks'; import { BazelDatasource } from './bazel'; +import { BitbucketServerTagsDatasource } from './bitbucket-server-tags'; import { BitbucketTagsDatasource } from './bitbucket-tags'; import { BitriseDatasource } from './bitrise'; import { BuildpacksRegistryDatasource } from './buildpacks-registry'; @@ -79,6 +80,7 @@ api.set(AwsRdsDatasource.id, new AwsRdsDatasource()); api.set(AzureBicepResourceDatasource.id, new AzureBicepResourceDatasource()); api.set(AzurePipelinesTasksDatasource.id, new AzurePipelinesTasksDatasource()); api.set(BazelDatasource.id, new BazelDatasource()); +api.set(BitbucketServerTagsDatasource.id, new BitbucketServerTagsDatasource()); api.set(BitbucketTagsDatasource.id, new BitbucketTagsDatasource()); api.set(BitriseDatasource.id, new BitriseDatasource()); api.set(BuildpacksRegistryDatasource.id, new BuildpacksRegistryDatasource()); diff --git a/lib/modules/datasource/bitbucket-server-tags/index.spec.ts b/lib/modules/datasource/bitbucket-server-tags/index.spec.ts new file mode 100644 index 00000000000..e1ca5c68fd1 --- /dev/null +++ b/lib/modules/datasource/bitbucket-server-tags/index.spec.ts @@ -0,0 +1,236 @@ +import { getDigest, getPkgReleases } from '..'; +import * as httpMock from '../../../../test/http-mock'; +import { HttpError } from '../../../util/http'; +import { BitbucketServerTagsDatasource } from '.'; + +const datasource = BitbucketServerTagsDatasource.id; +const baseUrl = 'https://bitbucket.some.domain.org'; +const apiBaseUrl = 'https://bitbucket.some.domain.org/rest/api/1.0/'; + +describe('modules/datasource/bitbucket-server-tags/index', () => { + describe('getReleases', () => { + it('returns tags', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags?limit=100') + .reply(200, { + size: 3, + limit: 100, + isLastPage: true, + start: 0, + values: [ + { + displayId: 'v17.7.2-deno', + hash: '430f18aa2968b244fc91ecd9f374f62301af4b63', + }, + { + displayId: 'v17.7.2', + hash: null, + }, + { + displayId: 'v17.7.1-deno', + hash: '974b64a175bf11c81bfabfeb4325c74e49204b77', + }, + ], + }); + + const res = await getPkgReleases({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/some-repo', + }); + expect(res).toMatchObject({ + sourceUrl: + 'https://bitbucket.some.domain.org/projects/some-org/repos/some-repo', + registryUrl: 'https://bitbucket.some.domain.org', + releases: [ + { + version: 'v17.7.1-deno', + gitRef: 'v17.7.1-deno', + newDigest: '974b64a175bf11c81bfabfeb4325c74e49204b77', + }, + { + version: 'v17.7.2-deno', + gitRef: 'v17.7.2-deno', + newDigest: '430f18aa2968b244fc91ecd9f374f62301af4b63', + }, + { + version: 'v17.7.2', + gitRef: 'v17.7.2', + newDigest: undefined, + }, + ], + }); + }); + + it('returns null on empty result', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/empty/tags?limit=100') + .reply(200, {}); + + const res = await getPkgReleases({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/empty', + }); + expect(res).toBeNull(); + }); + + it('returns null on missing registryUrl', async () => { + const res = await getPkgReleases({ + datasource, + packageName: 'some-org/notexisting', + }); + expect(res).toBeNull(); + }); + + it('handles not found', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/notexisting/tags?limit=100') + .reply(404); + + const res = await getPkgReleases({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/notexisting', + }); + expect(res).toBeNull(); + }); + }); + + describe('getTagCommit', () => { + it('returns commit hash of provided tag', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags/v1.0.0') + .reply(200, { + displayId: 'v1.0.0', + hash: '430f18aa2968b244fc91ecd9f374f62301af4b62', + }); + + const res = await getDigest( + { + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/some-repo', + }, + 'v1.0.0', + ); + expect(res).toBe('430f18aa2968b244fc91ecd9f374f62301af4b62'); + }); + + it('missing hash', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags/v1.0.0') + .reply(200, { + displayId: 'v1.0.0', + hash: null, + }); + + const res = await getDigest( + { + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/some-repo', + }, + 'v1.0.0', + ); + expect(res).toBeNull(); + }); + }); + + describe('getDigest', () => { + it('returns most recent commit hash', async () => { + httpMock + .scope(apiBaseUrl) + .get( + '/projects/some-org/repos/some-repo/commits?ignoreMissing=true&limit=1', + ) + .reply(200, { + size: 1, + limit: 1, + isLastPage: false, + start: 0, + values: [ + { + id: '0c95f9c79e1810cf9c8964fbf7d139009412f7e7', + displayId: '0c95f9c79e1', + }, + ], + }); + + const res = await getDigest({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/some-repo', + }); + expect(res).toBe('0c95f9c79e1810cf9c8964fbf7d139009412f7e7'); + }); + + it('no commits', async () => { + httpMock + .scope(apiBaseUrl) + .get( + '/projects/some-org/repos/some-repo/commits?ignoreMissing=true&limit=1', + ) + .reply(200, { + size: 0, + limit: 1, + isLastPage: true, + start: 0, + values: [], + }); + + const res = await getDigest({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/some-repo', + }); + expect(res).toBeNull(); + }); + + it('returns null on empty result', async () => { + httpMock + .scope(apiBaseUrl) + .get( + '/projects/some-org/repos/empty/commits?ignoreMissing=true&limit=1', + ) + .reply(200, {}); + + const res = await getDigest({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/empty', + }); + expect(res).toBeNull(); + }); + + it('returns null on missing registryUrl', async () => { + const res = await getDigest({ + datasource, + packageName: 'some-org/notexisting', + }); + expect(res).toBeNull(); + }); + + it('handles not found', async () => { + httpMock + .scope(apiBaseUrl) + .get( + '/projects/some-org/repos/notexisting/commits?ignoreMissing=true&limit=1', + ) + .reply(404); + + await expect( + getDigest({ + registryUrls: [baseUrl], + datasource, + packageName: 'some-org/notexisting', + }), + ).rejects.toThrow(HttpError); + }); + }); +}); diff --git a/lib/modules/datasource/bitbucket-server-tags/index.ts b/lib/modules/datasource/bitbucket-server-tags/index.ts new file mode 100644 index 00000000000..09817cc250e --- /dev/null +++ b/lib/modules/datasource/bitbucket-server-tags/index.ts @@ -0,0 +1,197 @@ +import { ZodError } from 'zod'; +import { logger } from '../../../logger'; +import { cache } from '../../../util/cache/package/decorator'; +import type { PackageCacheNamespace } from '../../../util/cache/package/types'; +import { BitbucketServerHttp } from '../../../util/http/bitbucket-server'; +import { regEx } from '../../../util/regex'; +import { Result } from '../../../util/result'; +import { ensureTrailingSlash } from '../../../util/url'; +import { Datasource } from '../datasource'; +import { DigestsConfig, ReleasesConfig } from '../schema'; +import type { DigestConfig, GetReleasesConfig, ReleaseResult } from '../types'; +import { + BitbucketServerCommits, + BitbucketServerTag, + BitbucketServerTags, +} from './schema'; + +export class BitbucketServerTagsDatasource extends Datasource { + static readonly id = 'bitbucket-server-tags'; + + override http = new BitbucketServerHttp(BitbucketServerTagsDatasource.id); + + static readonly sourceUrlSupport = 'package'; + static readonly sourceUrlNote = + 'The source URL is determined by using the `packageName` and `registryUrl`.'; + + static readonly cacheNamespace: PackageCacheNamespace = `datasource-${BitbucketServerTagsDatasource.id}`; + + constructor() { + super(BitbucketServerTagsDatasource.id); + } + + static getRegistryURL(registryUrl: string): string { + return registryUrl?.replace(regEx(/\/rest\/api\/1.0$/), ''); + } + + static getSourceUrl( + projectKey: string, + repositorySlug: string, + registryUrl: string, + ): string { + const url = BitbucketServerTagsDatasource.getRegistryURL(registryUrl); + return `${ensureTrailingSlash(url)}projects/${projectKey}/repos/${repositorySlug}`; + } + + static getApiUrl(registryUrl: string): string { + const res = BitbucketServerTagsDatasource.getRegistryURL(registryUrl); + return `${ensureTrailingSlash(res)}rest/api/1.0/`; + } + + static getCacheKey( + registryUrl: string | undefined, + repo: string, + type: string, + ): string { + return `${BitbucketServerTagsDatasource.getRegistryURL(registryUrl ?? '')}:${repo}:${type}`; + } + + // getReleases fetches list of tags for the repository + @cache({ + namespace: BitbucketServerTagsDatasource.cacheNamespace, + key: ({ registryUrl, packageName }: GetReleasesConfig) => + BitbucketServerTagsDatasource.getCacheKey( + registryUrl, + packageName, + 'tags', + ), + }) + async getReleases(config: GetReleasesConfig): Promise { + const { registryUrl, packageName } = config; + const [projectKey, repositorySlug] = packageName.split('/'); + if (!registryUrl) { + logger.debug('Missing registryUrl'); + return null; + } + + const result = Result.parse(config, ReleasesConfig) + .transform(({ registryUrl }) => { + const url = `${BitbucketServerTagsDatasource.getApiUrl(registryUrl)}projects/${projectKey}/repos/${repositorySlug}/tags`; + + return this.http.getJsonSafe( + url, + { paginate: true }, + BitbucketServerTags, + ); + }) + .transform((tags) => + tags.map(({ displayId, hash }) => ({ + version: displayId, + gitRef: displayId, + newDigest: hash ?? undefined, + })), + ) + .transform((versions): ReleaseResult => { + return { + sourceUrl: BitbucketServerTagsDatasource.getSourceUrl( + projectKey, + repositorySlug, + registryUrl, + ), + registryUrl: + BitbucketServerTagsDatasource.getRegistryURL(registryUrl), + releases: versions, + }; + }); + const { val, err } = await result.unwrap(); + + if (err instanceof ZodError) { + logger.debug({ err }, 'bitbucket-server-tags: validation error'); + return null; + } + + if (err) { + this.handleGenericErrors(err); + } + + return val; + } + + // getTagCommit fetches the commit hash for the specified tag + @cache({ + namespace: BitbucketServerTagsDatasource.cacheNamespace, + key: ({ registryUrl, packageName }: DigestConfig, tag: string) => + BitbucketServerTagsDatasource.getCacheKey( + registryUrl, + packageName, + `tag-${tag}`, + ), + }) + async getTagCommit(baseUrl: string, tag: string): Promise { + const bitbucketServerTag = ( + await this.http.getJson(`${baseUrl}/tags/${tag}`, BitbucketServerTag) + ).body; + + return bitbucketServerTag.hash ?? null; + } + + // getDigest fetches the latest commit for repository main branch. + // If newValue is provided, then getTagCommit is called + @cache({ + namespace: BitbucketServerTagsDatasource.cacheNamespace, + key: ({ registryUrl, packageName }: DigestConfig) => + BitbucketServerTagsDatasource.getCacheKey( + registryUrl, + packageName, + 'digest', + ), + }) + override async getDigest( + config: DigestConfig, + newValue?: string, + ): Promise { + const { registryUrl, packageName } = config; + const [projectKey, repositorySlug] = packageName.split('/'); + if (!registryUrl) { + logger.debug('Missing registryUrl'); + return null; + } + + const baseUrl = `${BitbucketServerTagsDatasource.getApiUrl(registryUrl)}projects/${projectKey}/repos/${repositorySlug}`; + + if (newValue?.length) { + return this.getTagCommit(baseUrl, newValue); + } + + const result = Result.parse(config, DigestsConfig) + .transform(() => { + const url = `${baseUrl}/commits?ignoreMissing=true`; + + return this.http.getJsonSafe( + url, + { + paginate: true, + limit: 1, + maxPages: 1, + }, + BitbucketServerCommits, + ); + }) + .transform((commits) => { + return commits[0]?.id; + }); + + const { val = null, err } = await result.unwrap(); + + if (err instanceof ZodError) { + logger.debug({ err }, 'bitbucket-server-tags: validation error'); + return null; + } + + if (err) { + this.handleGenericErrors(err); + } + + return val; + } +} diff --git a/lib/modules/datasource/bitbucket-server-tags/schema.spec.ts b/lib/modules/datasource/bitbucket-server-tags/schema.spec.ts new file mode 100644 index 00000000000..576bf25ea0b --- /dev/null +++ b/lib/modules/datasource/bitbucket-server-tags/schema.spec.ts @@ -0,0 +1,48 @@ +import { BitbucketServerCommits, BitbucketServerTags } from './schema'; + +describe('modules/datasource/bitbucket-server-tags/schema', () => { + it('parses BitbucketServerTags', () => { + const response = [ + { + id: 'refs/tags/v17.7.2-deno', + displayId: 'v17.7.2-deno', + type: 'TAG', + latestCommit: 'e1760e45c78538f2fd59d4a09fc0c0c6fd4b2379', + latestChangeset: 'e1760e45c78538f2fd59d4a09fc0c0c6fd4b2379', + hash: '430f18aa2968b244fc91ecd9f374f62301af4b63', + }, + { + id: 'refs/tags/v17.7.2', + displayId: 'v17.7.2', + type: 'TAG', + latestCommit: '3566b84b24a7e8cf24badac73ea1d20a0851924e', + latestChangeset: '3566b84b24a7e8cf24badac73ea1d20a0851924e', + hash: null, + }, + ]; + expect(BitbucketServerTags.parse(response)).toMatchObject([ + { + displayId: 'v17.7.2-deno', + hash: '430f18aa2968b244fc91ecd9f374f62301af4b63', + }, + { displayId: 'v17.7.2', hash: null }, + ]); + }); + + it('parses BitbucketServerCommits', () => { + const response = [ + { + id: '0c95f9c79e1810cf9c8964fbf7d139009412f7e7', + displayId: '0c95f9c79e1', + }, + { + id: '4266485b20e9b0f3a7f196e84c6d8284b04642cd', + displayId: '4266485b20e', + }, + ]; + expect(BitbucketServerCommits.parse(response)).toMatchObject([ + { id: '0c95f9c79e1810cf9c8964fbf7d139009412f7e7' }, + { id: '4266485b20e9b0f3a7f196e84c6d8284b04642cd' }, + ]); + }); +}); diff --git a/lib/modules/datasource/bitbucket-server-tags/schema.ts b/lib/modules/datasource/bitbucket-server-tags/schema.ts new file mode 100644 index 00000000000..9c3ccfd1fbb --- /dev/null +++ b/lib/modules/datasource/bitbucket-server-tags/schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const BitbucketServerTag = z.object({ + displayId: z.string(), + hash: z.string().nullable(), +}); + +export const BitbucketServerTags = z.array(BitbucketServerTag); + +export const BitbucketServerCommits = z.array( + z.object({ + id: z.string(), + }), +); diff --git a/lib/modules/datasource/github-runners/index.spec.ts b/lib/modules/datasource/github-runners/index.spec.ts index 4a5407e6630..59dad2c9de3 100644 --- a/lib/modules/datasource/github-runners/index.spec.ts +++ b/lib/modules/datasource/github-runners/index.spec.ts @@ -60,7 +60,7 @@ describe('modules/datasource/github-runners/index', () => { { version: '2016', isDeprecated: true }, { version: '2019' }, { version: '2022' }, - { version: '2025', isStable: false }, + { version: '2025' }, ], sourceUrl: 'https://github.com/actions/runner-images', }); diff --git a/lib/modules/datasource/github-runners/index.ts b/lib/modules/datasource/github-runners/index.ts index 2c86263dbaf..ae9e54d03c2 100644 --- a/lib/modules/datasource/github-runners/index.ts +++ b/lib/modules/datasource/github-runners/index.ts @@ -42,7 +42,7 @@ export class GithubRunnersDatasource extends Datasource { { version: '10.15', isDeprecated: true }, ], windows: [ - { version: '2025', isStable: false }, + { version: '2025' }, { version: '2022' }, { version: '2019' }, { version: '2016', isDeprecated: true }, diff --git a/lib/modules/platform/bitbucket-server/schema.ts b/lib/modules/platform/bitbucket-server/schema.ts index cc5abbc724e..4c5e3c923b3 100644 --- a/lib/modules/platform/bitbucket-server/schema.ts +++ b/lib/modules/platform/bitbucket-server/schema.ts @@ -5,6 +5,8 @@ export const UserSchema = z.object({ emailAddress: z.string(), }); +export const Files = z.array(z.string()); + export const Comment = z.object({ text: z.string(), id: z.number(), diff --git a/lib/util/cache/package/types.ts b/lib/util/cache/package/types.ts index 6e0e3ab9ab5..b99b3fc067e 100644 --- a/lib/util/cache/package/types.ts +++ b/lib/util/cache/package/types.ts @@ -20,6 +20,8 @@ export type PackageCacheNamespace = | '_test-namespace' | 'changelog-bitbucket-notes@v2' | 'changelog-bitbucket-release' + | 'changelog-bitbucket-server-notes@v2' + | 'changelog-bitbucket-server-release' | 'changelog-gitea-notes@v2' | 'changelog-gitea-release' | 'changelog-github-notes@v2' @@ -34,6 +36,7 @@ export type PackageCacheNamespace = | 'datasource-azure-pipelines-tasks' | 'datasource-bazel' | 'datasource-bitbucket-tags' + | 'datasource-bitbucket-server-tags' | 'datasource-bitrise' | 'datasource-buildpacks-registry' | 'datasource-cdnjs' diff --git a/lib/util/exec/containerbase.ts b/lib/util/exec/containerbase.ts index 862b7a60a54..4bb10c7cc59 100644 --- a/lib/util/exec/containerbase.ts +++ b/lib/util/exec/containerbase.ts @@ -176,6 +176,7 @@ const allToolConfig: Record = { datasource: 'github-releases', packageName: 'prefix-dev/pixi', versioning: condaVersioningId, + extractVersion: '^v(?.*)$', }, poetry: { datasource: 'pypi', diff --git a/lib/util/http/bitbucket-server.spec.ts b/lib/util/http/bitbucket-server.spec.ts index bdf0d975229..67596a1f741 100644 --- a/lib/util/http/bitbucket-server.spec.ts +++ b/lib/util/http/bitbucket-server.spec.ts @@ -109,4 +109,25 @@ describe('util/http/bitbucket-server', () => { }); expect(res.body).toEqual([...valuesPageOne, ...valuesPageTwo]); }); + + it('pagination: fetch only one entry with limit 1 and maxPages 1', async () => { + httpMock + .scope(baseUrl) + .get('/some-url?foo=bar&limit=1') + .reply(200, { + values: [1], + size: 1, + isLastPage: false, + limit: 1, + start: 0, + nextPageStart: 1, + }); + + const res = await api.getJsonUnchecked('/some-url?foo=bar', { + paginate: true, + limit: 1, + maxPages: 1, + }); + expect(res.body).toEqual([1]); + }); }); diff --git a/lib/util/http/bitbucket-server.ts b/lib/util/http/bitbucket-server.ts index aecc8980a95..38d9b85dcd8 100644 --- a/lib/util/http/bitbucket-server.ts +++ b/lib/util/http/bitbucket-server.ts @@ -3,6 +3,7 @@ import { HttpBase, type InternalJsonUnsafeOptions } from './http'; import type { HttpMethod, HttpOptions, HttpResponse } from './types'; const MAX_LIMIT = 100; +const MAX_PAGES = 100; let baseUrl: string; export const setBaseUrl = (url: string): void => { @@ -12,6 +13,7 @@ export const setBaseUrl = (url: string): void => { export interface BitbucketServerHttpOptions extends HttpOptions { paginate?: boolean; limit?: number; + maxPages?: number; } interface PagedResult { @@ -24,8 +26,8 @@ export class BitbucketServerHttp extends HttpBase { return baseUrl; } - constructor(options?: HttpOptions) { - super('bitbucket-server', options); + constructor(type = 'bitbucket-server', options?: BitbucketServerHttpOptions) { + super(type, options); } protected override async requestJsonUnsafe( @@ -58,7 +60,8 @@ export class BitbucketServerHttp extends HttpBase { const collectedValues = [...result.body.values]; let nextPageStart = result.body.nextPageStart; - while (nextPageStart) { + let maxPages = opts.httpOptions.maxPages ?? MAX_PAGES; + while (nextPageStart && --maxPages > 0) { resolvedUrl.searchParams.set('start', nextPageStart.toString()); const nextResult = await super.requestJsonUnsafe>( diff --git a/lib/util/http/host-rules.spec.ts b/lib/util/http/host-rules.spec.ts index 51954e3fcb2..e867c2c63b4 100644 --- a/lib/util/http/host-rules.spec.ts +++ b/lib/util/http/host-rules.spec.ts @@ -44,6 +44,11 @@ describe('util/http/host-rules', () => { hostType: 'bitbucket', token: 'cdef', }); + + hostRules.add({ + hostType: 'bitbucket-server', + token: 'cdef', + }); }); afterEach(() => { @@ -494,6 +499,40 @@ describe('util/http/host-rules', () => { }); }); + it('no fallback to bitbucket-server', () => { + hostRules.add({ + hostType: 'bitbucket-server-tags', + username: 'some', + password: 'xxx', + }); + const opts = { ...options, hostType: 'bitbucket-server-tags' }; + const hostRule = findMatchingRule(url, opts); + expect(hostRule).toEqual({ + password: 'xxx', + username: 'some', + }); + expect(applyHostRule(url, opts, hostRule)).toEqual({ + hostType: 'bitbucket-server-tags', + username: 'some', + password: 'xxx', + }); + }); + + it('fallback to bitbucket-server', () => { + const opts = { ...options, hostType: 'bitbucket-server-tags' }; + const hostRule = findMatchingRule(url, opts); + expect(hostRule).toEqual({ + token: 'cdef', + }); + expect(applyHostRule(url, opts, hostRule)).toEqual({ + context: { + authType: undefined, + }, + hostType: 'bitbucket-server-tags', + token: 'cdef', + }); + }); + it('no fallback to gitea', () => { hostRules.add({ hostType: 'gitea-tags', diff --git a/lib/util/http/host-rules.ts b/lib/util/http/host-rules.ts index 95cf06140d9..950ba2579ab 100644 --- a/lib/util/http/host-rules.ts +++ b/lib/util/http/host-rules.ts @@ -2,6 +2,7 @@ import is from '@sindresorhus/is'; import { GlobalConfig } from '../../config/global'; import { BITBUCKET_API_USING_HOST_TYPES, + BITBUCKET_SERVER_API_USING_HOST_TYPES, GITEA_API_USING_HOST_TYPES, GITHUB_API_USING_HOST_TYPES, GITLAB_API_USING_HOST_TYPES, @@ -98,6 +99,21 @@ export function findMatchingRule( }; } + // Fallback to `bitbucket-server` hostType + if ( + hostType && + BITBUCKET_SERVER_API_USING_HOST_TYPES.includes(hostType) && + hostType !== 'bitbucket-server' + ) { + res = { + ...hostRules.find({ + hostType: 'bitbucket-server', + url, + }), + ...res, + }; + } + // Fallback to `gitea` hostType if ( hostType && diff --git a/lib/workers/repository/update/pr/body/index.spec.ts b/lib/workers/repository/update/pr/body/index.spec.ts index 11c01fadf25..41ecd264dd0 100644 --- a/lib/workers/repository/update/pr/body/index.spec.ts +++ b/lib/workers/repository/update/pr/body/index.spec.ts @@ -98,12 +98,27 @@ describe('workers/repository/update/pr/body/index', () => { homepage: 'https://example.com', }; + const upgradeBitbucketServer = { + manager: 'some-manager', + branchName: 'some-branch', + sourceUrl: 'https://bitbucket.domain.org/projects/foo/repos/bar', + sourceDirectory: '/baz', + homepage: 'https://example.com', + changelogUrl: + 'https://bitbucket.domain.org/projects/foo/repos/bar/browse/CHANGELOG.md', + }; + getPrBody( { manager: 'some-manager', baseBranch: 'base', branchName: 'some-branch', - upgrades: [upgrade, upgrade1, upgradeBitbucket], + upgrades: [ + upgrade, + upgrade1, + upgradeBitbucket, + upgradeBitbucketServer, + ], }, { debugData: { @@ -146,6 +161,15 @@ describe('workers/repository/update/pr/body/index', () => { homepage: 'https://example.com', sourceUrl: 'https://bitbucket.org/foo/bar', }); + expect(upgradeBitbucketServer).toMatchObject({ + branchName: 'some-branch', + depNameLinked: + '[undefined](https://example.com) ([source](https://bitbucket.domain.org/projects/foo/repos/bar/browse/baz), [changelog](https://bitbucket.domain.org/projects/foo/repos/bar/browse/CHANGELOG.md))', + references: + '[homepage](https://example.com), [source](https://bitbucket.domain.org/projects/foo/repos/bar/browse/baz), [changelog](https://bitbucket.domain.org/projects/foo/repos/bar/browse/CHANGELOG.md)', + homepage: 'https://example.com', + sourceUrl: 'https://bitbucket.domain.org/projects/foo/repos/bar', + }); }); it('uses dependencyUrl as primary link', () => { diff --git a/lib/workers/repository/update/pr/body/index.ts b/lib/workers/repository/update/pr/body/index.ts index 984a3ff83b9..6c165a91851 100644 --- a/lib/workers/repository/update/pr/body/index.ts +++ b/lib/workers/repository/update/pr/body/index.ts @@ -32,22 +32,20 @@ function massageUpdateMetadata(config: BranchConfig): void { depNameLinked = `[${depNameLinked}](${primaryLink})`; } - let sourceRootPath = 'tree'; + let sourceRootPath = 'tree/HEAD'; if (sourceUrl) { const sourcePlatform = detectPlatform(sourceUrl); if (sourcePlatform === 'bitbucket') { - sourceRootPath = 'src'; + sourceRootPath = 'src/HEAD'; + } else if (sourcePlatform === 'bitbucket-server') { + sourceRootPath = 'browse'; } } const otherLinks = []; if (sourceUrl && (!!sourceDirectory || homepage)) { otherLinks.push( - `[source](${ - sourceDirectory - ? joinUrlParts(sourceUrl, sourceRootPath, 'HEAD', sourceDirectory) - : sourceUrl - })`, + `[source](${getFullSourceUrl(sourceUrl, sourceRootPath, sourceDirectory)})`, ); } if (changelogUrl) { @@ -62,16 +60,9 @@ function massageUpdateMetadata(config: BranchConfig): void { references.push(`[homepage](${homepage})`); } if (sourceUrl) { - let fullUrl = sourceUrl; - if (sourceDirectory) { - fullUrl = joinUrlParts( - sourceUrl, - sourceRootPath, - 'HEAD', - sourceDirectory, - ); - } - references.push(`[source](${fullUrl})`); + references.push( + `[source](${getFullSourceUrl(sourceUrl, sourceRootPath, sourceDirectory)})`, + ); } if (changelogUrl) { references.push(`[changelog](${changelogUrl})`); @@ -80,6 +71,19 @@ function massageUpdateMetadata(config: BranchConfig): void { }); } +function getFullSourceUrl( + sourceUrl: string, + sourceRootPath: string, + sourceDirectory?: string, +): string { + let fullUrl = sourceUrl; + if (sourceDirectory) { + fullUrl = joinUrlParts(sourceUrl, sourceRootPath, sourceDirectory); + } + + return fullUrl; +} + interface PrBodyConfig { appendExtra?: string | null | undefined; rebasingNotice?: string; diff --git a/lib/workers/repository/update/pr/changelog/api.ts b/lib/workers/repository/update/pr/changelog/api.ts index d4d0c9b9b17..36937b54768 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 { BitbucketServerChangeLogSource } from './bitbucket-server/source'; import { GiteaChangeLogSource } from './gitea/source'; import { GitHubChangeLogSource } from './github/source'; import { GitLabChangeLogSource } from './gitlab/source'; @@ -8,6 +9,7 @@ const api = new Map(); export default api; api.set('bitbucket', new BitbucketChangeLogSource()); +api.set('bitbucket-server', new BitbucketServerChangeLogSource()); api.set('gitea', new GiteaChangeLogSource()); api.set('github', new GitHubChangeLogSource()); api.set('gitlab', new GitLabChangeLogSource()); diff --git a/lib/workers/repository/update/pr/changelog/bitbucket-server/index.spec.ts b/lib/workers/repository/update/pr/changelog/bitbucket-server/index.spec.ts new file mode 100644 index 00000000000..17dbfefbe0a --- /dev/null +++ b/lib/workers/repository/update/pr/changelog/bitbucket-server/index.spec.ts @@ -0,0 +1,319 @@ +import { getChangeLogJSON } from '..'; +import type { ChangeLogProject, ChangeLogRelease } from '..'; +import { Fixtures } from '../../../../../../../test/fixtures'; +import * as httpMock from '../../../../../../../test/http-mock'; +import { logger, partial } from '../../../../../../../test/util'; +import * as semverVersioning from '../../../../../../modules/versioning/semver'; +import * as hostRules from '../../../../../../util/host-rules'; +import type { BranchUpgradeConfig } from '../../../../../types'; +import { getReleaseList, getReleaseNotesMdFile } from '../release-notes'; +import { BitbucketServerChangeLogSource } from './source'; + +const baseUrl = 'https://bitbucket.some.domain.org/'; +const apiBaseUrl = 'https://bitbucket.some.domain.org/rest/api/1.0/'; + +const upgrade = partial({ + manager: 'some-manager', + branchName: '', + endpoint: apiBaseUrl, + packageName: 'renovate', + versioning: semverVersioning.id, + currentVersion: '5.2.0', + newVersion: '5.7.0', + sourceUrl: `${baseUrl}projects/some-org/repos/some-repo`, + releases: [ + { version: '5.2.0' }, + { version: '5.4.0' }, + { version: '5.5.0', gitRef: 'eba303e91c930292198b2fc57040145682162a1b' }, + { version: '5.6.0' }, + { version: '5.6.1' }, + ], +}); + +const bitbucketProject = partial({ + type: 'bitbucket-server', + repository: 'some-org/some-repo', + baseUrl, + apiBaseUrl, +}); + +const changelogSource = new BitbucketServerChangeLogSource(); + +describe('workers/repository/update/pr/changelog/bitbucket-server/index', () => { + describe('getChangeLogJSON', () => { + beforeEach(() => { + hostRules.clear(); + hostRules.add({ + hostType: 'bitbucket-server', + matchHost: baseUrl, + token: 'abc', + }); + }); + + it('uses bitbucket-server tags', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags?limit=100') + .reply(200, { + isLastPage: true, + values: [ + { displayId: 'v5.2.0', hash: null }, + { displayId: 'v5.4.0', hash: null }, + { + displayId: 'v5.5.0', + hash: 'eba303e91c930292198b2fc57040145682162a1b', + }, + { displayId: 'v5.6.0', hash: null }, + { displayId: 'v5.6.1', hash: null }, + { displayId: 'v5.7.0', hash: null }, + ], + }) + .get('/projects/some-org/repos/some-repo/files?limit=100') + .times(4) + .reply(200, { + isLastPage: true, + values: ['src/CHANGELOG.md', 'CHANGELOG.md'], + }) + .get('/projects/some-org/repos/some-repo/raw/CHANGELOG.md') + .times(4) + .reply(200, 'text'); + + expect( + await getChangeLogJSON({ + ...upgrade, + }), + ).toMatchObject({ + hasReleaseNotes: true, + project: { + apiBaseUrl, + baseUrl, + packageName: 'renovate', + repository: 'some-org/some-repo', + sourceDirectory: undefined, + sourceUrl: `${baseUrl}projects/some-org/repos/some-repo`, + type: 'bitbucket-server', + }, + versions: [ + { version: '5.6.1' }, + { version: '5.6.0' }, + { version: '5.5.0' }, + { version: '5.4.0' }, + ], + }); + }); + + it('handles empty bitbucket-server tags response', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags?limit=100') + .reply(200, []) + .get('/projects/some-org/repos/some-repo/files?limit=100') + .times(4) + .reply(200, []); + + expect( + await getChangeLogJSON({ + ...upgrade, + }), + ).toMatchObject({ + hasReleaseNotes: false, + project: { + apiBaseUrl, + baseUrl, + packageName: 'renovate', + repository: 'some-org/some-repo', + sourceDirectory: undefined, + sourceUrl: `${baseUrl}projects/some-org/repos/some-repo`, + type: 'bitbucket-server', + }, + versions: [ + { version: '5.6.1' }, + { version: '5.6.0' }, + { version: '5.5.0' }, + { version: '5.4.0' }, + ], + }); + }); + + it('uses bitbucket-server tags with error', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags?limit=100') + .replyWithError('Unknown bitbucket-server repo') + .get('/projects/some-org/repos/some-repo/files?limit=100') + .times(4) + .reply(200, []); + + expect( + await getChangeLogJSON({ + ...upgrade, + }), + ).toMatchObject({ + hasReleaseNotes: false, + project: { + apiBaseUrl, + baseUrl, + packageName: 'renovate', + repository: 'some-org/some-repo', + sourceDirectory: undefined, + sourceUrl: `${baseUrl}projects/some-org/repos/some-repo`, + type: 'bitbucket-server', + }, + versions: [ + { version: '5.6.1' }, + { version: '5.6.0' }, + { version: '5.5.0' }, + { version: '5.4.0' }, + ], + }); + }); + }); + + describe('getReleaseNotesMdFile', () => { + it('handles release notes', async () => { + const changelogMd = Fixtures.get('jest.md', '..'); + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/files?limit=100') + .reply(200, { + isLastPage: true, + values: ['.gitignore', 'README.md', 'src/CHANGELOG.md'], + }) + .get('/projects/some-org/repos/some-repo/raw/src/CHANGELOG.md') + .reply(200, changelogMd); + + const res = await getReleaseNotesMdFile(bitbucketProject); + expect(res).toStrictEqual({ + changelogFile: 'src/CHANGELOG.md', + changelogMd: changelogMd + '\n#\n##', + }); + }); + + it('handles release notes with sourceDirectory', async () => { + const changelogMd = Fixtures.get('jest.md', '..'); + httpMock + .scope(apiBaseUrl) + .get( + '/projects/some-org/repos/some-repo/files/packages/components?limit=100', + ) + .reply(200, { + isLastPage: true, + values: [ + '.gitignore', + 'README.md', + 'src/CHANGELOG.md', + 'src/CHANGELOG', + ], + }) + .get( + '/projects/some-org/repos/some-repo/raw/packages/components/src/CHANGELOG.md', + ) + .reply(200, changelogMd); + + const project = { + ...bitbucketProject, + sourceDirectory: 'packages/components', + }; + const res = await getReleaseNotesMdFile(project); + expect(res).toStrictEqual({ + changelogFile: 'packages/components/src/CHANGELOG.md', + changelogMd: changelogMd + '\n#\n##', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + `Multiple candidates for changelog file, using packages/components/src/CHANGELOG.md`, + ); + }); + + it('handles missing release notes', async () => { + httpMock + .scope(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/files?limit=100') + .reply(200, { + isLastPage: true, + values: ['.gitignore', 'README.md'], + }); + expect(await getReleaseNotesMdFile(bitbucketProject)).toBeNull(); + }); + }); + + it('getReleaseList', async () => { + const res = await getReleaseList( + bitbucketProject, + partial({}), + ); + expect(res).toBeEmptyArray(); + }); + + describe('source', () => { + it('getAPIBaseUrl', () => { + expect(changelogSource.getAPIBaseUrl(upgrade)).toBe(apiBaseUrl); + }); + + it('getCompareURL', () => { + const res = changelogSource.getCompareURL( + baseUrl, + 'some-org/some-repo', + 'abc', + 'xyz', + ); + expect(res).toBe( + `${baseUrl}projects/some-org/repos/some-repo/compare/commits?sourceBranch=xyz&targetBranch=abc`, + ); + }); + + describe('getRepositoryFromUrl', () => { + it.each` + input | expected + ${'ssh://git@some-host.org:7999/some-org/some-repo.git'} | ${'some-org/some-repo'} + ${'https://some-host.org:7990/scm/some-org/some-repo.git'} | ${'some-org/some-repo'} + ${'https://some-host:7990/projects/some-org/repos/some-repo/raw/src/CHANGELOG.md?at=HEAD'} | ${'some-org/some-repo'} + ${'some-random-value'} | ${''} + `('$input', ({ input, expected }) => { + expect( + changelogSource.getRepositoryFromUrl({ + ...upgrade, + sourceUrl: input, + }), + ).toBe(expected); + }); + }); + }); + + 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(apiBaseUrl) + .get('/projects/some-org/repos/some-repo/tags?limit=100') + .reply(200, { + values: [ + { + displayId: 'v17.7.2-deno', + hash: '430f18aa2968b244fc91ecd9f374f62301af4b63', + }, + { displayId: 'v17.7.2', hash: null }, + { + displayId: 'v17.7.1-deno', + hash: '974b64a175bf11c81bfabfeb4325c74e49204b77', + }, + ], + }); + + const res = await changelogSource.getAllTags( + apiBaseUrl, + 'some-org/some-repo', + ); + expect(res).toEqual(['v17.7.1-deno', 'v17.7.2-deno', 'v17.7.2']); + }); + }); +}); diff --git a/lib/workers/repository/update/pr/changelog/bitbucket-server/index.ts b/lib/workers/repository/update/pr/changelog/bitbucket-server/index.ts new file mode 100644 index 00000000000..1e0576b8ffd --- /dev/null +++ b/lib/workers/repository/update/pr/changelog/bitbucket-server/index.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; +import changelogFilenameRegex from 'changelog-filename-regex'; +import { logger } from '../../../../../../logger'; +import { Files } from '../../../../../../modules/platform/bitbucket-server/schema'; +import { BitbucketServerHttp } from '../../../../../../util/http/bitbucket-server'; +import { ensureTrailingSlash, joinUrlParts } from '../../../../../../util/url'; +import { compareChangelogFilePath } from '../common'; +import type { ChangeLogFile } from '../types'; + +export const id = 'bitbucket-server-changelog'; +const http = new BitbucketServerHttp(id); + +export async function getReleaseNotesMd( + repository: string, + apiBaseUrl: string, + sourceDirectory?: string, +): Promise { + logger.info('bitbucketServer.getReleaseNotesMd()'); + + const [projectKey, repositorySlug] = repository.split('/'); + const apiRepoBaseUrl = joinUrlParts( + apiBaseUrl, + `projects`, + projectKey, + 'repos', + repositorySlug, + ); + + const repositorySourceURl = joinUrlParts( + apiRepoBaseUrl, + 'files', + sourceDirectory ?? '', + ); + const allFiles = ( + await http.getJson( + repositorySourceURl, + { + paginate: true, + }, + Files, + ) + ).body; + + const changelogFiles = allFiles.filter((f) => + changelogFilenameRegex.test(path.basename(f)), + ); + + let changelogFile = changelogFiles + .sort((a, b) => compareChangelogFilePath(a, b)) + .shift(); + if (!changelogFile) { + logger.trace('no changelog file found'); + return null; + } + + changelogFile = `${sourceDirectory ? ensureTrailingSlash(sourceDirectory) : ''}${changelogFile}`; + if (changelogFiles.length !== 0) { + logger.debug( + `Multiple candidates for changelog file, using ${changelogFile}`, + ); + } + + const fileRes = await http.getText( + joinUrlParts(apiRepoBaseUrl, 'raw', changelogFile), + ); + const changelogMd = `${fileRes.body}\n#\n##`; + + return { changelogFile, changelogMd }; +} diff --git a/lib/workers/repository/update/pr/changelog/bitbucket-server/source.ts b/lib/workers/repository/update/pr/changelog/bitbucket-server/source.ts new file mode 100644 index 00000000000..687cbe14080 --- /dev/null +++ b/lib/workers/repository/update/pr/changelog/bitbucket-server/source.ts @@ -0,0 +1,40 @@ +import { regEx } from '../../../../../../util/regex'; +import { parseUrl } from '../../../../../../util/url'; +import type { BranchUpgradeConfig } from '../../../../../types'; +import { ChangeLogSource } from '../source'; + +const repositoryRegex = regEx( + '^/(?:scm|projects)?/?(?[^\\/]+)/(?:repos/)?(?[^\\/]+?)(?:\\.git|/.*|$)', +); + +export class BitbucketServerChangeLogSource extends ChangeLogSource { + constructor() { + super('bitbucket-server', 'bitbucket-server-tags'); + } + + getAPIBaseUrl(config: BranchUpgradeConfig): string { + return `${this.getBaseUrl(config)}rest/api/1.0/`; + } + + getCompareURL( + baseUrl: string, + repository: string, + prevHead: string, + nextHead: string, + ): string { + const [projectKey, repositorySlug] = repository.split('/'); + return `${baseUrl}projects/${projectKey}/repos/${repositorySlug}/compare/commits?sourceBranch=${nextHead}&targetBranch=${prevHead}`; + } + + override getRepositoryFromUrl(config: BranchUpgradeConfig): string { + const parsedUrl = parseUrl(config.sourceUrl); + if (parsedUrl) { + const match = repositoryRegex.exec(parsedUrl.pathname); + if (match?.groups) { + return `${match.groups.project}/${match.groups.repo}`; + } + } + + return ''; + } +} diff --git a/lib/workers/repository/update/pr/changelog/release-notes.spec.ts b/lib/workers/repository/update/pr/changelog/release-notes.spec.ts index b3561ed80e5..77452d39db4 100644 --- a/lib/workers/repository/update/pr/changelog/release-notes.spec.ts +++ b/lib/workers/repository/update/pr/changelog/release-notes.spec.ts @@ -97,6 +97,12 @@ const bitbucketProject = partial({ baseUrl: 'https://bitbucket.org/', }); +const bitbucketServerProject = partial({ + type: 'bitbucket-server', + apiBaseUrl: 'https://bitbucket.domain.org/rest/api/1.0/', + baseUrl: 'https://bitbucket\\.domain.org/', +}); + const githubProject = partial({ type: 'github', apiBaseUrl: 'https://api.github.com/', @@ -361,6 +367,17 @@ describe('workers/repository/update/pr/changelog/release-notes', () => { }, ]); }); + + it('should return empty release list for self-hosted bitbucket-server', async () => { + const res = await getReleaseList( + { + ...bitbucketServerProject, + repository: 'some/yet-other-repository', + }, + partial(), + ); + expect(res).toBeEmptyArray(); + }); }); describe('getReleaseNotes()', () => { @@ -1150,6 +1167,35 @@ describe('workers/repository/update/pr/changelog/release-notes', () => { }); }); + it('handles bitbucket-server release notes link', async () => { + httpMock + .scope(bitbucketServerProject.apiBaseUrl) + .get('/projects/some-org/repos/some-repo/files?limit=100') + .reply(200, { + isLastPage: true, + values: ['CHANGELOG.md'], + }) + .get('/projects/some-org/repos/some-repo/raw/CHANGELOG.md') + .reply(200, angularJsChangelogMd); + + const res = await getReleaseNotesMd( + { + ...bitbucketServerProject, + repository: 'some-org/some-repo', + }, + partial({ + version: '1.6.9', + gitRef: '1.6.9', + }), + ); + + const notesSourceUrl = `${bitbucketServerProject.baseUrl}projects/some-org/repos/some-repo/browse/CHANGELOG.md?at=HEAD`; + expect(res).toMatchObject({ + notesSourceUrl, + url: `${notesSourceUrl}#169-fiery-basilisk-2018-02-02`, + }); + }); + it('parses angular.js', async () => { httpMock .scope('https://api.github.com') diff --git a/lib/workers/repository/update/pr/changelog/release-notes.ts b/lib/workers/repository/update/pr/changelog/release-notes.ts index 32aebe599c5..584aa276af5 100644 --- a/lib/workers/repository/update/pr/changelog/release-notes.ts +++ b/lib/workers/repository/update/pr/changelog/release-notes.ts @@ -12,13 +12,13 @@ import { coerceString } from '../../../../../util/string'; import { isHttpUrl, joinUrlParts } from '../../../../../util/url'; import type { BranchUpgradeConfig } from '../../../../types'; import * as bitbucket from './bitbucket'; +import * as bitbucketServer from './bitbucket-server'; import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; import type { ChangeLogFile, ChangeLogNotes, - ChangeLogPlatform, ChangeLogProject, ChangeLogRelease, ChangeLogResult, @@ -45,6 +45,11 @@ export async function getReleaseList( return await github.getReleaseList(project, release); case 'bitbucket': return bitbucket.getReleaseList(project, release); + case 'bitbucket-server': + logger.trace( + 'Unsupported Bitbucket Server feature. Skipping release fetching.', + ); + return []; default: logger.warn({ apiBaseUrl, repository, type }, 'Invalid project type'); return []; @@ -281,6 +286,12 @@ export async function getReleaseNotesMdFileInner( apiBaseUrl, sourceDirectory, ); + case 'bitbucket-server': + return await bitbucketServer.getReleaseNotesMd( + repository, + apiBaseUrl, + sourceDirectory, + ); default: logger.warn({ apiBaseUrl, repository, type }, 'Invalid project type'); return null; @@ -359,12 +370,10 @@ export async function getReleaseNotesMd( for (const word of title) { if (word.includes(version) && !isHttpUrl(word)) { logger.trace({ body }, 'Found release notes for v' + version); - // TODO: fix url - const notesSourceUrl = joinUrlParts( + const notesSourceUrl = getNotesSourceUrl( baseUrl, repository, - getSourceRootPath(project.type), - 'HEAD', + project, changelogFile, ); const mdHeadingLink = title @@ -487,11 +496,31 @@ export function shouldSkipChangelogMd(repository: string): boolean { return repositoriesToSkipMdFetching.includes(repository); } -function getSourceRootPath(type: ChangeLogPlatform): string { - switch (type) { - case 'bitbucket': - return 'src'; - default: - return 'blob'; +function getNotesSourceUrl( + baseUrl: string, + repository: string, + project: ChangeLogProject, + changelogFile: string, +): string { + if (project.type === 'bitbucket-server') { + const [projectKey, repositorySlug] = repository.split('/'); + return joinUrlParts( + baseUrl, + 'projects', + projectKey, + 'repos', + repositorySlug, + 'browse', + changelogFile, + '?at=HEAD', + ); } + + return joinUrlParts( + baseUrl, + repository, + project.type === 'bitbucket' ? 'src' : 'blob', + 'HEAD', + changelogFile, + ); } diff --git a/lib/workers/repository/update/pr/changelog/source.ts b/lib/workers/repository/update/pr/changelog/source.ts index 4aa174ea908..8dcae20425d 100644 --- a/lib/workers/repository/update/pr/changelog/source.ts +++ b/lib/workers/repository/update/pr/changelog/source.ts @@ -26,6 +26,7 @@ export abstract class ChangeLogSource { private readonly platform: ChangeLogPlatform, private readonly datasource: | 'bitbucket-tags' + | 'bitbucket-server-tags' | 'gitea-tags' | 'github-tags' | 'gitlab-tags', diff --git a/lib/workers/repository/update/pr/changelog/types.ts b/lib/workers/repository/update/pr/changelog/types.ts index 969d196fd53..3e504775b21 100644 --- a/lib/workers/repository/update/pr/changelog/types.ts +++ b/lib/workers/repository/update/pr/changelog/types.ts @@ -23,7 +23,12 @@ export interface ChangeLogRelease { gitRef: string; } -export type ChangeLogPlatform = 'bitbucket' | 'gitea' | 'github' | 'gitlab'; +export type ChangeLogPlatform = + | 'bitbucket' + | 'bitbucket-server' + | 'gitea' + | 'github' + | 'gitlab'; export interface ChangeLogProject { packageName?: string;