diff --git a/.changeset/breezy-days-prove.md b/.changeset/breezy-days-prove.md new file mode 100644 index 0000000000..d3bc3c169e --- /dev/null +++ b/.changeset/breezy-days-prove.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Add new bitbucket(Cloud/Server) integrations to ScaffolderClient. diff --git a/.changeset/dirty-eagles-hunt.md b/.changeset/dirty-eagles-hunt.md new file mode 100644 index 0000000000..d3ad27dfc3 --- /dev/null +++ b/.changeset/dirty-eagles-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Split BitbucketUrlReader into BitbucketCloudUrlReader and BitbucketServerUrlReader. Backwards compatible. diff --git a/.changeset/moody-suns-smell.md b/.changeset/moody-suns-smell.md new file mode 100644 index 0000000000..b051335ad7 --- /dev/null +++ b/.changeset/moody-suns-smell.md @@ -0,0 +1,37 @@ +--- +'@backstage/integration': minor +'@backstage/integration-react': minor +--- + +Split `bitbucket` integration into `bitbucketCloud` and `bitbucketServer` +(backwards compatible). + +In order to migrate to the new integration configs, +move your configs from `integrations.bitbucket` +to `integrations.bitbucketCloud` or `integrations.bitbucketServer`. + +Migration example: + +**Before:** + +```yaml +integrations: + bitbucket: + - host: bitbucket.org + username: bitbucket_user + appPassword: app-password + - host: bitbucket-server.company.com + token: my-token +``` + +**After:** + +```yaml +integrations: + bitbucketCloud: + - username: bitbucket_user + appPassword: app-password + bitbucketServer: + - host: bitbucket-server.company.com + token: my-token +``` diff --git a/.changeset/shiny-students-approve.md b/.changeset/shiny-students-approve.md new file mode 100644 index 0000000000..77de3ffc04 --- /dev/null +++ b/.changeset/shiny-students-approve.md @@ -0,0 +1,21 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Split `publish:bitbucket` into `publish:bitbucketCloud` and `publish:bitbucketServer`. + +In order to migrate from the deprecated action, you need to replace the use of action +`publish:bitbucket` in your templates with the use of either `publish:bitbucketCloud` +or `publish:bitbucketServer` - depending on which destination SCM provider you use. + +Additionally, these actions will not utilize `integrations.bitbucket` anymore, +but `integrations.bitbucketCloud` or `integrations.bitbucketServer` respectively. +You may or may not have migrated to these already. + +As described in a previous changeset, using these two replacement integrations configs +will not compromise use cases which still rely on `integrations.bitbucket` as this was +set up in a backwards compatible way. + +Additionally, please mind that the option `enableLFS` is only available (and always was) +for Bitbucket Server use cases and therefore, is not even part of the schema for +`publish:bitbucketCloud` anymore. diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index 67c4dcac33..c0c6afc7a3 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -6,38 +6,51 @@ sidebar_label: Locations description: Integrating source code stored in Bitbucket into the Backstage catalog --- -The Bitbucket integration supports loading catalog entities from bitbucket.org -or a self-hosted Bitbucket. Entities can be added to +The Bitbucket integration supports loading catalog entities from bitbucket.org (Bitbucket Cloud) +or Bitbucket Server. Entities can be added to [static catalog configuration](../../features/software-catalog/configuration.md), or registered with the [catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) plugin. +## Bitbucket Cloud + ```yaml integrations: - bitbucket: - - host: bitbucket.org - token: ${BITBUCKET_TOKEN} + bitbucketCloud: + - username: ${BITBUCKET_CLOUD_USERNAME} + appPassword: ${BITBUCKET_CLOUD_PASSWORD} ``` -> Note: A public Bitbucket provider is added automatically at startup for -> convenience, so you only need to list it if you want to supply a -> [token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html). +> Note: A public Bitbucket Cloud provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply credentials. -Directly under the `bitbucket` key is a list of provider configurations, where -you can list the Bitbucket providers you want to fetch data from. Each entry is -a structure with up to four elements: +Directly under the `bitbucketCloud` key is a list of provider configurations, where +you can list the Bitbucket Cloud providers you want to fetch data from. +In the case of Bitbucket Cloud, you will have up to one entry. -- `host`: The host of the Bitbucket instance, e.g. `bitbucket.company.com`. -- `token` (optional): An personal access token as expected by Bitbucket. Either - an access token **or** a username + appPassword may be supplied. -- `username` (optional): The Bitbucket username to use in API requests. If +This one entry will have the following elements: + +- `username`: The Bitbucket Cloud username to use in API requests. If neither a username nor token are supplied, anonymous access will be used. -- `appPassword` (optional): The password for the Bitbucket user. Only needed - when using `username` instead of `token`. -- `apiBaseUrl` (optional): The URL of the Bitbucket API. For self-hosted - installations, it is commonly at `https:///rest/api/1.0`. For - bitbucket.org, this configuration is not needed as it can be inferred. +- `appPassword`: The app password for the Bitbucket Cloud user. -> Note: If you are using Bitbucket server you MUST set the username as well as -> the token or appPassword. +## Bitbucket Server + +```yaml +integrations: + bitbucketServer: + - host: bitbucket.company.com + token: ${BITBUCKET_SERVER_TOKEN} +``` + +Directly under the `bitbucketServer` key is a list of provider configurations, where +you can list the Bitbucket Server providers you want to fetch data from. Each entry is +a structure with the following elements: + +- `host`: The host of the Bitbucket Server instance, e.g. `bitbucket.company.com`. +- `token` (optional): + An [personal access token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html) + as expected by Bitbucket Server. +- `apiBaseUrl` (optional): The URL of the Bitbucket Server API. For self-hosted + installations, it is commonly at `https:///rest/api/1.0`. diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index cd4d08df87..1e77e5e3de 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -10,7 +10,9 @@ import { AbortController as AbortController_2 } from 'node-abort-controller'; import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; import { AwsS3Integration } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; +import { BitbucketCloudIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; +import { BitbucketServerIntegration } from '@backstage/integration'; import { Config } from '@backstage/config'; import cors from 'cors'; import Docker from 'dockerode'; @@ -83,9 +85,54 @@ export class AzureUrlReader implements UrlReader { } // @public +export class BitbucketCloudUrlReader implements UrlReader { + constructor( + integration: BitbucketCloudIntegration, + deps: { + treeResponseFactory: ReadTreeResponseFactory; + }, + ); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + readUrl(url: string, options?: ReadUrlOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export class BitbucketServerUrlReader implements UrlReader { + constructor( + integration: BitbucketServerIntegration, + deps: { + treeResponseFactory: ReadTreeResponseFactory; + }, + ); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + readUrl(url: string, options?: ReadUrlOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public @deprecated export class BitbucketUrlReader implements UrlReader { constructor( integration: BitbucketIntegration, + logger: Logger, deps: { treeResponseFactory: ReadTreeResponseFactory; }, diff --git a/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts new file mode 100644 index 0000000000..38841d75ad --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts @@ -0,0 +1,362 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { + BitbucketCloudIntegration, + readBitbucketCloudIntegrationConfig, +} from '@backstage/integration'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import os from 'os'; +import path from 'path'; +import { NotModifiedError } from '@backstage/errors'; +import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader'; +import { DefaultReadTreeResponseFactory } from './tree'; + +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + +const reader = new BitbucketCloudUrlReader( + new BitbucketCloudIntegration( + readBitbucketCloudIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + username: 'username', + appPassword: 'password', + }), + ), + ), + { treeResponseFactory }, +); + +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + +describe('BitbucketCloudUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('readUrl', () => { + it('should be able to readUrl without ETag', async () => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml', + (req, res, ctx) => { + expect(req.headers.get('If-None-Match')).toBeNull(); + return res( + ctx.status(200), + ctx.body('foo'), + ctx.set('ETag', 'etag-value'), + ); + }, + ), + ); + + const result = await reader.readUrl( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + ); + const buffer = await result.buffer(); + expect(buffer.toString()).toBe('foo'); + }); + + it('should be able to readUrl with matching ETag', async () => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml', + (req, res, ctx) => { + expect(req.headers.get('If-None-Match')).toBe( + 'matching-etag-value', + ); + return res(ctx.status(304)); + }, + ), + ); + + await expect( + reader.readUrl( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + { etag: 'matching-etag-value' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + + it('should be able to readUrl without matching ETag', async () => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml', + (req, res, ctx) => { + expect(req.headers.get('If-None-Match')).toBe( + 'previous-etag-value', + ); + return res( + ctx.status(200), + ctx.body('foo'), + ctx.set('ETag', 'new-etag-value'), + ); + }, + ), + ); + + const result = await reader.readUrl( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + { etag: 'previous-etag-value' }, + ); + const buffer = await result.buffer(); + expect(buffer.toString()).toBe('foo'); + expect(result.etag).toBe('new-etag-value'); + }); + }); + + describe('read', () => { + it('rejects unknown targets', async () => { + await expect( + reader.read('https://not.bitbucket.com/apa'), + ).rejects.toThrow( + 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket Cloud URL or file path', + ); + }); + }); + + describe('readTree', () => { + const repoBuffer = fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/bitbucket-repo-with-commit-hash.tar.gz', + ), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), + rest.get( + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('returns the wanted files from an archive', async () => { + const response = await reader.readTree( + 'https://bitbucket.org/backstage/mock/src/master', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(2); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + }); + + it('creates a directory with the wanted files', async () => { + const response = await reader.readTree( + 'https://bitbucket.org/backstage/mock', + ); + + const dir = await response.dir({ targetDir: tmpDir }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('returns the wanted files from an archive with a subpath', async () => { + const response = await reader.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('creates a directory with the wanted files with a subpath', async () => { + const response = await reader.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + const dir = await response.dir({ targetDir: tmpDir }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('throws a NotModifiedError when given a etag in options', async () => { + const fnBitbucket = async () => { + await reader.readTree('https://bitbucket.org/backstage/mock', { + etag: '12ab34cd56ef', + }); + }; + + await expect(fnBitbucket).rejects.toThrow(NotModifiedError); + }); + + it('should not throw a NotModifiedError when given an outdated etag in options', async () => { + const response = await reader.readTree( + 'https://bitbucket.org/backstage/mock', + { etag: 'outdatedetag123abc' }, + ); + + expect(response.etag).toBe('12ab34cd56ef'); + }); + }); + + describe('search hosted', () => { + const repoBuffer = fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/bitbucket-repo-with-commit-hash.tar.gz', + ), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), + rest.get( + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await reader.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.org/backstage/mock/src/master/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('works in nested folders', async () => { + const result = await reader.search( + 'https://bitbucket.org/backstage/mock/src/master/docs/index.*', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.org/backstage/mock/src/master/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + reader.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); +}); diff --git a/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts b/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts new file mode 100644 index 0000000000..6a9406e920 --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts @@ -0,0 +1,234 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NotFoundError, NotModifiedError } from '@backstage/errors'; +import { + BitbucketCloudIntegration, + getBitbucketCloudDefaultBranch, + getBitbucketCloudDownloadUrl, + getBitbucketCloudFileFetchUrl, + getBitbucketCloudRequestOptions, + ScmIntegrations, +} from '@backstage/integration'; +import fetch, { Response } from 'node-fetch'; +import parseGitUrl from 'git-url-parse'; +import { trimEnd } from 'lodash'; +import { Minimatch } from 'minimatch'; +import { Readable } from 'stream'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + ReadTreeResponseFactory, + ReadUrlOptions, + ReadUrlResponse, + SearchOptions, + SearchResponse, + UrlReader, +} from './types'; + +/** + * Implements a {@link UrlReader} for files from Bitbucket Cloud. + * + * @public + */ +export class BitbucketCloudUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const integrations = ScmIntegrations.fromConfig(config); + return integrations.bitbucketCloud.list().map(integration => { + const reader = new BitbucketCloudUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; + return { reader, predicate }; + }); + }; + + constructor( + private readonly integration: BitbucketCloudIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { + const { host, username, appPassword } = integration.config; + + if (username && !appPassword) { + throw new Error( + `Bitbucket Cloud integration for '${host}' has configured a username but is missing a required appPassword.`, + ); + } + } + + async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + const { etag, signal } = options ?? {}; + const bitbucketUrl = getBitbucketCloudFileFetchUrl( + url, + this.integration.config, + ); + const requestOptions = getBitbucketCloudRequestOptions( + this.integration.config, + ); + + let response: Response; + try { + response = await fetch(bitbucketUrl.toString(), { + headers: { + ...requestOptions.headers, + ...(etag && { 'If-None-Match': etag }), + }, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), + }); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.status === 304) { + throw new NotModifiedError(); + } + + if (response.ok) { + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + etag: response.headers.get('ETag') ?? undefined, + }; + } + + const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { filepath } = parseGitUrl(url); + + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } + + const downloadUrl = await getBitbucketCloudDownloadUrl( + url, + this.integration.config, + ); + const archiveResponse = await fetch( + downloadUrl, + getBitbucketCloudRequestOptions(this.integration.config), + ); + if (!archiveResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`; + if (archiveResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return await this.deps.treeResponseFactory.fromTarArchive({ + stream: archiveResponse.body as unknown as Readable, + subpath: filepath, + etag: lastCommitShortHash, + filter: options?.filter, + }); + } + + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = trimEnd(url.replace(filepath, ''), '/'); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(path), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; + } + + toString() { + const { host, username, appPassword } = this.integration.config; + const authed = Boolean(username && appPassword); + return `bitbucketCloud{host=${host},authed=${authed}}`; + } + + private async getLastCommitShortHash(url: string): Promise { + const { name: repoName, owner: project, ref } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketCloudDefaultBranch( + url, + this.integration.config, + ); + } + + const commitsApiUrl = `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; + + const commitsResponse = await fetch( + commitsApiUrl, + getBitbucketCloudRequestOptions(this.integration.config), + ); + if (!commitsResponse.ok) { + const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; + if (commitsResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commits = await commitsResponse.json(); + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].hash + ) { + return commits.values[0].hash.substring(0, 12); + } + + throw new Error(`Failed to read response from ${commitsApiUrl}`); + } +} diff --git a/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts new file mode 100644 index 0000000000..1417752494 --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { + BitbucketServerIntegration, + readBitbucketServerIntegrationConfig, +} from '@backstage/integration'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import os from 'os'; +import path from 'path'; +import { NotModifiedError } from '@backstage/errors'; +import { BitbucketServerUrlReader } from './BitbucketServerUrlReader'; +import { DefaultReadTreeResponseFactory } from './tree'; + +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + +const reader = new BitbucketServerUrlReader( + new BitbucketServerIntegration( + readBitbucketServerIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }), + ), + ), + { treeResponseFactory }, +); + +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + +describe('BitbucketServerUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('readTree', () => { + const repoBuffer = fs.readFileSync( + path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.tgz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('uses private bitbucket host', async () => { + const response = await reader.readTree( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + ); + + expect(response.etag).toBe('12ab34cd56ef'); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + }); + + describe('search private', () => { + const repoBuffer = fs.readFileSync( + path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.tgz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await reader.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('works in nested folders', async () => { + const result = await reader.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.*?at=master', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + reader.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); +}); diff --git a/packages/backend-common/src/reading/BitbucketServerUrlReader.ts b/packages/backend-common/src/reading/BitbucketServerUrlReader.ts new file mode 100644 index 0000000000..320b8f2719 --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketServerUrlReader.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NotFoundError, NotModifiedError } from '@backstage/errors'; +import { + BitbucketServerIntegration, + getBitbucketServerDownloadUrl, + getBitbucketServerFileFetchUrl, + getBitbucketServerRequestOptions, + ScmIntegrations, +} from '@backstage/integration'; +import fetch, { Response } from 'node-fetch'; +import parseGitUrl from 'git-url-parse'; +import { trimEnd } from 'lodash'; +import { Minimatch } from 'minimatch'; +import { Readable } from 'stream'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + ReadTreeResponseFactory, + ReadUrlOptions, + ReadUrlResponse, + SearchOptions, + SearchResponse, + UrlReader, +} from './types'; + +/** + * Implements a {@link UrlReader} for files from Bitbucket Server APIs. + * + * @public + */ +export class BitbucketServerUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const integrations = ScmIntegrations.fromConfig(config); + return integrations.bitbucketServer.list().map(integration => { + const reader = new BitbucketServerUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; + return { reader, predicate }; + }); + }; + + constructor( + private readonly integration: BitbucketServerIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) {} + + async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + const { etag, signal } = options ?? {}; + const bitbucketUrl = getBitbucketServerFileFetchUrl( + url, + this.integration.config, + ); + const requestOptions = getBitbucketServerRequestOptions( + this.integration.config, + ); + + let response: Response; + try { + response = await fetch(bitbucketUrl.toString(), { + headers: { + ...requestOptions.headers, + ...(etag && { 'If-None-Match': etag }), + }, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), + }); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.status === 304) { + throw new NotModifiedError(); + } + + if (response.ok) { + return { + buffer: async () => Buffer.from(await response.arrayBuffer()), + etag: response.headers.get('ETag') ?? undefined, + }; + } + + const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { filepath } = parseGitUrl(url); + + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } + + const downloadUrl = await getBitbucketServerDownloadUrl( + url, + this.integration.config, + ); + const archiveResponse = await fetch( + downloadUrl, + getBitbucketServerRequestOptions(this.integration.config), + ); + if (!archiveResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`; + if (archiveResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return await this.deps.treeResponseFactory.fromTarArchive({ + stream: archiveResponse.body as unknown as Readable, + subpath: filepath, + etag: lastCommitShortHash, + filter: options?.filter, + }); + } + + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = trimEnd(url.replace(filepath, ''), '/'); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(path), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; + } + + toString() { + const { host, token } = this.integration.config; + const authed = Boolean(token); + return `bitbucketServer{host=${host},authed=${authed}}`; + } + + private async getLastCommitShortHash(url: string): Promise { + const { name: repoName, owner: project } = parseGitUrl(url); + + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222 + const commitsApiUrl = `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; + + const commitsResponse = await fetch( + commitsApiUrl, + getBitbucketServerRequestOptions(this.integration.config), + ); + if (!commitsResponse.ok) { + const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; + if (commitsResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commits = await commitsResponse.json(); + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].id + ) { + return commits.values[0].id.substring(0, 12); + } + + throw new Error(`Failed to read response from ${commitsApiUrl}`); + } +} diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index eff982581b..3a2360debb 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -29,38 +29,76 @@ import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { DefaultReadTreeResponseFactory } from './tree'; +import { getVoidLogger } from '../logging'; -const treeResponseFactory = DefaultReadTreeResponseFactory.create({ - config: new ConfigReader({}), +const logger = getVoidLogger(); + +describe('BitbucketUrlReader.factory', () => { + it('only apply integration configs not inherited from bitbucketCloud or bitbucketServer', () => { + const config = new ConfigReader({ + integrations: { + bitbucket: [], + bitbucketCloud: [ + { + username: 'username', + appPassword: 'password', + }, + ], + bitbucketServer: [ + { + host: 'bitbucket-server.local', + token: 'test-token', + }, + ], + }, + }); + const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: config, + }); + + const tuples = BitbucketUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); + + expect(tuples).toHaveLength(0); + }); }); -const bitbucketProcessor = new BitbucketUrlReader( - new BitbucketIntegration( - readBitbucketIntegrationConfig( - new ConfigReader({ - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }), - ), - ), - { treeResponseFactory }, -); - -const hostedBitbucketProcessor = new BitbucketUrlReader( - new BitbucketIntegration( - readBitbucketIntegrationConfig( - new ConfigReader({ - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', - }), - ), - ), - { treeResponseFactory }, -); - -const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - describe('BitbucketUrlReader', () => { + const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), + }); + + const bitbucketProcessor = new BitbucketUrlReader( + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }), + ), + ), + logger, + { treeResponseFactory }, + ); + + const hostedBitbucketProcessor = new BitbucketUrlReader( + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }), + ), + ), + logger, + { treeResponseFactory }, + ); + + const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + beforeEach(() => { mockFs({ [tmpDir]: mockFs.directory(), diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index df22f1633f..1687a66262 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -28,6 +28,7 @@ import parseGitUrl from 'git-url-parse'; import { trimEnd } from 'lodash'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; +import { Logger } from 'winston'; import { ReaderFactory, ReadTreeOptions, @@ -45,24 +46,38 @@ import { * as the one exposed by Bitbucket Cloud itself. * * @public + * @deprecated in favor of BitbucketCloudUrlReader and BitbucketServerUrlReader */ export class BitbucketUrlReader implements UrlReader { - static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + static factory: ReaderFactory = ({ config, logger, treeResponseFactory }) => { const integrations = ScmIntegrations.fromConfig(config); - return integrations.bitbucket.list().map(integration => { - const reader = new BitbucketUrlReader(integration, { - treeResponseFactory, + return integrations.bitbucket + .list() + .filter( + item => + !integrations.bitbucketCloud.byHost(item.config.host) && + !integrations.bitbucketServer.byHost(item.config.host), + ) + .map(integration => { + const reader = new BitbucketUrlReader(integration, logger, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; + return { reader, predicate }; }); - const predicate = (url: URL) => url.host === integration.config.host; - return { reader, predicate }; - }); }; constructor( private readonly integration: BitbucketIntegration, + logger: Logger, private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, ) { const { host, token, username, appPassword } = integration.config; + const replacement = + host === 'bitbucket.org' ? 'bitbucketCloud' : 'bitbucketServer'; + logger.warn( + `[Deprecated] Please migrate from "integrations.bitbucket" to "integrations.${replacement}".`, + ); if (!token && username && !appPassword) { throw new Error( diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index e676d0e14c..72833454c3 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -19,6 +19,8 @@ import { Config } from '@backstage/config'; import { ReaderFactory, UrlReader } from './types'; import { UrlReaderPredicateMux } from './UrlReaderPredicateMux'; import { AzureUrlReader } from './AzureUrlReader'; +import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader'; +import { BitbucketServerUrlReader } from './BitbucketServerUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GerritUrlReader } from './GerritUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; @@ -82,6 +84,8 @@ export class UrlReaders { config, factories: factories.concat([ AzureUrlReader.factory, + BitbucketCloudUrlReader.factory, + BitbucketServerUrlReader.factory, BitbucketUrlReader.factory, GerritUrlReader.factory, GithubUrlReader.factory, diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts index c2c82e2bd5..f1609179b5 100644 --- a/packages/backend-common/src/reading/index.ts +++ b/packages/backend-common/src/reading/index.ts @@ -15,7 +15,9 @@ */ export { AzureUrlReader } from './AzureUrlReader'; +export { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader'; export { BitbucketUrlReader } from './BitbucketUrlReader'; +export { BitbucketServerUrlReader } from './BitbucketServerUrlReader'; export { GerritUrlReader } from './GerritUrlReader'; export { GithubUrlReader } from './GithubUrlReader'; export { GitlabUrlReader } from './GitlabUrlReader'; diff --git a/packages/integration-react/dev/DevPage.tsx b/packages/integration-react/dev/DevPage.tsx index 07afbcd660..8ff594d80d 100644 --- a/packages/integration-react/dev/DevPage.tsx +++ b/packages/integration-react/dev/DevPage.tsx @@ -53,6 +53,14 @@ export const DevPage = () => { Bitbucket + + Bitbucket Cloud + + + + Bitbucket Server + + GitHub diff --git a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts index aa291c2815..fb53bbdb12 100644 --- a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts +++ b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts @@ -26,6 +26,6 @@ describe('scmIntegrationsApiRef', () => { it('should be instantiated', () => { const i = ScmIntegrationsApi.fromConfig(new ConfigReader({})); - expect(i.list().length).toBe(5); // The default ones + expect(i.list().length).toBe(6); // The default ones }); }); diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 190278c99f..b99a55647c 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -66,6 +66,35 @@ export type AzureIntegrationConfig = { }; // @public +export class BitbucketCloudIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketCloudIntegrationConfig); + // (undocumented) + get config(): BitbucketCloudIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type BitbucketCloudIntegrationConfig = { + host: string; + apiBaseUrl: string; + username?: string; + appPassword?: string; +}; + +// @public @deprecated export class BitbucketIntegration implements ScmIntegration { constructor(integrationConfig: BitbucketIntegrationConfig); // (undocumented) @@ -86,7 +115,7 @@ export class BitbucketIntegration implements ScmIntegration { get type(): string; } -// @public +// @public @deprecated export type BitbucketIntegrationConfig = { host: string; apiBaseUrl: string; @@ -95,6 +124,34 @@ export type BitbucketIntegrationConfig = { appPassword?: string; }; +// @public +export class BitbucketServerIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketServerIntegrationConfig); + // (undocumented) + get config(): BitbucketServerIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type BitbucketServerIntegrationConfig = { + host: string; + apiBaseUrl: string; + token?: string; +}; + // @public export class DefaultGithubCredentialsProvider implements GithubCredentialsProvider @@ -161,30 +218,80 @@ export function getAzureRequestOptions( }; // @public +export function getBitbucketCloudDefaultBranch( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise; + +// @public +export function getBitbucketCloudDownloadUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise; + +// @public +export function getBitbucketCloudFileFetchUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): string; + +// @public +export function getBitbucketCloudRequestOptions( + config: BitbucketCloudIntegrationConfig, +): { + headers: Record; +}; + +// @public @deprecated export function getBitbucketDefaultBranch( url: string, config: BitbucketIntegrationConfig, ): Promise; -// @public +// @public @deprecated export function getBitbucketDownloadUrl( url: string, config: BitbucketIntegrationConfig, ): Promise; -// @public +// @public @deprecated export function getBitbucketFileFetchUrl( url: string, config: BitbucketIntegrationConfig, ): string; -// @public +// @public @deprecated export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, ): { headers: Record; }; +// @public +export function getBitbucketServerDefaultBranch( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise; + +// @public +export function getBitbucketServerDownloadUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise; + +// @public +export function getBitbucketServerFileFetchUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): string; + +// @public +export function getBitbucketServerRequestOptions( + config: BitbucketServerIntegrationConfig, +): { + headers: Record; +}; + // @public export function getGerritFileContentsApiUrl( config: GerritIntegrationConfig, @@ -332,9 +439,13 @@ export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; // (undocumented) azure: ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) bitbucket: ScmIntegrationsGroup; // (undocumented) + bitbucketCloud: ScmIntegrationsGroup; + // (undocumented) + bitbucketServer: ScmIntegrationsGroup; + // (undocumented) gerrit: ScmIntegrationsGroup; // (undocumented) github: ScmIntegrationsGroup; @@ -366,15 +477,35 @@ export function readAzureIntegrationConfigs( ): AzureIntegrationConfig[]; // @public +export function readBitbucketCloudIntegrationConfig( + config: Config, +): BitbucketCloudIntegrationConfig; + +// @public +export function readBitbucketCloudIntegrationConfigs( + configs: Config[], +): BitbucketCloudIntegrationConfig[]; + +// @public @deprecated export function readBitbucketIntegrationConfig( config: Config, ): BitbucketIntegrationConfig; -// @public +// @public @deprecated export function readBitbucketIntegrationConfigs( configs: Config[], ): BitbucketIntegrationConfig[]; +// @public +export function readBitbucketServerIntegrationConfig( + config: Config, +): BitbucketServerIntegrationConfig; + +// @public +export function readBitbucketServerIntegrationConfigs( + configs: Config[], +): BitbucketServerIntegrationConfig[]; + // @public export function readGerritIntegrationConfig( config: Config, @@ -441,9 +572,13 @@ export interface ScmIntegrationRegistry awsS3: ScmIntegrationsGroup; // (undocumented) azure: ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) bitbucket: ScmIntegrationsGroup; // (undocumented) + bitbucketCloud: ScmIntegrationsGroup; + // (undocumented) + bitbucketServer: ScmIntegrationsGroup; + // (undocumented) gerrit: ScmIntegrationsGroup; // (undocumented) github: ScmIntegrationsGroup; @@ -464,9 +599,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry { get awsS3(): ScmIntegrationsGroup; // (undocumented) get azure(): ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) get bitbucket(): ScmIntegrationsGroup; // (undocumented) + get bitbucketCloud(): ScmIntegrationsGroup; + // (undocumented) + get bitbucketServer(): ScmIntegrationsGroup; + // (undocumented) byHost(host: string): ScmIntegration | undefined; // (undocumented) byUrl(url: string | URL): ScmIntegration | undefined; diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index e2d26be28e..e4aff81c6f 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -31,7 +31,10 @@ export interface Config { token?: string; }>; - /** Integration configuration for Bitbucket */ + /** + * Integration configuration for Bitbucket + * @deprecated replaced by bitbucketCloud and bitbucketServer + */ bitbucket?: Array<{ /** * The hostname of the given Bitbucket instance @@ -60,6 +63,39 @@ export interface Config { appPassword?: string; }>; + /** Integration configuration for Bitbucket Cloud */ + bitbucketCloud?: Array<{ + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username: string; + /** + * Bitbucket Cloud app password used to authenticate requests. + * @visibility secret + */ + appPassword: string; + }>; + + /** Integration configuration for Bitbucket Server */ + bitbucketServer?: Array<{ + /** + * The hostname of the given Bitbucket Server instance + * @visibility frontend + */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the Bitbucket Server API, for example https:///rest/api/1.0 + * @visibility frontend + */ + apiBaseUrl?: string; + }>; + /** Integration configuration for Gerrit */ gerrit?: Array<{ /** diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index 2e6f2f1472..d9fcaf36f0 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -17,8 +17,16 @@ import { AwsS3IntegrationConfig } from './awsS3'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegrationConfig } from './azure'; import { AzureIntegration } from './azure/AzureIntegration'; +import { + BitbucketCloudIntegration, + BitbucketCloudIntegrationConfig, +} from './bitbucketCloud'; import { BitbucketIntegrationConfig } from './bitbucket'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { + BitbucketServerIntegration, + BitbucketServerIntegrationConfig, +} from './bitbucketServer'; import { GerritIntegrationConfig } from './gerrit'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegrationConfig } from './github'; @@ -41,6 +49,14 @@ describe('ScmIntegrations', () => { host: 'bitbucket.local', } as BitbucketIntegrationConfig); + const bitbucketCloud = new BitbucketCloudIntegration({ + host: 'bitbucket.org', + } as BitbucketCloudIntegrationConfig); + + const bitbucketServer = new BitbucketServerIntegration({ + host: 'bitbucket-server.local', + } as BitbucketServerIntegrationConfig); + const gerrit = new GerritIntegration({ host: 'gerrit.local', } as GerritIntegrationConfig); @@ -57,6 +73,11 @@ describe('ScmIntegrations', () => { awsS3: basicIntegrations([awsS3], item => item.config.host), azure: basicIntegrations([azure], item => item.config.host), bitbucket: basicIntegrations([bitbucket], item => item.config.host), + bitbucketCloud: basicIntegrations([bitbucketCloud], item => item.title), + bitbucketServer: basicIntegrations( + [bitbucketServer], + item => item.config.host, + ), gerrit: basicIntegrations([gerrit], item => item.config.host), github: basicIntegrations([github], item => item.config.host), gitlab: basicIntegrations([gitlab], item => item.config.host), @@ -66,6 +87,12 @@ describe('ScmIntegrations', () => { expect(i.awsS3.byUrl('https://awss3.local')).toBe(awsS3); expect(i.azure.byUrl('https://azure.local')).toBe(azure); expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.bitbucketCloud.byUrl('https://bitbucket.org')).toBe( + bitbucketCloud, + ); + expect(i.bitbucketServer.byUrl('https://bitbucket-server.local')).toBe( + bitbucketServer, + ); expect(i.gerrit.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.github.byUrl('https://github.local')).toBe(github); expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab); @@ -73,7 +100,16 @@ describe('ScmIntegrations', () => { it('can list', () => { expect(i.list()).toEqual( - expect.arrayContaining([awsS3, azure, bitbucket, gerrit, github, gitlab]), + expect.arrayContaining([ + awsS3, + azure, + bitbucket, + bitbucketCloud, + bitbucketServer, + gerrit, + github, + gitlab, + ]), ); }); @@ -81,6 +117,8 @@ describe('ScmIntegrations', () => { expect(i.byUrl('https://awss3.local')).toBe(awsS3); expect(i.byUrl('https://azure.local')).toBe(azure); expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.byUrl('https://bitbucket.org')).toBe(bitbucketCloud); + expect(i.byUrl('https://bitbucket-server.local')).toBe(bitbucketServer); expect(i.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.byUrl('https://github.local')).toBe(github); expect(i.byUrl('https://gitlab.local')).toBe(gitlab); @@ -88,6 +126,8 @@ describe('ScmIntegrations', () => { expect(i.byHost('awss3.local')).toBe(awsS3); expect(i.byHost('azure.local')).toBe(azure); expect(i.byHost('bitbucket.local')).toBe(bitbucket); + expect(i.byHost('bitbucket.org')).toBe(bitbucketCloud); + expect(i.byHost('bitbucket-server.local')).toBe(bitbucketServer); expect(i.byHost('gerrit.local')).toBe(gerrit); expect(i.byHost('github.local')).toBe(github); expect(i.byHost('gitlab.local')).toBe(gitlab); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 8638b814ec..3e712cc012 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -17,7 +17,9 @@ import { Config } from '@backstage/config'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; @@ -33,7 +35,12 @@ import { ScmIntegrationRegistry } from './registry'; export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; + /** + * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` + */ bitbucket: ScmIntegrationsGroup; + bitbucketCloud: ScmIntegrationsGroup; + bitbucketServer: ScmIntegrationsGroup; gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup; @@ -52,6 +59,8 @@ export class ScmIntegrations implements ScmIntegrationRegistry { awsS3: AwsS3Integration.factory({ config }), azure: AzureIntegration.factory({ config }), bitbucket: BitbucketIntegration.factory({ config }), + bitbucketCloud: BitbucketCloudIntegration.factory({ config }), + bitbucketServer: BitbucketServerIntegration.factory({ config }), gerrit: GerritIntegration.factory({ config }), github: GitHubIntegration.factory({ config }), gitlab: GitLabIntegration.factory({ config }), @@ -70,10 +79,21 @@ export class ScmIntegrations implements ScmIntegrationRegistry { return this.byType.azure; } + /** + * @deprecated in favor of `bitbucketCloud()` and `bitbucketServer()` + */ get bitbucket(): ScmIntegrationsGroup { return this.byType.bitbucket; } + get bitbucketCloud(): ScmIntegrationsGroup { + return this.byType.bitbucketCloud; + } + + get bitbucketServer(): ScmIntegrationsGroup { + return this.byType.bitbucketServer; + } + get gerrit(): ScmIntegrationsGroup { return this.byType.gerrit; } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 6bbe1be27b..575e1c9b6f 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -18,25 +18,52 @@ import { ConfigReader } from '@backstage/config'; import { BitbucketIntegration } from './BitbucketIntegration'; describe('BitbucketIntegration', () => { - it('has a working factory', () => { - const integrations = BitbucketIntegration.factory({ - config: new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'h.com', - apiBaseUrl: 'a', - token: 't', - username: 'u', - appPassword: 'p', - }, - ], - }, - }), + describe('factory', () => { + it('works', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('bitbucket.org'); + }); + + it('falls back to bitbucketCloud+bitbucketServer', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + bitbucketServer: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('bitbucket.org'); + expect(integrations.list()[1].config.host).toBe('h.com'); }); - expect(integrations.list().length).toBe(2); // including default - expect(integrations.list()[0].config.host).toBe('h.com'); - expect(integrations.list()[1].config.host).toBe('bitbucket.org'); }); it('returns the basics', () => { diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index 0162463e3c..d92532b54a 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -26,13 +26,21 @@ import { * A Bitbucket based integration. * * @public + * @deprecated replaced by the integrations bitbucketCloud and bitbucketServer. */ export class BitbucketIntegration implements ScmIntegration { static factory: ScmIntegrationsFactory = ({ config, }) => { const configs = readBitbucketIntegrationConfigs( - config.getOptionalConfigArray('integrations.bitbucket') ?? [], + config.getOptionalConfigArray('integrations.bitbucket') ?? [ + // if integrations.bitbucket was not used assume the use was migrated to the new configs + // and backport for the deprecated integration to be usable for other parts of the system + // until these got migrated + ...(config.getOptionalConfigArray('integrations.bitbucketCloud') ?? []), + ...(config.getOptionalConfigArray('integrations.bitbucketServer') ?? + []), + ], ); return basicIntegrations( configs.map(c => new BitbucketIntegration(c)), diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index 44a2f0cc1f..11839094da 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -25,6 +25,7 @@ const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0'; * The configuration parameters for a single Bitbucket API provider. * * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export type BitbucketIntegrationConfig = { /** @@ -68,6 +69,7 @@ export type BitbucketIntegrationConfig = { * * @param config - The config object of a single integration * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function readBitbucketIntegrationConfig( config: Config, @@ -107,6 +109,7 @@ export function readBitbucketIntegrationConfig( * * @param configs - All of the integration config objects * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function readBitbucketIntegrationConfigs( configs: Config[], diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index da10152bf8..f6018ad27a 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -24,6 +24,7 @@ import { BitbucketIntegrationConfig } from './config'; * @param url - A URL pointing to a path * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export async function getBitbucketDefaultBranch( url: string, @@ -75,6 +76,7 @@ export async function getBitbucketDefaultBranch( * @param url - A URL pointing to a path * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export async function getBitbucketDownloadUrl( url: string, @@ -119,6 +121,7 @@ export async function getBitbucketDownloadUrl( * @param url - A URL pointing to a file * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function getBitbucketFileFetchUrl( url: string, @@ -155,6 +158,7 @@ export function getBitbucketFileFetchUrl( * * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, diff --git a/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts new file mode 100644 index 0000000000..4516b78f90 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { BitbucketCloudIntegration } from './BitbucketCloudIntegration'; + +describe('BitbucketCloudIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketCloudIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); + expect(integrations.list()[0].config.username).toBe('u'); + expect(integrations.list()[0].config.appPassword).toBe('p'); + }); + + it('returns the basics', () => { + const integration = new BitbucketCloudIntegration({ + host: 'bitbucket.org', + } as any); + expect(integration.type).toBe('bitbucketCloud'); + expect(integration.title).toBe('bitbucket.org'); + }); + + it('resolves url line number correctly', () => { + const integration = new BitbucketCloudIntegration({} as any); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://bitbucket.org/my-owner/my-project/src/master/README.md', + lineNumber: 14, + }), + ).toBe( + 'https://bitbucket.org/my-owner/my-project/src/master/a.yaml#lines-14', + ); + }); + + it('resolve edit URL', () => { + const integration = new BitbucketCloudIntegration({} as any); + + expect( + integration.resolveEditUrl( + 'https://bitbucket.org/my-owner/my-project/src/master/README.md', + ), + ).toBe( + 'https://bitbucket.org/my-owner/my-project/src/master/README.md?mode=edit&at=master', + ); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts new file mode 100644 index 0000000000..d05c1fd4a0 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; + +/** + * A Bitbucket Cloud based integration. + * + * @public + */ +export class BitbucketCloudIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketCloudIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucketCloud') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketCloudIntegration(c)), + i => i.config.host, + ); + }; + + constructor( + private readonly integrationConfig: BitbucketCloudIntegrationConfig, + ) {} + + get type(): string { + return 'bitbucketCloud'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketCloudIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const resolved = defaultScmResolveUrl(options); + + // Bitbucket Cloud line numbers use the syntax #lines-42, rather than #L42 + if (options.lineNumber) { + const url = new URL(resolved); + + url.hash = `lines-${options.lineNumber}`; + return url.toString(); + } + + return resolved; + } + + resolveEditUrl(url: string): string { + const urlData = parseGitUrl(url); + const editUrl = new URL(url); + + editUrl.searchParams.set('mode', 'edit'); + editUrl.searchParams.set('at', urlData.ref); + return editUrl.toString(); + } +} diff --git a/packages/integration/src/bitbucketCloud/config.test.ts b/packages/integration/src/bitbucketCloud/config.test.ts new file mode 100644 index 0000000000..9f7a66e2bf --- /dev/null +++ b/packages/integration/src/bitbucketCloud/config.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + BitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; + +describe('readBitbucketCloudIntegrationConfig', () => { + function buildConfig(data: Partial): Config { + return new ConfigReader(data); + } + + async function buildFrontendConfig( + data: Partial, + ): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { bitbucketCloud: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader(processed[0].data as any); + } + + it('reads all values', () => { + const output = readBitbucketCloudIntegrationConfig( + buildConfig({ + username: 'u', + appPassword: 'p', + }), + ); + expect(output).toEqual({ + apiBaseUrl: 'https://api.bitbucket.org/2.0', + appPassword: 'p', + host: 'bitbucket.org', + username: 'u', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + username: 'u', + appPassword: 'p', + }; + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, username: 7 }), + ), + ).toThrow(/username/); + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, appPassword: 7 }), + ), + ).toThrow(/appPassword/); + }); + + it('credentials hidden on the frontend', async () => { + const frontendConfig = await buildFrontendConfig({ + appPassword: 'p', + username: 'u', + }); + expect( + readBitbucketCloudIntegrationConfigs( + frontendConfig.getOptionalConfigArray('integrations.bitbucketCloud') ?? + [], + ), + ).toEqual([ + { + apiBaseUrl: 'https://api.bitbucket.org/2.0', + host: 'bitbucket.org', + }, + ]); + }); +}); + +describe('readBitbucketCloudIntegrationConfigs', () => { + function buildConfig( + data: Partial[], + ): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readBitbucketCloudIntegrationConfigs( + buildConfig([ + { + username: 'u', + appPassword: 'p', + }, + ]), + ); + expect(output).toContainEqual({ + apiBaseUrl: 'https://api.bitbucket.org/2.0', + appPassword: 'p', + host: 'bitbucket.org', + username: 'u', + }); + }); + + it('adds a default Bitbucket Cloud entry when missing', () => { + const output = readBitbucketCloudIntegrationConfigs(buildConfig([])); + expect(output).toEqual([ + { + apiBaseUrl: 'https://api.bitbucket.org/2.0', + host: 'bitbucket.org', + }, + ]); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts new file mode 100644 index 0000000000..710957900e --- /dev/null +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; + +const BITBUCKET_CLOUD_HOST = 'bitbucket.org'; +const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0'; + +/** + * The configuration parameters for a single Bitbucket Cloud API provider. + * + * @public + */ +export type BitbucketCloudIntegrationConfig = { + /** + * Constant. bitbucket.org + */ + host: string; + + /** + * Constant. https://api.bitbucket.org/2.0 + */ + apiBaseUrl: string; + + /** + * The username to use for requests to Bitbucket Cloud (bitbucket.org). + */ + username?: string; + + /** + * Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/ + */ + appPassword?: string; +}; + +/** + * Reads a single Bitbucket Cloud integration config. + * + * @param config - The config object of a single integration + * @public + */ +export function readBitbucketCloudIntegrationConfig( + config: Config, +): BitbucketCloudIntegrationConfig { + const host = BITBUCKET_CLOUD_HOST; + const apiBaseUrl = BITBUCKET_CLOUD_API_BASE_URL; + // If config is provided, we assume authenticated access is desired + // (as the anonymous one is provided by default). + const username = config.getString('username'); + const appPassword = config.getString('appPassword'); + + return { + host, + apiBaseUrl, + username, + appPassword, + }; +} + +/** + * Reads a set of Bitbucket Cloud integration configs, + * and inserts one for public Bitbucket Cloud if none specified. + * + * @param configs - All of the integration config objects + * @public + */ +export function readBitbucketCloudIntegrationConfigs( + configs: Config[], +): BitbucketCloudIntegrationConfig[] { + // First read all the explicit integrations + const result = configs.map(readBitbucketCloudIntegrationConfig); + + // If no explicit bitbucket.org integration was added, + // put one in the list as a convenience + if (result.length === 0) { + result.push({ + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + }); + } + + return result; +} diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts new file mode 100644 index 0000000000..5ce2b74862 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { BitbucketCloudIntegrationConfig } from './config'; +import { + getBitbucketCloudDefaultBranch, + getBitbucketCloudDownloadUrl, + getBitbucketCloudFileFetchUrl, + getBitbucketCloudRequestOptions, +} from './core'; + +describe('bitbucketCloud core', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('getBitbucketCloudRequestOptions', () => { + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + ( + getBitbucketCloudRequestOptions(withUsernameAndPassword) + .headers as any + ).Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + ( + getBitbucketCloudRequestOptions(withoutUsernameAndPassword) + .headers as any + ).Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketCloudFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketCloudIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect(() => getBitbucketCloudFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getBitbucketCloudFileFetchUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, + ), + ).toEqual( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ); + }); + }); + + describe('getBitbucketCloudDownloadUrl', () => { + it('do not add path param for Bitbucket Cloud', async () => { + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const result = await getBitbucketCloudDownloadUrl( + 'https://bitbucket.org/backstage/mock/src/master', + config, + ); + expect(result).toEqual( + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', + ); + }); + }); + + describe('getBitbucketCloudDefaultBranch', () => { + it('return default branch for Bitbucket Cloud', async () => { + const repoInfoResponse = { + mainbranch: { + name: 'main', + }, + }; + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoInfoResponse), + ), + ), + ); + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const defaultBranch = await getBitbucketCloudDefaultBranch( + 'https://bitbucket.org/backstage/mock/src/main', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts new file mode 100644 index 0000000000..37feb5dad6 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { BitbucketCloudIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketCloudDefaultBranch( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise { + const { name: repoName, owner: project } = parseGitUrl(url); + + const branchUrl = `${config.apiBaseUrl}/repositories/${project}/${repoName}`; + const response = await fetch( + branchUrl, + getBitbucketCloudRequestOptions(config), + ); + + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + const repoInfo = await response.json(); + const defaultBranch = repoInfo.mainbranch.name; + if (!defaultBranch) { + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketCloudDownloadUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise { + const { + name: repoName, + owner: project, + ref, + protocol, + resource, + } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketCloudDefaultBranch(url, config); + } + return `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tar.gz`; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * @remarks + * + * Converts + * from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + * to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + * + * @param url - A URL pointing to a file + * @param config - The relevant provider config + * @public + */ +export function getBitbucketCloudFileFetchUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if (!owner || !name || (filepathtype !== 'src' && filepathtype !== 'raw')) { + throw new Error('Invalid Bitbucket Cloud URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (!ref) { + throw new Error('Invalid Bitbucket Cloud URL or file path'); + } + return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config - The relevant provider config + * @public + */ +export function getBitbucketCloudRequestOptions( + config: BitbucketCloudIntegrationConfig, +): { headers: Record } { + const headers: Record = {}; + + if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucketCloud/index.ts b/packages/integration/src/bitbucketCloud/index.ts new file mode 100644 index 0000000000..7119d3ea90 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BitbucketCloudIntegration } from './BitbucketCloudIntegration'; +export { + readBitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; +export type { BitbucketCloudIntegrationConfig } from './config'; +export { + getBitbucketCloudDefaultBranch, + getBitbucketCloudDownloadUrl, + getBitbucketCloudFileFetchUrl, + getBitbucketCloudRequestOptions, +} from './core'; diff --git a/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts new file mode 100644 index 0000000000..24d7935e72 --- /dev/null +++ b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { BitbucketServerIntegration } from './BitbucketServerIntegration'; + +describe('BitbucketServerIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketServerIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); + expect(integrations.list()[0].config.host).toBe('h.com'); + }); + + it('returns the basics', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + expect(integration.type).toBe('bitbucketServer'); + expect(integration.title).toBe('h.com'); + }); + + it('resolves url line number correctly', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://h.com/my-owner/my-project/src/master/README.md', + lineNumber: 14, + }), + ).toBe('https://h.com/my-owner/my-project/src/master/a.yaml#a.yaml-14'); + }); + + it('resolve edit URL', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + + expect( + integration.resolveEditUrl( + 'https://h.com/my-owner/my-project/src/master/README.md', + ), + ).toBe( + 'https://h.com/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master', + ); + }); +}); diff --git a/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts new file mode 100644 index 0000000000..10055aeddc --- /dev/null +++ b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; + +/** + * A Bitbucket Server based integration. + * + * @public + */ +export class BitbucketServerIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketServerIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucketServer') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketServerIntegration(c)), + i => i.config.host, + ); + }; + + constructor( + private readonly integrationConfig: BitbucketServerIntegrationConfig, + ) {} + + get type(): string { + return 'bitbucketServer'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketServerIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const resolved = defaultScmResolveUrl(options); + + // Bitbucket Server line numbers use the syntax #example.txt-42, rather than #L42 + if (options.lineNumber) { + const url = new URL(resolved); + + const filename = url.pathname.split('/').slice(-1)[0]; + url.hash = `${filename}-${options.lineNumber}`; + return url.toString(); + } + + return resolved; + } + + resolveEditUrl(url: string): string { + const urlData = parseGitUrl(url); + const editUrl = new URL(url); + + editUrl.searchParams.set('mode', 'edit'); + // TODO: Not sure what spa=0 does, at least bitbucket.org doesn't support it + // but this is taken over from the initial implementation. + editUrl.searchParams.set('spa', '0'); + editUrl.searchParams.set('at', urlData.ref); + return editUrl.toString(); + } +} diff --git a/packages/integration/src/bitbucketServer/config.test.ts b/packages/integration/src/bitbucketServer/config.test.ts new file mode 100644 index 0000000000..83c28f5e9d --- /dev/null +++ b/packages/integration/src/bitbucketServer/config.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + BitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; + +describe('readBitbucketServerIntegrationConfig', () => { + function buildConfig( + data: Partial, + ): Config { + return new ConfigReader(data); + } + + async function buildFrontendConfig( + data: Partial, + ): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { bitbucketServer: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader( + (processed[0].data as any).integrations.bitbucketServer[0], + ); + } + + it('reads all values', () => { + const output = readBitbucketServerIntegrationConfig( + buildConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }), + ); + expect(output).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }; + expect(() => + readBitbucketServerIntegrationConfig(buildConfig({ ...valid, host: 7 })), + ).toThrow(/host/); + expect(() => + readBitbucketServerIntegrationConfig( + buildConfig({ ...valid, apiBaseUrl: 7 }), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readBitbucketServerIntegrationConfig(buildConfig({ ...valid, token: 7 })), + ).toThrow(/token/); + }); + + it('works on the frontend', async () => { + expect( + readBitbucketServerIntegrationConfig( + await buildFrontendConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }), + ), + ).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + }); + }); +}); + +describe('readBitbucketServerIntegrationConfigs', () => { + function buildConfig( + data: Partial[], + ): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readBitbucketServerIntegrationConfigs( + buildConfig([ + { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }, + ]), + ); + expect(output).toContainEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }); + }); + + it('adds no default Bitbucket Server entry when missing', () => { + const output = readBitbucketServerIntegrationConfigs(buildConfig([])); + expect(output).toEqual([]); + }); + + it('injects the correct Bitbucket Server API base URL when missing', () => { + const output = readBitbucketServerIntegrationConfigs( + buildConfig([{ host: 'bitbucket.company.com' }]), + ); + expect(output).toEqual([ + { + host: 'bitbucket.company.com', + apiBaseUrl: 'https://bitbucket.company.com/rest/api/1.0', + }, + ]); + }); +}); diff --git a/packages/integration/src/bitbucketServer/config.ts b/packages/integration/src/bitbucketServer/config.ts new file mode 100644 index 0000000000..ec7930616c --- /dev/null +++ b/packages/integration/src/bitbucketServer/config.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; +import { isValidHost } from '../helpers'; + +/** + * The configuration parameters for a single Bitbucket Server API provider. + * + * @public + */ +export type BitbucketServerIntegrationConfig = { + /** + * The host of the target that this matches on, e.g. "bitbucket.company.com" + */ + host: string; + + /** + * The base URL of the API of this provider, e.g. "https:///rest/api/1.0", + * with no trailing slash. + * + * The API will always be preferred if both its base URL and a token are + * present. + */ + apiBaseUrl: string; + + /** + * The authorization token to use for requests to a Bitbucket Server provider. + * + * See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html + * + * If no token is specified, anonymous access is used. + */ + token?: string; +}; + +/** + * Reads a single Bitbucket Server integration config. + * + * @param config - The config object of a single integration + * @public + */ +export function readBitbucketServerIntegrationConfig( + config: Config, +): BitbucketServerIntegrationConfig { + const host = config.getString('host'); + let apiBaseUrl = config.getOptionalString('apiBaseUrl'); + const token = config.getOptionalString('token'); + + if (!isValidHost(host)) { + throw new Error( + `Invalid Bitbucket Server integration config, '${host}' is not a valid host`, + ); + } + + if (apiBaseUrl) { + apiBaseUrl = trimEnd(apiBaseUrl, '/'); + } else { + apiBaseUrl = `https://${host}/rest/api/1.0`; + } + + return { + host, + apiBaseUrl, + token, + }; +} + +/** + * Reads a set of Bitbucket Server integration configs. + * + * @param configs - All of the integration config objects + * @public + */ +export function readBitbucketServerIntegrationConfigs( + configs: Config[], +): BitbucketServerIntegrationConfig[] { + // Read all the explicit integrations + // No default integration will be added + return configs.map(readBitbucketServerIntegrationConfig); +} diff --git a/packages/integration/src/bitbucketServer/core.test.ts b/packages/integration/src/bitbucketServer/core.test.ts new file mode 100644 index 0000000000..1c8f5d259c --- /dev/null +++ b/packages/integration/src/bitbucketServer/core.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { BitbucketServerIntegrationConfig } from './config'; +import { + getBitbucketServerDefaultBranch, + getBitbucketServerDownloadUrl, + getBitbucketServerFileFetchUrl, + getBitbucketServerRequestOptions, +} from './core'; + +describe('bitbucketServer core', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('getBitbucketServerRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketServerRequestOptions(withToken).headers as any) + .Authorization, + ).toEqual('Bearer A'); + expect( + (getBitbucketServerRequestOptions(withoutToken).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketServerFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect(() => getBitbucketServerFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Server', () => { + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getBitbucketServerFileFetchUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=', + ); + }); + }); + + describe('getBitbucketServerDownloadUrl', () => { + it('add path param if a path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param if no path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse', + config, + ); + + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock', + ); + }); + + it('get by branch for Bitbucket Server', async () => { + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=some-branch&prefix=backstage-mock&path=docs', + ); + }); + }); + + describe('getBitbucketServerDefaultBranch', () => { + it('return default branch for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketServerDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(404), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketServerDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); +}); diff --git a/packages/integration/src/bitbucketServer/core.ts b/packages/integration/src/bitbucketServer/core.ts new file mode 100644 index 0000000000..80dbc0443f --- /dev/null +++ b/packages/integration/src/bitbucketServer/core.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { BitbucketServerIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketServerDefaultBranch( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise { + const { name: repoName, owner: project } = parseGitUrl(url); + + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 + let branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; + + let response = await fetch( + branchUrl, + getBitbucketServerRequestOptions(config), + ); + + if (response.status === 404) { + // First try the new format, and then if it gets specifically a 404 it should try the old format + // (to support old Atlassian Bitbucket Server v5.11.1 format ) + branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + response = await fetch(branchUrl, getBitbucketServerRequestOptions(config)); + } + + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + const { displayId } = await response.json(); + const defaultBranch = displayId; + if (!defaultBranch) { + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketServerDownloadUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise { + const { name: repoName, owner: project, ref, filepath } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketServerDefaultBranch(url, config); + } + // path will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will download the docs folder and everything below it + const path = filepath ? `&path=${encodeURIComponent(filepath)}` : ''; + return `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=tgz&at=${branch}&prefix=${project}-${repoName}${path}`; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * @remarks + * + * Converts + * from: https://bitbucket.company.com/projectname/reponame/src/main/file.yaml + * to: https://bitbucket.company.com/rest/api/1.0/project/projectname/reponame/raw/file.yaml?at=main + * + * @param url - A URL pointing to a file + * @param config - The relevant provider config + * @public + */ +export function getBitbucketServerFileFetchUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket Server URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config - The relevant provider config + * @public + */ +export function getBitbucketServerRequestOptions( + config: BitbucketServerIntegrationConfig, +): { headers: Record } { + const headers: Record = {}; + + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucketServer/index.ts b/packages/integration/src/bitbucketServer/index.ts new file mode 100644 index 0000000000..23683ddbff --- /dev/null +++ b/packages/integration/src/bitbucketServer/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BitbucketServerIntegration } from './BitbucketServerIntegration'; +export { + readBitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; +export type { BitbucketServerIntegrationConfig } from './config'; +export { + getBitbucketServerDefaultBranch, + getBitbucketServerDownloadUrl, + getBitbucketServerFileFetchUrl, + getBitbucketServerRequestOptions, +} from './core'; diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts index 60a2789f05..06feb4104e 100644 --- a/packages/integration/src/helpers.test.ts +++ b/packages/integration/src/helpers.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BitbucketIntegration } from './bitbucket'; +import { BitbucketServerIntegration } from './bitbucketServer'; import { basicIntegrations, defaultScmResolveUrl, @@ -24,11 +24,11 @@ import { describe('basicIntegrations', () => { describe('byUrl', () => { it('handles hosts without a port', () => { - const integration = new BitbucketIntegration({ + const integration = new BitbucketServerIntegration({ host: 'host.com', apiBaseUrl: 'a', }); - const integrations = basicIntegrations( + const integrations = basicIntegrations( [integration], i => i.config.host, ); @@ -36,11 +36,11 @@ describe('basicIntegrations', () => { expect(integrations.byUrl('https://host.com:8080/a')).toBeUndefined(); }); it('handles hosts with a port', () => { - const integration = new BitbucketIntegration({ + const integration = new BitbucketServerIntegration({ host: 'host.com:8080', apiBaseUrl: 'a', }); - const integrations = basicIntegrations( + const integrations = basicIntegrations( [integration], i => i.config.host, ); diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index cf0eeca51a..388b874275 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -20,13 +20,15 @@ * @packageDocumentation */ +export * from './awsS3'; export * from './azure'; export * from './bitbucket'; +export * from './bitbucketCloud'; +export * from './bitbucketServer'; export * from './gerrit'; export * from './github'; export * from './gitlab'; export * from './googleGcs'; -export * from './awsS3'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; export type { IntegrationsByType } from './ScmIntegrations'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts index 5ab5d049fb..f4f759cfe9 100644 --- a/packages/integration/src/registry.ts +++ b/packages/integration/src/registry.ts @@ -17,7 +17,9 @@ import { ScmIntegration, ScmIntegrationsGroup } from './types'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; @@ -31,7 +33,12 @@ export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; + /** + * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` + */ bitbucket: ScmIntegrationsGroup; + bitbucketCloud: ScmIntegrationsGroup; + bitbucketServer: ScmIntegrationsGroup; gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup; diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 4090e47510..ff00ed5f0b 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -198,7 +198,7 @@ export function createPublishAzureAction(options: { gitAuthorEmail?: string | undefined; }>; -// @public +// @public @deprecated export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; config: Config; @@ -215,6 +215,33 @@ export function createPublishBitbucketAction(options: { gitAuthorEmail?: string | undefined; }>; +// @public +export function createPublishBitbucketCloudAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}): TemplateAction<{ + repoUrl: string; + description?: string | undefined; + defaultBranch?: string | undefined; + repoVisibility?: 'private' | 'public' | undefined; + sourcePath?: string | undefined; + token?: string | undefined; +}>; + +// @public +export function createPublishBitbucketServerAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}): TemplateAction<{ + repoUrl: string; + description?: string | undefined; + defaultBranch?: string | undefined; + repoVisibility?: 'private' | 'public' | undefined; + sourcePath?: string | undefined; + enableLFS?: boolean | undefined; + token?: string | undefined; +}>; + // @public export function createPublishFileAction(): TemplateAction<{ path: string; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 93c5991ca9..0d269019df 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -37,6 +37,8 @@ import { import { createPublishAzureAction, createPublishBitbucketAction, + createPublishBitbucketCloudAction, + createPublishBitbucketServerAction, createPublishGithubAction, createPublishGithubPullRequestAction, createPublishGitlabAction, @@ -129,6 +131,14 @@ export const createBuiltinActions = ( integrations, config, }), + createPublishBitbucketCloudAction({ + integrations, + config, + }), + createPublishBitbucketServerAction({ + integrations, + config, + }), createPublishAzureAction({ integrations, config, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 769905e668..8f72157fc2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -198,6 +198,7 @@ const performEnableLFS = async (opts: { * Creates a new action that initializes a git repository of the content in the workspace * and publishes it to Bitbucket. * @public + * @deprecated in favor of createPublishBitbucketCloudAction and createPublishBitbucketServerAction */ export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; @@ -292,6 +293,9 @@ export function createPublishBitbucketAction(options: { }, }, async handler(ctx) { + ctx.logger.warn( + `[Deprecated] Please migrate the use of action "publish:bitbucket" to "publish:bitbucketCloud" or "publish:bitbucketServer".`, + ); const { repoUrl, description, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts new file mode 100644 index 0000000000..b21a47f506 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts @@ -0,0 +1,480 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('../helpers'); + +import { createPublishBitbucketCloudAction } from './bitbucketCloud'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +import { initRepoAndPush } from '../helpers'; + +describe('publish:bitbucketCloud', () => { + const config = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketCloudAction({ integrations, config }); + const mockContext = { + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }, + workspacePath: 'wsp', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + const server = setupServer(); + setupRequestMockHandlers(server); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'bitbucket.org?project=project&repo=repo', + }, + }), + ).rejects.toThrow(/missing workspace/); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'bitbucket.org?workspace=workspace&repo=repo', + }, + }), + ).rejects.toThrow(/missing project/); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'bitbucket.org?workspace=workspace&project=project', + }, + }), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'missing.com?workspace=workspace&project=project&repo=repo', + }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should throw if there is no token in the integration config that is returned', async () => { + const configNoCreds = new ConfigReader({ + integrations: { + bitbucketCloud: [], + }, + }); + + const integrationsNoCreds = ScmIntegrations.fromConfig(configNoCreds); + const actionNoCreds = createPublishBitbucketCloudAction({ + integrations: integrationsNoCreds, + config: configNoCreds, + }); + + await expect(actionNoCreds.handler(mockContext)).rejects.toThrow( + /Authorization has not been provided for Bitbucket Cloud/, + ); + }); + + it('should call the correct APIs', async () => { + expect.assertions(2); + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic dTpw'); + expect(req.body).toEqual({ + is_private: true, + scm: 'git', + project: { key: 'project' }, + }); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler(mockContext); + }); + + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + const token = 'user-token'; + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(req.body).toEqual({ + is_private: true, + scm: 'git', + project: { key: 'project' }, + }); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/repo', + }, + ], + }, + }), + ); + }, + ), + ); + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + token: token, + }, + }); + }); + + it('should call initAndPush with the correct values', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + defaultBranch: 'master', + auth: { username: 'u', password: 'p' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the correct default branch', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + defaultBranch: 'main', + auth: { username: 'u', password: 'p' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = + ScmIntegrations.fromConfig(customAuthorConfig); + const customAuthorAction = createPublishBitbucketCloudAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + auth: { username: 'u', password: 'p' }, + logger: mockContext.logger, + defaultBranch: 'master', + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, + }); + }); + + it('should call initAndPush with the configured defaultCommitMessage', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + scaffolder: { + defaultCommitMessage: 'Test commit message', + }, + }); + + const customAuthorIntegrations = + ScmIntegrations.fromConfig(customAuthorConfig); + const customAuthorAction = createPublishBitbucketCloudAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + auth: { username: 'u', password: 'p' }, + logger: mockContext.logger, + defaultBranch: 'master', + commitMessage: 'Test commit message', + gitAuthorInfo: { email: undefined, name: undefined }, + }); + }); + + it('should call outputs with the correct urls', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler(mockContext); + + expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://bitbucket.org/workspace/cloneurl', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://bitbucket.org/workspace/repo/src/master', + ); + }); + + it('should call outputs with the correct urls with correct default branch', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://bitbucket.org/workspace/cloneurl', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://bitbucket.org/workspace/repo/src/main', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts new file mode 100644 index 0000000000..02e29c33de --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts @@ -0,0 +1,281 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import fetch, { Response, RequestInit } from 'node-fetch'; +import { initRepoAndPush } from '../helpers'; +import { createTemplateAction } from '../../createTemplateAction'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; +import { Config } from '@backstage/config'; + +const createRepository = async (opts: { + workspace: string; + project: string; + repo: string; + description?: string; + repoVisibility: 'private' | 'public'; + mainBranch: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { + workspace, + project, + repo, + description, + repoVisibility, + mainBranch, + authorization, + apiBaseUrl, + } = opts; + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + scm: 'git', + description: description, + is_private: repoVisibility === 'private', + project: { key: project }, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}`, + options, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + + if (response.status !== 200) { + throw new Error( + `Unable to create repository, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'https') { + remoteUrl = link.href; + } + } + + // "mainbranch.name" cannot be set neither at create nor update of the repo + // the first pushed branch will be set as "main branch" then + const repoContentsUrl = `${r.links.html.href}/src/${mainBranch}`; + return { remoteUrl, repoContentsUrl }; +}; + +const getAuthorizationHeader = (config: { + username?: string; + appPassword?: string; + token?: string; +}) => { + if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + + return `Basic ${buffer.toString('base64')}`; + } + + if (config.token) { + return `Bearer ${config.token}`; + } + + throw new Error( + `Authorization has not been provided for Bitbucket Cloud. Please add either username + appPassword to the Integrations config or a user login auth token`, + ); +}; + +/** + * Creates a new action that initializes a git repository of the content in the workspace + * and publishes it to Bitbucket Cloud. + * @public + */ +export function createPublishBitbucketCloudAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations, config } = options; + + return createTemplateAction<{ + repoUrl: string; + description?: string; + defaultBranch?: string; + repoVisibility?: 'private' | 'public'; + sourcePath?: string; + token?: string; + }>({ + id: 'publish:bitbucketCloud', + description: + 'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket Cloud.', + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + description: { + title: 'Repository Description', + type: 'string', + }, + repoVisibility: { + title: 'Repository Visibility', + type: 'string', + enum: ['private', 'public'], + }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, + sourcePath: { + title: 'Source Path', + description: + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', + type: 'string', + }, + token: { + title: 'Authentication Token', + type: 'string', + description: + 'The token to use for authorization to BitBucket Cloud', + }, + }, + }, + output: { + type: 'object', + properties: { + remoteUrl: { + title: 'A URL to the repository with the provider', + type: 'string', + }, + repoContentsUrl: { + title: 'A URL to the root of the repository', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + description, + defaultBranch = 'master', + repoVisibility = 'private', + } = ctx.input; + + const { workspace, project, repo, host } = parseRepoUrl( + repoUrl, + integrations, + ); + + if (!workspace) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`, + ); + } + + if (!project) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`, + ); + } + + const integrationConfig = integrations.bitbucketCloud.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const authorization = getAuthorizationHeader( + ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, + ); + + const apiBaseUrl = integrationConfig.config.apiBaseUrl; + + const { remoteUrl, repoContentsUrl } = await createRepository({ + authorization, + workspace: workspace || '', + project, + repo, + repoVisibility, + mainBranch: defaultBranch, + description, + apiBaseUrl, + }); + + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + + let auth; + + if (ctx.input.token) { + auth = { + username: 'x-token-auth', + password: ctx.input.token, + }; + } else { + if ( + !integrationConfig.config.username || + !integrationConfig.config.appPassword + ) { + throw new Error( + 'Credentials for Bitbucket Cloud integration required for this action.', + ); + } + + auth = { + username: integrationConfig.config.username, + password: integrationConfig.config.appPassword, + }; + } + + await initRepoAndPush({ + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), + remoteUrl, + auth, + defaultBranch, + logger: ctx.logger, + commitMessage: config.getOptionalString( + 'scaffolder.defaultCommitMessage', + ), + gitAuthorInfo, + }); + + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts new file mode 100644 index 0000000000..338231ea55 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts @@ -0,0 +1,593 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('../helpers'); + +import { createPublishBitbucketServerAction } from './bitbucketServer'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +import { initRepoAndPush } from '../helpers'; + +describe('publish:bitbucketServer', () => { + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'notoken.bitbucket.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketServerAction({ integrations, config }); + const mockContext = { + input: { + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + repoVisibility: 'private' as const, + }, + workspacePath: 'wsp', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + const server = setupServer(); + setupRequestMockHandlers(server); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?repo=repo', + }, + }), + ).rejects.toThrow(/missing project/); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project', + }, + }), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'missing.com?project=project&repo=repo', + }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should throw if there is no token in the integration config that is returned', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'notoken.bitbucket.com?project=project&repo=repo', + }, + }), + ).rejects.toThrow( + /Authorization has not been provided for notoken.bitbucket.com/, + ); + }); + + it('should call the correct APIs', async () => { + expect.assertions(2); + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + }, + }); + }); + + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + const token = 'user-token'; + server.use( + rest.post( + 'https://notoken.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'notoken.bitbucket.com?project=project&repo=repo', + token: token, + }, + }); + }); + + describe('LFS for hosted bitbucket', () => { + const repoCreationResponse = { + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }; + + it('should call the correct APIs to enable LFS if requested and the host is hosted bitbucket', async () => { + expect.assertions(1); + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (_, res, ctx) => { + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoCreationResponse), + ); + }, + ), + rest.put( + 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + return res(ctx.status(204)); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + enableLFS: true, + }, + }); + }); + + it('should report an error if enabling LFS fails', async () => { + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (_, res, ctx) => { + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoCreationResponse), + ); + }, + ), + rest.put( + 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled', + (_, res, ctx) => { + return res(ctx.status(500)); + }, + ), + ); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + enableLFS: true, + }, + }), + ).rejects.toThrow(/Failed to enable LFS/); + }); + }); + + it('should call initAndPush with the correct values', async () => { + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + defaultBranch: 'master', + auth: { username: 'x-token-auth', password: 'thing' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the correct default branch', async () => { + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + defaultBranch: 'main', + auth: { username: 'x-token-auth', password: 'thing' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with the configured defaultAuthor', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'notoken.bitbucket.com', + }, + ], + }, + scaffolder: { + defaultAuthor: { + name: 'Test', + email: 'example@example.com', + }, + }, + }); + + const customAuthorIntegrations = + ScmIntegrations.fromConfig(customAuthorConfig); + const customAuthorAction = createPublishBitbucketServerAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + auth: { username: 'x-token-auth', password: 'thing' }, + logger: mockContext.logger, + defaultBranch: 'master', + gitAuthorInfo: { name: 'Test', email: 'example@example.com' }, + }); + }); + + it('should call initAndPush with the configured defaultCommitMessage', async () => { + const customAuthorConfig = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'notoken.bitbucket.com', + }, + ], + }, + scaffolder: { + defaultCommitMessage: 'Test commit message', + }, + }); + + const customAuthorIntegrations = + ScmIntegrations.fromConfig(customAuthorConfig); + const customAuthorAction = createPublishBitbucketServerAction({ + integrations: customAuthorIntegrations, + config: customAuthorConfig, + }); + + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await customAuthorAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + auth: { username: 'x-token-auth', password: 'thing' }, + logger: mockContext.logger, + defaultBranch: 'master', + commitMessage: 'Test commit message', + gitAuthorInfo: { email: undefined, name: undefined }, + }); + }); + + it('should call outputs with the correct urls', async () => { + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler(mockContext); + + expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://bitbucket.mycompany.com/scm/project/repo', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://bitbucket.mycompany.com/projects/project/repos/repo', + ); + }); + + it('should call outputs with the correct urls with correct default branch', async () => { + server.use( + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + defaultBranch: 'main', + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://bitbucket.mycompany.com/scm/project/repo', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://bitbucket.mycompany.com/projects/project/repos/repo', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts new file mode 100644 index 0000000000..208403d6f4 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts @@ -0,0 +1,265 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import fetch, { Response, RequestInit } from 'node-fetch'; +import { initRepoAndPush } from '../helpers'; +import { createTemplateAction } from '../../createTemplateAction'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; +import { Config } from '@backstage/config'; + +const createRepository = async (opts: { + project: string; + repo: string; + description?: string; + repoVisibility: 'private' | 'public'; + authorization: string; + apiBaseUrl: string; +}) => { + const { + project, + repo, + description, + authorization, + repoVisibility, + apiBaseUrl, + } = opts; + + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: repo, + description: description, + public: repoVisibility === 'public', + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch(`${apiBaseUrl}/projects/${project}/repos`, options); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + + if (response.status !== 201) { + throw new Error( + `Unable to create repository, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'http') { + remoteUrl = link.href; + } + } + + const repoContentsUrl = `${r.links.self[0].href}`; + return { remoteUrl, repoContentsUrl }; +}; + +const getAuthorizationHeader = (config: { token: string }) => { + return `Bearer ${config.token}`; +}; + +const performEnableLFS = async (opts: { + authorization: string; + host: string; + project: string; + repo: string; +}) => { + const { authorization, host, project, repo } = opts; + + const options: RequestInit = { + method: 'PUT', + headers: { + Authorization: authorization, + }, + }; + + const { ok, status, statusText } = await fetch( + `https://${host}/rest/git-lfs/admin/projects/${project}/repos/${repo}/enabled`, + options, + ); + + if (!ok) + throw new Error( + `Failed to enable LFS in the repository, ${status}: ${statusText}`, + ); +}; + +/** + * Creates a new action that initializes a git repository of the content in the workspace + * and publishes it to Bitbucket Server. + * @public + */ +export function createPublishBitbucketServerAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations, config } = options; + + return createTemplateAction<{ + repoUrl: string; + description?: string; + defaultBranch?: string; + repoVisibility?: 'private' | 'public'; + sourcePath?: string; + enableLFS?: boolean; + token?: string; + }>({ + id: 'publish:bitbucketServer', + description: + 'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket Server.', + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + description: { + title: 'Repository Description', + type: 'string', + }, + repoVisibility: { + title: 'Repository Visibility', + type: 'string', + enum: ['private', 'public'], + }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'master'`, + }, + sourcePath: { + title: 'Source Path', + description: + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', + type: 'string', + }, + enableLFS: { + title: 'Enable LFS?', + description: 'Enable LFS for the repository.', + type: 'boolean', + }, + token: { + title: 'Authentication Token', + type: 'string', + description: + 'The token to use for authorization to BitBucket Server', + }, + }, + }, + output: { + type: 'object', + properties: { + remoteUrl: { + title: 'A URL to the repository with the provider', + type: 'string', + }, + repoContentsUrl: { + title: 'A URL to the root of the repository', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + description, + defaultBranch = 'master', + repoVisibility = 'private', + enableLFS = false, + } = ctx.input; + + const { project, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!project) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`, + ); + } + + const integrationConfig = integrations.bitbucketServer.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const token = ctx.input.token ?? integrationConfig.config.token; + if (!token) { + throw new Error( + `Authorization has not been provided for ${integrationConfig.config.host}. Please add either token to the Integrations config or a user login auth token`, + ); + } + + const authorization = getAuthorizationHeader({ token }); + + const apiBaseUrl = integrationConfig.config.apiBaseUrl; + + const { remoteUrl, repoContentsUrl } = await createRepository({ + authorization, + project, + repo, + repoVisibility, + description, + apiBaseUrl, + }); + + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + + const auth = { + username: 'x-token-auth', + password: token, + }; + + await initRepoAndPush({ + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), + remoteUrl, + auth, + defaultBranch, + logger: ctx.logger, + commitMessage: config.getOptionalString( + 'scaffolder.defaultCommitMessage', + ), + gitAuthorInfo, + }); + + if (enableLFS) { + await performEnableLFS({ authorization, host, project, repo }); + } + + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 7d85b45edd..02266b6a06 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -16,6 +16,8 @@ export { createPublishAzureAction } from './azure'; export { createPublishBitbucketAction } from './bitbucket'; +export { createPublishBitbucketCloudAction } from './bitbucketCloud'; +export { createPublishBitbucketServerAction } from './bitbucketServer'; export { createPublishFileAction } from './file'; export { createPublishGithubAction } from './github'; export { createPublishGithubPullRequestAction } from './githubPullRequest'; diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 818ee7971b..6fc456e1fe 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -75,7 +75,15 @@ export class ScaffolderClient implements ScaffolderApi { ): Promise { const integrations = [ ...this.scmIntegrationsApi.azure.list(), - ...this.scmIntegrationsApi.bitbucket.list(), + ...this.scmIntegrationsApi.bitbucket + .list() + .filter( + item => + !this.scmIntegrationsApi.bitbucketCloud.byHost(item.config.host) && + !this.scmIntegrationsApi.bitbucketServer.byHost(item.config.host), + ), + ...this.scmIntegrationsApi.bitbucketCloud.list(), + ...this.scmIntegrationsApi.bitbucketServer.list(), ...this.scmIntegrationsApi.github.list(), ...this.scmIntegrationsApi.gitlab.list(), ]