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}`,