diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index 998f6d9a2b18193c95f88619a54b4a74756cc05e..8247faa8f5c9ca5d371e480091502071c3d83630 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -383,7 +383,7 @@ To handle the case where the underlying Git processes appear to hang, configure ## gitUrl Override the default resolution for Git remote, e.g. to switch GitLab from HTTPS to SSH-based. -Currently works for GitLab only. +Currently works for Bitbucket Server and GitLab only. Possible values: diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 37bb30e434b1448bbea632063b42e21097c6ceff..bcc167f8d0ae6e9df1f93201b4fe483d6db44089 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -2295,6 +2295,7 @@ const options: RenovateOptions[] = [ description: 'Overrides the default resolution for Git remote, e.g. to switch GitLab from HTTPS to SSH-based.', type: 'string', + supportedPlatforms: ['gitlab', 'bitbucket-server'], allowedValues: ['default', 'ssh', 'endpoint'], default: 'default', stage: 'repository', diff --git a/lib/modules/platform/bitbucket-server/index.spec.ts b/lib/modules/platform/bitbucket-server/index.spec.ts index 4883352ff9648c32dfa506f9b3efcbe79fa351be..8faf6f06a6185e951d213710cf9690e984a40e90 100644 --- a/lib/modules/platform/bitbucket-server/index.spec.ts +++ b/lib/modules/platform/bitbucket-server/index.spec.ts @@ -292,6 +292,113 @@ describe('modules/platform/bitbucket-server/index', () => { ).toMatchSnapshot(); }); + it('no git url', async () => { + expect.assertions(1); + httpMock + .scope(urlHost) + .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) + .reply(200, repoMock(url, 'SOME', 'repo')) + .get( + `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default` + ) + .reply(200, { + displayId: 'master', + }); + expect( + await bitbucket.initRepo({ + endpoint: 'https://stash.renovatebot.com/vcs/', + repository: 'SOME/repo', + }) + ).toEqual({ defaultBranch: 'master', isFork: false }); + }); + + it('gitUrl ssh returns ssh url', async () => { + expect.assertions(2); + const responseMock = repoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: true }, + }); + httpMock + .scope(urlHost) + .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) + .reply(200, responseMock) + .get( + `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default` + ) + .reply(200, { + displayId: 'master', + }); + const res = await bitbucket.initRepo({ + endpoint: 'https://stash.renovatebot.com/vcs/', + repository: 'SOME/repo', + gitUrl: 'ssh', + }); + expect(git.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ url: sshLink('SOME', 'repo') }) + ); + expect(res).toEqual({ defaultBranch: 'master', isFork: false }); + }); + + it('gitURL endpoint returns generates endpoint URL', async () => { + expect.assertions(2); + const link = httpLink(url.toString(), 'SOME', 'repo'); + const responseMock = repoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: false }, + }); + httpMock + .scope(urlHost) + .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) + .reply(200, responseMock) + .get( + `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default` + ) + .reply(200, { + displayId: 'master', + }); + git.getUrl.mockReturnValueOnce(link); + const res = await bitbucket.initRepo({ + endpoint: 'https://stash.renovatebot.com/vcs/', + repository: 'SOME/repo', + gitUrl: 'endpoint', + }); + expect(git.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ + url: link, + }) + ); + expect(res).toEqual({ defaultBranch: 'master', isFork: false }); + }); + + it('gitUrl default returns http from API with injected auth', async () => { + expect.assertions(2); + const responseMock = repoMock(url, 'SOME', 'repo', { + cloneUrl: { https: true, ssh: true }, + }); + httpMock + .scope(urlHost) + .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) + .reply(200, responseMock) + .get( + `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default` + ) + .reply(200, { + displayId: 'master', + }); + const res = await bitbucket.initRepo({ + endpoint: 'https://stash.renovatebot.com/vcs/', + repository: 'SOME/repo', + gitUrl: 'default', + }); + expect(git.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ + url: httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ), + }) + ); + expect(res).toEqual({ defaultBranch: 'master', isFork: false }); + }); + it('uses ssh url from API if http not in API response', async () => { expect.assertions(2); const responseMock = repoMock(url, 'SOME', 'repo', { diff --git a/lib/modules/platform/bitbucket-server/index.ts b/lib/modules/platform/bitbucket-server/index.ts index 212f01a481bcb11fe032724c25708e1fe7a32203..289bb36418e33b42bb37c492d87646df3235e8cf 100644 --- a/lib/modules/platform/bitbucket-server/index.ts +++ b/lib/modules/platform/bitbucket-server/index.ts @@ -154,6 +154,7 @@ export async function initRepo({ repository, cloneSubmodules, ignorePrAuthor, + gitUrl, }: RepoParams): Promise<RepoResult> { logger.debug(`initRepo("${JSON.stringify({ repository }, null, 2)}")`); const opts = hostRules.find({ @@ -189,17 +190,18 @@ export async function initRepo({ throw new Error(REPOSITORY_EMPTY); } - const gitUrl = utils.getRepoGitUrl( + const url = utils.getRepoGitUrl( config.repositorySlug, // TODO #7154 defaults.endpoint!, + gitUrl, info, opts ); await git.initRepo({ ...config, - url: gitUrl, + url, cloneSubmodules, fullClone: true, }); diff --git a/lib/modules/platform/bitbucket-server/types.ts b/lib/modules/platform/bitbucket-server/types.ts index fe5d4f2f74515a9247ebea4b3614d3845ffd0ac3..a856ffea5980d69f076f9917daa1a90fc2c6ee22 100644 --- a/lib/modules/platform/bitbucket-server/types.ts +++ b/lib/modules/platform/bitbucket-server/types.ts @@ -51,7 +51,7 @@ export interface BbsRestRepo { project: { key: string }; origin: { name: string; slug: string }; links: { - clone: { href: string; name: string }[]; + clone?: { href: string; name: string }[]; }; } diff --git a/lib/modules/platform/bitbucket-server/utils.spec.ts b/lib/modules/platform/bitbucket-server/utils.spec.ts index c8598c21079d2a8b0635fdd6cf1f279b54789fad..460a94a8277246fc830af38c9581860de87acf75 100644 --- a/lib/modules/platform/bitbucket-server/utils.spec.ts +++ b/lib/modules/platform/bitbucket-server/utils.spec.ts @@ -1,11 +1,80 @@ import type { Response } from 'got'; import { partial } from '../../../../test/util'; -import type { BitbucketError, BitbucketErrorResponse } from './types'; +import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages'; +import type { + BbsRestRepo, + BitbucketError, + BitbucketErrorResponse, +} from './types'; import { BITBUCKET_INVALID_REVIEWERS_EXCEPTION, getInvalidReviewers, + getRepoGitUrl, } from './utils'; +function sshLink(projectKey: string, repositorySlug: string): string { + return `ssh://git@stash.renovatebot.com:7999/${projectKey.toLowerCase()}/${repositorySlug}.git`; +} + +function httpLink( + endpointStr: string, + projectKey: string, + repositorySlug: string +): string { + return `${endpointStr}scm/${projectKey}/${repositorySlug}.git`; +} + +function infoMock( + endpoint: URL | string, + projectKey: string, + repositorySlug: string, + options: { cloneUrl: { https: boolean; ssh: boolean } } = { + cloneUrl: { https: true, ssh: true }, + } +): BbsRestRepo { + const endpointStr = endpoint.toString(); + const links: { + self: { href: string }[]; + clone?: { href: string; name: string }[]; + } = { + self: [ + { + href: `${endpointStr}projects/${projectKey}/repos/${repositorySlug}/browse`, + }, + ], + }; + + if (options.cloneUrl.https || options.cloneUrl.ssh) { + links.clone = []; + if (options.cloneUrl.https) { + links.clone.push({ + href: httpLink(endpointStr, projectKey, repositorySlug), + name: 'http', + }); + } + + if (options.cloneUrl.ssh) { + links.clone.push({ + href: sshLink(projectKey, repositorySlug), + name: 'ssh', + }); + } + return { + project: { key: projectKey }, + origin: { name: repositorySlug, slug: repositorySlug }, + links, + } as BbsRestRepo; + } else { + // This mimics the behavior of bb-server which does not include the clone property at all + // if ssh and https are both turned off + return { + project: { key: projectKey }, + origin: { name: repositorySlug, slug: repositorySlug }, + links: { clone: undefined }, + } as BbsRestRepo; + } +} + describe('modules/platform/bitbucket-server/utils', () => { function createError( body: Partial<BitbucketErrorResponse> | undefined = undefined @@ -37,4 +106,167 @@ describe('modules/platform/bitbucket-server/utils', () => { ) ).toStrictEqual([]); }); + + const scenarios = { + 'endpoint with no path': new URL('https://stash.renovatebot.com'), + 'endpoint with path': new URL('https://stash.renovatebot.com/vcs/'), + }; + + describe('getRepoGitUrl', () => { + Object.entries(scenarios).forEach(([scenarioName, url]) => { + describe(scenarioName, () => { + const username = 'abc'; + const password = '123'; + const opts = { + username: username, + password: password, + }; + + it('works gitUrl:undefined generate endpoint', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + undefined, + infoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: false }, + }), + opts + ) + ).toBe( + httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ) + ); + }); + + it('works gitUrl:undefined use endpoint with injected auth', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + undefined, + infoMock(url, 'SOME', 'repo', { + cloneUrl: { https: true, ssh: false }, + }), + opts + ) + ).toBe( + httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ) + ); + }); + + it('works gitUrl:undefined use ssh', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + undefined, + infoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: true }, + }), + opts + ) + ).toBe(sshLink('SOME', 'repo')); + }); + + it('works gitUrl:default', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'default', + infoMock(url, 'SOME', 'repo'), + opts + ) + ).toBe( + httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ) + ); + }); + + it('gitUrl:default invalid http url throws CONFIG_GIT_URL_UNAVAILABLE', () => { + expect(() => + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'default', + infoMock('invalidUrl', 'SOME', 'repo', { + cloneUrl: { https: true, ssh: false }, + }), + opts + ) + ).toThrow(Error(CONFIG_GIT_URL_UNAVAILABLE)); + }); + + it('gitUrl:default no http url returns generated url', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'default', + infoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: false }, + }), + opts + ) + ).toBe( + httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ) + ); + }); + + it('gitUrl:ssh no ssh url throws CONFIG_GIT_URL_UNAVAILABLE', () => { + expect(() => + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'ssh', + infoMock(url, 'SOME', 'repo', { + cloneUrl: { https: false, ssh: false }, + }), + opts + ) + ).toThrow(Error(CONFIG_GIT_URL_UNAVAILABLE)); + }); + + it('works gitUrl:ssh', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'ssh', + infoMock(url, 'SOME', 'repo'), + opts + ) + ).toBe(sshLink('SOME', 'repo')); + }); + + it('works gitUrl:endpoint', () => { + expect( + getRepoGitUrl( + 'SOME/repo', + url.toString(), + 'endpoint', + infoMock(url, 'SOME', 'repo'), + opts + ) + ).toBe( + httpLink(url.toString(), 'SOME', 'repo').replace( + 'https://', + `https://${username}:${password}@` + ) + ); + }); + }); + }); + }); }); diff --git a/lib/modules/platform/bitbucket-server/utils.ts b/lib/modules/platform/bitbucket-server/utils.ts index 4031166e97b38ae210a515624a430085ebb5f73e..a3c1c8ab3b26b5b18c497eed88852295b6bf5a43 100644 --- a/lib/modules/platform/bitbucket-server/utils.ts +++ b/lib/modules/platform/bitbucket-server/utils.ts @@ -1,6 +1,8 @@ // SEE for the reference https://github.com/renovatebot/renovate/blob/c3e9e572b225085448d94aa121c7ec81c14d3955/lib/platform/bitbucket/utils.js import url from 'url'; import is from '@sindresorhus/is'; +import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; import { HostRule, PrState } from '../../../types'; import type { GitProtocol } from '../../../types/git'; import * as git from '../../../util/git'; @@ -10,7 +12,9 @@ import type { HttpPostOptions, HttpResponse, } from '../../../util/http/types'; +import { parseUrl } from '../../../util/url'; import { getPrBodyStruct } from '../pr-body'; +import type { GitUrlOption } from '../types'; import type { BbsPr, BbsRestPr, BbsRestRepo, BitbucketError } from './types'; export const BITBUCKET_INVALID_REVIEWERS_EXCEPTION = @@ -156,39 +160,62 @@ export function getInvalidReviewers(err: BitbucketError): string[] { return invalidReviewers; } +function generateUrlFromEndpoint( + defaultEndpoint: string, + opts: HostRule, + repository: string +): string { + const url = new URL(defaultEndpoint); + const generatedUrl = git.getUrl({ + protocol: url.protocol as GitProtocol, + auth: `${opts.username}:${opts.password}`, + host: `${url.host}${url.pathname}${ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + url.pathname!.endsWith('/') ? '' : /* istanbul ignore next */ '/' + }scm`, + repository, + }); + logger.debug({ url: generatedUrl }, `using generated endpoint URL`); + return generatedUrl; +} + +function injectAuth(url: string, opts: HostRule): string { + const repoUrl = parseUrl(url)!; + if (!repoUrl) { + logger.debug(`Invalid url: ${url}`); + throw new Error(CONFIG_GIT_URL_UNAVAILABLE); + } + // TODO: null checks (#7154) + repoUrl.username = opts.username!; + repoUrl.password = opts.password!; + return repoUrl.toString(); +} + export function getRepoGitUrl( repository: string, defaultEndpoint: string, + gitUrl: GitUrlOption | undefined, info: BbsRestRepo, opts: HostRule ): string { - let cloneUrl = info.links.clone?.find(({ name }) => name === 'http'); - if (!cloneUrl) { - // Http access might be disabled, try to find ssh url in this case - cloneUrl = info.links.clone?.find(({ name }) => name === 'ssh'); + if (gitUrl === 'ssh') { + const sshUrl = info.links.clone?.find(({ name }) => name === 'ssh'); + if (sshUrl === undefined) { + throw new Error(CONFIG_GIT_URL_UNAVAILABLE); + } + logger.debug({ url: sshUrl.href }, `using ssh URL`); + return sshUrl.href; } - - let gitUrl: string; - if (!cloneUrl) { - // Fallback to generating the url if the API didn't give us an URL - const { host, pathname } = url.parse(defaultEndpoint); - // TODO #7154 - gitUrl = git.getUrl({ - protocol: defaultEndpoint.split(':')[0] as GitProtocol, - auth: `${opts.username}:${opts.password}`, - host: `${host}${pathname}${ - pathname!.endsWith('/') ? '' : /* istanbul ignore next */ '/' - }scm`, - repository, - }); - } else if (cloneUrl.name === 'http') { + let cloneUrl = info.links.clone?.find(({ name }) => name === 'http'); + if (cloneUrl) { // Inject auth into the API provided URL - const repoUrl = url.parse(cloneUrl.href); - repoUrl.auth = `${opts.username}:${opts.password}`; - gitUrl = url.format(repoUrl); - } else { - // SSH urls can be used directly - gitUrl = cloneUrl.href; + return injectAuth(cloneUrl.href, opts); + } + // Http access might be disabled, try to find ssh url in this case + cloneUrl = info.links.clone?.find(({ name }) => name === 'ssh'); + if (gitUrl === 'endpoint' || !cloneUrl) { + return generateUrlFromEndpoint(defaultEndpoint, opts, repository); } - return gitUrl; + // SSH urls can be used directly + return cloneUrl.href; }