From d9eba5b71511636c596bb77281f287401d0b38f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Thu, 10 Dec 2020 12:32:47 +0000 Subject: [PATCH 1/7] Add initial support for Bitbucket Server scaffolding --- .../src/scaffolder/stages/helpers.ts | 4 +- .../scaffolder/stages/prepare/bitbucket.ts | 85 ++++++++++++++ .../scaffolder/stages/prepare/preparers.ts | 3 + .../scaffolder/stages/publish/bitbucket.ts | 104 ++++++++++++++++++ .../scaffolder/stages/publish/publishers.ts | 29 +++++ .../src/scaffolder/stages/types.ts | 3 +- 6 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index e54e796b3b..40aa0ab7b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -85,7 +85,9 @@ export function makeDeprecatedLocationTypeDetector( config.getOptionalConfigArray('integrations.azure')?.forEach(sub => { hostMap.set(sub.getString('host'), 'azure/api'); }); - + config.getOptionalConfigArray('integrations.bitbucket')?.forEach(sub => { + hostMap.set(sub.getString('host'), 'bitbucket/api'); + }); return (url: string): string | undefined => { const parsed = new URL(url); return hostMap.get(parsed.hostname); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts new file mode 100644 index 0000000000..587a742605 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 os from 'os'; +import fs from 'fs-extra'; +import path from 'path'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { parseLocationAnnotation } from '../helpers'; +import { InputError } from '@backstage/backend-common'; +import { PreparerBase, PreparerOptions } from './types'; +import GitUriParser from 'git-url-parse'; +import { Clone, Cred } from 'nodegit'; +import { Config } from '@backstage/config'; + +export class BitbucketPreparer implements PreparerBase { + private readonly privateToken: string; + private readonly user: string; + + constructor(config: Config) { + this.user = + config.getOptionalString('scaffolder.bitbucket.api.username') ?? ''; + this.privateToken = + config.getOptionalString('scaffolder.bitbucket.api.token') ?? ''; + } + + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { + const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + + if (!['bitbucket/api', 'url'].includes(protocol)) { + throw new InputError( + `Wrong location protocol: ${protocol}, should be 'url'`, + ); + } + const templateId = template.metadata.name; + + const repo = GitUriParser(location); + let repositoryCheckoutUrl; + // could be refactor once https://github.com/IonicaBizau/git-url-parse/pull/117 has been published + if (repo.source === 'bitbucket.org') { + repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/${repo.owner}/${repo.name}`; + } else { + repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/scm/${repo.owner}/${repo.name}`; + } + + const tempDir = await fs.promises.mkdtemp( + path.join(workingDirectory, templateId), + ); + + const templateDirectory = path.join( + `${path.dirname(repo.filepath)}`, + template.spec.path ?? '.', + ); + + const options = this.privateToken + ? { + fetchOpts: { + callbacks: { + credentials: () => + Cred.userpassPlaintextNew(this.user, this.privateToken), + }, + }, + } + : {}; + + await Clone.clone(repositoryCheckoutUrl, tempDir, options); + + return path.resolve(tempDir, templateDirectory); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 84be130b79..25e771ae12 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -28,6 +28,7 @@ import { FilePreparer } from './file'; import { GitlabPreparer } from './gitlab'; import { AzurePreparer } from './azure'; import { GithubPreparer } from './github'; +import { BitbucketPreparer } from './bitbucket'; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); @@ -79,11 +80,13 @@ export class Preparers implements PreparerBuilder { const filePreparer = new FilePreparer(); const gitlabPreparer = new GitlabPreparer(config); const azurePreparer = new AzurePreparer(config); + const bitbucketPreparer = new BitbucketPreparer(config); preparers.register('file', filePreparer); preparers.register('gitlab', gitlabPreparer); preparers.register('gitlab/api', gitlabPreparer); preparers.register('azure/api', azurePreparer); + preparers.register('bitbucket/api', bitbucketPreparer); const githubConfig = config.getOptionalConfig('scaffolder.github'); if (githubConfig) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts new file mode 100644 index 0000000000..84e06440d8 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { PublisherBase, PublisherOptions, PublisherResult } from './types'; +import { pushToRemoteUserPass } from './helpers'; +import { RequiredTemplateValues } from '../templater'; +import { JsonValue } from '../../../../../../packages/config/src'; +import fetch from 'cross-fetch'; + +export class BitbucketPublisher implements PublisherBase { + private readonly host: string; + private readonly username: string; + private readonly token: string; + + constructor(host: string, username: string, token: string) { + this.host = host; + this.username = username; + this.token = token; + } + + async publish({ + values, + directory, + }: PublisherOptions): Promise { + const result = await this.createRemote(values); + + await pushToRemoteUserPass( + directory, + result.remoteUrl, + this.username, + this.token, + ); + return result; + } + + private async createRemote( + values: RequiredTemplateValues & Record, + ): Promise { + const [project, name] = values.storePath.split('/'); + if (this.host === 'bitbucket.org') { + return this.createBitbucketCloudRepository(project, name); + } + return this.createBitbucketServerRepository(project, name); + } + + private async createBitbucketCloudRepository( + project: string, + name: string, + ): Promise { + throw new Error( + `Failed to create ${project}/${name} on bitbucket.org which is currently not supported`, + ); + } + + private async createBitbucketServerRepository( + project: string, + name: string, + ): Promise { + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: name, + }), + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `${this.host}/rest/api/1.0/projects/${project}/repos`, + options, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + if (response.status === 201) { + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'http') { + remoteUrl = link.href; + } + } + const catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`; + return { remoteUrl, catalogInfoUrl }; + } + throw new Error(`Not a valid response code ${await response.text()}`); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 03a4e884ba..7496600356 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -30,6 +30,7 @@ import { RemoteProtocol } from '../types'; import { GithubPublisher, RepoVisibilityOptions } from './github'; import { GitlabPublisher } from './gitlab'; import { AzurePublisher } from './azure'; +import { BitbucketPublisher } from './bitbucket'; export class Publishers implements PublisherBuilder { private publisherMap = new Map(); @@ -163,6 +164,34 @@ export class Publishers implements PublisherBuilder { } } + const bitbucketConfig = config.getOptionalConfig( + 'scaffolder.bitbucket.api', + ); + if (bitbucketConfig) { + try { + const baseUrl = bitbucketConfig.getString('host'); + const bitbucketUsername = bitbucketConfig.getString('username'); + const bitbucketToken = bitbucketConfig.getString('token'); + + const bitbucketPublisher = new BitbucketPublisher( + baseUrl, + bitbucketUsername, + bitbucketToken, + ); + publishers.register('bitbucket/api', bitbucketPublisher); + } catch (e) { + const providerName = 'bitbucket'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } return publishers; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts index 938d906857..c1dc9fbcdb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts @@ -18,4 +18,5 @@ export type RemoteProtocol = | 'github' | 'gitlab' | 'gitlab/api' - | 'azure/api'; + | 'azure/api' + | 'bitbucket/api'; From 37a5244ef20fe5f7f1341a33e0f1d50d541ea09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Fri, 18 Dec 2020 22:47:14 +0000 Subject: [PATCH 2/7] Add initial support for Bitbucket Cloud scaffolding --- .changeset/bitbucket-scaffolder.md | 5 +++ .../scaffolder/stages/prepare/bitbucket.ts | 14 ++----- .../scaffolder/stages/publish/bitbucket.ts | 38 +++++++++++++++++-- 3 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 .changeset/bitbucket-scaffolder.md diff --git a/.changeset/bitbucket-scaffolder.md b/.changeset/bitbucket-scaffolder.md new file mode 100644 index 0000000000..908bcd7e2b --- /dev/null +++ b/.changeset/bitbucket-scaffolder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Add scaffolding support for Bitbucket Cloud and Server. diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 587a742605..6537c8f63f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -26,10 +26,10 @@ import { Config } from '@backstage/config'; export class BitbucketPreparer implements PreparerBase { private readonly privateToken: string; - private readonly user: string; + private readonly username: string; constructor(config: Config) { - this.user = + this.username = config.getOptionalString('scaffolder.bitbucket.api.username') ?? ''; this.privateToken = config.getOptionalString('scaffolder.bitbucket.api.token') ?? ''; @@ -50,13 +50,7 @@ export class BitbucketPreparer implements PreparerBase { const templateId = template.metadata.name; const repo = GitUriParser(location); - let repositoryCheckoutUrl; - // could be refactor once https://github.com/IonicaBizau/git-url-parse/pull/117 has been published - if (repo.source === 'bitbucket.org') { - repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/${repo.owner}/${repo.name}`; - } else { - repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/scm/${repo.owner}/${repo.name}`; - } + const repositoryCheckoutUrl = repo.toString('https'); const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), @@ -72,7 +66,7 @@ export class BitbucketPreparer implements PreparerBase { fetchOpts: { callbacks: { credentials: () => - Cred.userpassPlaintextNew(this.user, this.privateToken), + Cred.userpassPlaintextNew(this.username, this.privateToken), }, }, } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index 84e06440d8..f564f43ae5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -50,7 +50,7 @@ export class BitbucketPublisher implements PublisherBase { values: RequiredTemplateValues & Record, ): Promise { const [project, name] = values.storePath.split('/'); - if (this.host === 'bitbucket.org') { + if (this.host === 'https://bitbucket.org') { return this.createBitbucketCloudRepository(project, name); } return this.createBitbucketServerRepository(project, name); @@ -60,9 +60,39 @@ export class BitbucketPublisher implements PublisherBase { project: string, name: string, ): Promise { - throw new Error( - `Failed to create ${project}/${name} on bitbucket.org which is currently not supported`, - ); + let response: Response; + const buffer = Buffer.from(`${this.username}:${this.token}`, 'utf8'); + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({}), + headers: { + Authorization: `Basic ${buffer.toString('base64')}`, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${project}/${name}`, + options, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + if (response.status === 200) { + const r = await response.json(); + let remoteUrl = ''; + for (const link of r.links.clone) { + if (link.name === 'https') { + remoteUrl = link.href; + } + } + + // TODO use the urlReader to get the defautl branch + const catalogInfoUrl = `${r.links.html.href}/src/master/catalog-info.yaml`; + return { remoteUrl, catalogInfoUrl }; + } + throw new Error(`Not a valid response code ${await response.text()}`); } private async createBitbucketServerRepository( From 0337151237983716e9f6356df9acf3a26d1adeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Mon, 4 Jan 2021 11:36:10 +0000 Subject: [PATCH 3/7] Add tests and conform to latest changes to the scaffolder --- .../stages/prepare/bitbucket.test.ts | 145 ++++++++++++++++++ .../scaffolder/stages/prepare/bitbucket.ts | 30 ++-- .../stages/publish/bitbucket.test.ts | 130 ++++++++++++++++ .../scaffolder/stages/publish/bitbucket.ts | 34 ++-- 4 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts new file mode 100644 index 0000000000..143e1e0990 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Spotify AB + * + * 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.doMock('fs-extra', () => ({ + promises: { + mkdtemp: jest.fn(dir => `${dir}-static`), + }, +})); + +import { BitbucketPreparer } from './bitbucket'; +import { + TemplateEntityV1alpha1, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; +import { getVoidLogger, Git } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; + +describe('BitbucketPreparer', () => { + let mockEntity: TemplateEntityV1alpha1; + const mockGitClient = { + clone: jest.fn(), + }; + + jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); + + beforeEach(() => { + jest.clearAllMocks(); + mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + [LOCATION_ANNOTATION]: + 'bitbucket/api:https://bitbucket.org/backstage-project/backstage-repo', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + generation: 1, + }, + spec: { + type: 'website', + templater: 'cookiecutter', + path: './template', + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + }, + }; + }); + + it('calls the clone command with the correct arguments for a repository', async () => { + const preparer = new BitbucketPreparer(new ConfigReader({})); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://bitbucket.org/backstage-project/backstage-repo', + dir: expect.any(String), + }); + }); + + it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { + const preparer = new BitbucketPreparer( + new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-password', + }, + ], + }, + }), + ); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://bitbucket.org/backstage-project/backstage-repo', + dir: expect.any(String), + }); + }); + + it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { + const preparer = new BitbucketPreparer(new ConfigReader({})); + delete mockEntity.spec.path; + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://bitbucket.org/backstage-project/backstage-repo', + dir: expect.any(String), + }); + }); + + it('return the temp directory with the path to the folder if it is specified', async () => { + const preparer = new BitbucketPreparer(new ConfigReader({})); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + }); + + expect(response.split('\\').join('/')).toMatch( + /\/template\/test\/1\/2\/3$/, + ); + }); + + it('return the working directory with the path to the folder if it is specified', async () => { + const preparer = new BitbucketPreparer(new ConfigReader({})); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + workingDirectory: '/workDir', + }); + + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 6537c8f63f..a39b8d6fff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -18,10 +18,9 @@ import fs from 'fs-extra'; import path from 'path'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; -import { InputError } from '@backstage/backend-common'; +import { InputError, Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import GitUriParser from 'git-url-parse'; -import { Clone, Cred } from 'nodegit'; import { Config } from '@backstage/config'; export class BitbucketPreparer implements PreparerBase { @@ -41,6 +40,7 @@ export class BitbucketPreparer implements PreparerBase { ): Promise { const { protocol, location } = parseLocationAnnotation(template); const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + const { logger } = opts; if (!['bitbucket/api', 'url'].includes(protocol)) { throw new InputError( @@ -61,19 +61,21 @@ export class BitbucketPreparer implements PreparerBase { template.spec.path ?? '.', ); - const options = this.privateToken - ? { - fetchOpts: { - callbacks: { - credentials: () => - Cred.userpassPlaintextNew(this.username, this.privateToken), - }, - }, - } - : {}; + const checkoutLocation = path.resolve(tempDir, templateDirectory); - await Clone.clone(repositoryCheckoutUrl, tempDir, options); + const git = this.privateToken + ? Git.fromAuth({ + username: this.username, + password: this.privateToken, + logger, + }) + : Git.fromAuth({ logger }); - return path.resolve(tempDir, templateDirectory); + await git.clone({ + url: repositoryCheckoutUrl, + dir: tempDir, + }); + + return checkoutLocation; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts new file mode 100644 index 0000000000..82f54ec746 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +const mockResponse = jest.fn(); +jest.mock('./helpers'); +jest.mock('cross-fetch', () => mockResponse); + +import { BitbucketPublisher } from './bitbucket'; +import { initRepoAndPush } from './helpers'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('Bitbucket Publisher', () => { + const logger = getVoidLogger(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('publish: createRemoteInBitbucketCloud', () => { + it('should create repo in bitbucket cloud', async () => { + const publisher = new BitbucketPublisher( + 'https://bitbucket.org', + 'fake-user', + 'fake-token', + ); + mockResponse.mockResolvedValue({ + status: 200, + json: () => + Promise.resolve({ + links: { + html: { + href: 'https://bitbucket.org/project/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/project/repo', + }, + ], + }, + }), + }); + + const result = await publisher.publish({ + values: { + storePath: 'project/repo', + owner: 'bob', + }, + directory: '/tmp/test', + logger: logger, + }); + + expect(result).toEqual({ + remoteUrl: 'https://bitbucket.org/project/repo', + catalogInfoUrl: + 'https://bitbucket.org/project/repo/src/master/catalog-info.yaml', + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: '/tmp/test', + remoteUrl: 'https://bitbucket.org/project/repo', + auth: { username: 'fake-user', password: 'fake-token' }, + logger: logger, + }); + }); + }); + describe('publish: createRemoteInBitbucketServer', () => { + it('should create repo in bitbucket server', async () => { + const publisher = new BitbucketPublisher( + 'https://bitbucket.mycompany.com', + 'fake-user', + 'fake-token', + ); + mockResponse.mockResolvedValue({ + status: 201, + json: () => + Promise.resolve({ + links: { + self: [ + { + href: + 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + }); + + const result = await publisher.publish({ + values: { + storePath: 'project/repo', + owner: 'bob', + }, + directory: '/tmp/test', + logger: logger, + }); + + expect(result).toEqual({ + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + catalogInfoUrl: + 'https://bitbucket.mycompany.com/projects/project/repos/repo/catalog-info.yaml', + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: '/tmp/test', + remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', + auth: { username: 'fake-user', password: 'fake-token' }, + logger: logger, + }); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index f564f43ae5..c117fddf16 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -15,7 +15,7 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { pushToRemoteUserPass } from './helpers'; +import { initRepoAndPush } from './helpers'; import { RequiredTemplateValues } from '../templater'; import { JsonValue } from '../../../../../../packages/config/src'; import fetch from 'cross-fetch'; @@ -34,32 +34,36 @@ export class BitbucketPublisher implements PublisherBase { async publish({ values, directory, + logger, }: PublisherOptions): Promise { const result = await this.createRemote(values); - await pushToRemoteUserPass( - directory, - result.remoteUrl, - this.username, - this.token, - ); + await initRepoAndPush({ + dir: directory, + remoteUrl: result.remoteUrl, + auth: { + username: this.username, + password: this.token, + }, + logger, + }); return result; } private async createRemote( values: RequiredTemplateValues & Record, ): Promise { - const [project, name] = values.storePath.split('/'); if (this.host === 'https://bitbucket.org') { - return this.createBitbucketCloudRepository(project, name); + return this.createBitbucketCloudRepository(values); } - return this.createBitbucketServerRepository(project, name); + return this.createBitbucketServerRepository(values); } private async createBitbucketCloudRepository( - project: string, - name: string, + values: RequiredTemplateValues & Record, ): Promise { + const [project, name] = values.storePath.split('/'); + let response: Response; const buffer = Buffer.from(`${this.username}:${this.token}`, 'utf8'); @@ -96,14 +100,16 @@ export class BitbucketPublisher implements PublisherBase { } private async createBitbucketServerRepository( - project: string, - name: string, + values: RequiredTemplateValues & Record, ): Promise { + const [project, name] = values.storePath.split('/'); + let response: Response; const options: RequestInit = { method: 'POST', body: JSON.stringify({ name: name, + description: values.description, }), headers: { Authorization: `Bearer ${this.token}`, From fc40b23edbedc0afa655b512230be3ba7f360320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Mon, 4 Jan 2021 12:27:53 +0000 Subject: [PATCH 4/7] Add repository description for Bitbucket Cloud --- .../src/scaffolder/stages/publish/bitbucket.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index c117fddf16..ad83ad54e8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -69,7 +69,10 @@ export class BitbucketPublisher implements PublisherBase { const options: RequestInit = { method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify({ + scm: 'git', + description: values.description, + }), headers: { Authorization: `Basic ${buffer.toString('base64')}`, 'Content-Type': 'application/json', From 2c7ff367e43c34df7403fec15741c01fa8b84ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Mon, 4 Jan 2021 12:31:14 +0000 Subject: [PATCH 5/7] Add example configuration for bitbucket scaffolder --- app-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app-config.yaml b/app-config.yaml index f6f71478d0..66e69462aa 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -206,6 +206,13 @@ scaffolder: api: token: $env: AZURE_TOKEN + bitbucket: + api: + host: https://bitbucket.org + username: + $env: BITBUCKET_USERNAME + token: + $env: BITBUCKET_TOKEN auth: environment: development ### Providing an auth.session.secret will enable session support in the auth-backend From 082f1703f4e7d3ccf9095ff2b4dd89db5c38ff5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Mon, 4 Jan 2021 14:57:07 +0000 Subject: [PATCH 6/7] Use msw for mocking external Bitbucket apis --- .changeset/bitbucket-scaffolder.md | 2 +- plugins/scaffolder-backend/package.json | 4 +- .../stages/prepare/bitbucket.test.ts | 2 +- .../scaffolder/stages/prepare/bitbucket.ts | 2 +- .../scaffolder/stages/prepare/preparers.ts | 2 +- .../stages/publish/bitbucket.test.ts | 96 +++++++++++-------- .../scaffolder/stages/publish/publishers.ts | 2 +- .../src/scaffolder/stages/types.ts | 2 +- 8 files changed, 66 insertions(+), 46 deletions(-) diff --git a/.changeset/bitbucket-scaffolder.md b/.changeset/bitbucket-scaffolder.md index 908bcd7e2b..e1e6f17b29 100644 --- a/.changeset/bitbucket-scaffolder.md +++ b/.changeset/bitbucket-scaffolder.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-backend': patch --- Add scaffolding support for Bitbucket Cloud and Server. diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 1a443ceb7c..c290a94b19 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -59,12 +59,14 @@ }, "devDependencies": { "@backstage/cli": "^0.4.3", + "@backstage/test-utils": "^0.1.5", "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", - "yaml": "^1.10.0" + "yaml": "^1.10.0", + "msw": "^0.21.2" }, "files": [ "dist", diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index 143e1e0990..809d18c808 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -44,7 +44,7 @@ describe('BitbucketPreparer', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - 'bitbucket/api:https://bitbucket.org/backstage-project/backstage-repo', + 'bitbucket:https://bitbucket.org/backstage-project/backstage-repo', }, name: 'graphql-starter', title: 'GraphQL Service', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index a39b8d6fff..5b244d24ce 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -42,7 +42,7 @@ export class BitbucketPreparer implements PreparerBase { const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); const { logger } = opts; - if (!['bitbucket/api', 'url'].includes(protocol)) { + if (!['bitbucket', 'url'].includes(protocol)) { throw new InputError( `Wrong location protocol: ${protocol}, should be 'url'`, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 25e771ae12..906fc0f9fd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -86,7 +86,7 @@ export class Preparers implements PreparerBuilder { preparers.register('gitlab', gitlabPreparer); preparers.register('gitlab/api', gitlabPreparer); preparers.register('azure/api', azurePreparer); - preparers.register('bitbucket/api', bitbucketPreparer); + preparers.register('bitbucket', bitbucketPreparer); const githubConfig = config.getOptionalConfig('scaffolder.github'); if (githubConfig) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 82f54ec746..3b084f1bf9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -14,44 +14,55 @@ * limitations under the License. */ -const mockResponse = jest.fn(); jest.mock('./helpers'); -jest.mock('cross-fetch', () => mockResponse); import { BitbucketPublisher } from './bitbucket'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('Bitbucket Publisher', () => { const logger = getVoidLogger(); + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { jest.clearAllMocks(); }); describe('publish: createRemoteInBitbucketCloud', () => { it('should create repo in bitbucket cloud', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/project/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/project/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/project/repo', + }, + ], + }, + }), + ), + ), + ); + const publisher = new BitbucketPublisher( 'https://bitbucket.org', 'fake-user', 'fake-token', ); - mockResponse.mockResolvedValue({ - status: 200, - json: () => - Promise.resolve({ - links: { - html: { - href: 'https://bitbucket.org/project/repo', - }, - clone: [ - { - name: 'https', - href: 'https://bitbucket.org/project/repo', - }, - ], - }, - }), - }); const result = await publisher.publish({ values: { @@ -78,31 +89,38 @@ describe('Bitbucket Publisher', () => { }); describe('publish: createRemoteInBitbucketServer', () => { it('should create repo in bitbucket server', async () => { + server.use( + rest.post( + 'https://bitbucket.mycompany.com/rest/api/1.0/projects/project/repos', + (_, res, ctx) => + 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', + }, + ], + }, + }), + ), + ), + ); + const publisher = new BitbucketPublisher( 'https://bitbucket.mycompany.com', 'fake-user', 'fake-token', ); - mockResponse.mockResolvedValue({ - status: 201, - json: () => - Promise.resolve({ - links: { - self: [ - { - href: - 'https://bitbucket.mycompany.com/projects/project/repos/repo', - }, - ], - clone: [ - { - name: 'http', - href: 'https://bitbucket.mycompany.com/scm/project/repo', - }, - ], - }, - }), - }); const result = await publisher.publish({ values: { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 7496600356..75243aa610 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -178,7 +178,7 @@ export class Publishers implements PublisherBuilder { bitbucketUsername, bitbucketToken, ); - publishers.register('bitbucket/api', bitbucketPublisher); + publishers.register('bitbucket', bitbucketPublisher); } catch (e) { const providerName = 'bitbucket'; if (process.env.NODE_ENV !== 'development') { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts index c1dc9fbcdb..56111337fe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts @@ -19,4 +19,4 @@ export type RemoteProtocol = | 'gitlab' | 'gitlab/api' | 'azure/api' - | 'bitbucket/api'; + | 'bitbucket'; From a35d8dd76fd764b67db9d5903e49354c45415929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Mon, 4 Jan 2021 15:01:01 +0000 Subject: [PATCH 7/7] Use bitbucket as location identifier instead of bitbucket/api --- plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index 40aa0ab7b1..21b63609e8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -86,7 +86,7 @@ export function makeDeprecatedLocationTypeDetector( hostMap.set(sub.getString('host'), 'azure/api'); }); config.getOptionalConfigArray('integrations.bitbucket')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'bitbucket/api'); + hostMap.set(sub.getString('host'), 'bitbucket'); }); return (url: string): string | undefined => { const parsed = new URL(url);