diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 46e560a7c6..501373de53 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -58,44 +58,6 @@ export interface Config { * @visibility secret */ appPassword?: string; - /** - * Bitbucket workspaces - * @visibility frontend - */ - workspaces?: Array<{ - /** - * The name of workspace - * @visibility frontend - */ - name: string; - /** - * The description for a workspace - * @visibility frontend - */ - description?: string; - /** - * Bitbucket projects in the the workspace - * @visibility frontend - */ - projects?: Array<{ - /** - * The name of the project in bitbucket - * @visibility frontend - */ - name: string; - /** - * The key for the project in bitbucket - * @visibility frontend - */ - key: string; - /** - * The description for the project in bitbucket - * @visibility frontend - */ - description?: string; - }>; - }>; - }>; /** Integration configuration for GitHub */ diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index 6706c86afc..b2ddfa3532 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -60,29 +60,8 @@ export type BitbucketIntegrationConfig = { * See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/ */ appPassword?: string; - - /** - * Bitbucket workspaces, can be organization or user - */ - workspaces?: BitbucketWorkspace[]; - }; -export type BitbucketWorkspace = { - name: string; - description?: string; - /** - * Bitbucket projects in the organization to add repos to - */ - projects?: BitbucketProject[]; -} - -export type BitbucketProject = { - name: string; - key: string; - description?: string; -} - /** * Reads a single Bitbucket integration config. * @@ -96,15 +75,6 @@ export function readBitbucketIntegrationConfig( const token = config.getOptionalString('token'); const username = config.getOptionalString('username'); const appPassword = config.getOptionalString('appPassword'); - const workspaces = config.getOptionalConfigArray('workspaces')?.map(w => ({ - name: w.getString('name'), - description: w.getOptionalString('description'), - projects : w.getOptionalConfigArray('projects')?.map(p => ({ - name: p.getString('name'), - key: p.getString('key'), - description: p.getOptionalString('description'), - })), - })); if (!isValidHost(host)) { throw new Error( @@ -124,7 +94,6 @@ export function readBitbucketIntegrationConfig( token, username, appPassword, - workspaces }; } diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml similarity index 86% rename from plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template.yaml rename to plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml index dc97ba9c6c..fda1324e42 100644 --- a/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml @@ -1,9 +1,9 @@ apiVersion: backstage.io/v1beta2 kind: Template metadata: - name: bitbucket-cloud - title: Test Bitbucket Cloud RepoUrlPicker template - description: scaffolder v1beta2 template demo publishing to bitbucket cloud + name: bitbucket-demo + title: Test Bitbucket RepoUrlPicker template + description: scaffolder v1beta2 template demo publishing to bitbucket spec: owner: backstage/techdocs-core type: service @@ -16,7 +16,12 @@ spec: repoUrl: title: Repository Location type: string - ui:field: RepoUrlPickerBitbucketCloud + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + - bitbucket.org + - server.bitbucket.com - title: Fill in some steps required: - name @@ -57,7 +62,7 @@ spec: - id: publish name: Publish - action: publish:bitbucketcloud + action: publish:bitbucket input: allowedHosts: ['bitbucket.org'] description: 'This is {{ parameters.name }}' diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml rename to plugins/scaffolder-backend/sample-templates/bitbucket-demo/template/catalog-info.yaml diff --git a/plugins/scaffolder-backend/sample-templates/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml index 58c7419716..5c13f0ab40 100644 --- a/plugins/scaffolder-backend/sample-templates/local-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/local-templates.yaml @@ -11,4 +11,4 @@ spec: - ./springboot-grpc-template/template.yaml - ./v1beta2-demo/template.yaml - ./pull-request/template.yaml - - ./bitbucket-cloud-demo/template.yaml + - ./bitbucket-demo/template.yaml diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index eecda4e11f..5a16ef9a55 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -33,7 +33,6 @@ import { import { createPublishAzureAction, createPublishBitbucketAction, - createPublishBitbucketCloudAction, createPublishGithubAction, createPublishGithubPullRequestAction, createPublishGitlabAction, @@ -79,9 +78,6 @@ export const createBuiltinActions = (options: { integrations, config, }), - createPublishBitbucketCloudAction({ - integrations, - }), createPublishAzureAction({ integrations, config, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts index 180b6cc579..8efd644bbe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts @@ -50,7 +50,7 @@ describe('publish:bitbucket', () => { const action = createPublishBitbucketAction({ integrations, config }); const mockContext = { input: { - repoUrl: 'bitbucket.org?repo=repo&owner=owner', + repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&project=project&repo=repo', repoVisibility: 'private', }, workspacePath: 'lol', @@ -70,14 +70,21 @@ describe('publish:bitbucket', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'bitbucket.com?repo=bob' }, + input: { repoUrl: 'bitbucket.org?type=bitbucket&project=project&repo=repo' }, }), - ).rejects.toThrow(/missing owner/); + ).rejects.toThrow(/missing workspace/); await expect( action.handler({ ...mockContext, - input: { repoUrl: 'bitbucket.com?owner=owner' }, + input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&repo=repo' }, + }), + ).rejects.toThrow(/missing project/); + + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&project=project' }, }), ).rejects.toThrow(/missing repo/); }); @@ -86,7 +93,7 @@ describe('publish:bitbucket', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'missing.com?repo=bob&owner=owner' }, + input: { repoUrl: 'missing.com?type=bitbucket&workspace=workspace&project=project&repo=repo' }, }), ).rejects.toThrow(/No matching integration configuration/); }); @@ -96,7 +103,7 @@ describe('publish:bitbucket', () => { action.handler({ ...mockContext, input: { - repoUrl: 'notoken.bitbucket.com?repo=bob&owner=owner', + repoUrl: 'notoken.bitbucket.com?type=bitbucket&workspace=workspace&project=project&repo=repo', }, }), ).rejects.toThrow(/Authorization has not been provided for Bitbucket/); @@ -106,22 +113,22 @@ describe('publish:bitbucket', () => { expect.assertions(2); server.use( rest.post( - 'https://api.bitbucket.org/2.0/repositories/owner/repo', + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', (req, res, ctx) => { expect(req.headers.get('Authorization')).toBe('Bearer tokenlols'); - expect(req.body).toEqual({ is_private: true, scm: 'git' }); + 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/owner/repo', + href: 'https://bitbucket.org/workspace/repo', }, clone: [ { name: 'https', - href: 'https://bitbucket.org/owner/repo', + href: 'https://bitbucket.org/workspace/repo', }, ], }, @@ -138,7 +145,7 @@ describe('publish:bitbucket', () => { expect.assertions(2); server.use( rest.post( - 'https://hosted.bitbucket.com/rest/api/1.0/projects/owner/repos', + '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' }); @@ -169,7 +176,7 @@ describe('publish:bitbucket', () => { ...mockContext, input: { ...mockContext.input, - repoUrl: 'hosted.bitbucket.com?owner=owner&repo=repo', + repoUrl: 'hosted.bitbucket.com?type=bitbucket&project=project&repo=repo', }, }); }); @@ -259,7 +266,7 @@ describe('publish:bitbucket', () => { it('should call initAndPush with the correct values', async () => { server.use( rest.post( - 'https://api.bitbucket.org/2.0/repositories/owner/repo', + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', (_, res, ctx) => res( ctx.status(200), @@ -267,12 +274,12 @@ describe('publish:bitbucket', () => { ctx.json({ links: { html: { - href: 'https://bitbucket.org/owner/repo', + href: 'https://bitbucket.org/workspace/repo', }, clone: [ { name: 'https', - href: 'https://bitbucket.org/owner/cloneurl', + href: 'https://bitbucket.org/workspace/cloneurl', }, ], }, @@ -475,7 +482,7 @@ describe('publish:bitbucket', () => { it('should call outputs with the correct urls', async () => { server.use( rest.post( - 'https://api.bitbucket.org/2.0/repositories/owner/repo', + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', (_, res, ctx) => res( ctx.status(200), @@ -483,12 +490,12 @@ describe('publish:bitbucket', () => { ctx.json({ links: { html: { - href: 'https://bitbucket.org/owner/repo', + href: 'https://bitbucket.org/workspace/repo', }, clone: [ { name: 'https', - href: 'https://bitbucket.org/owner/cloneurl', + href: 'https://bitbucket.org/workspace/cloneurl', }, ], }, @@ -501,11 +508,11 @@ describe('publish:bitbucket', () => { expect(mockContext.output).toHaveBeenCalledWith( 'remoteUrl', - 'https://bitbucket.org/owner/cloneurl', + 'https://bitbucket.org/workspace/cloneurl', ); expect(mockContext.output).toHaveBeenCalledWith( 'repoContentsUrl', - 'https://bitbucket.org/owner/repo/src/master', + 'https://bitbucket.org/workspace/repo/src/master', ); }); }); 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 41d71c9ab5..092390d08e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -26,13 +26,14 @@ import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { Config } from '@backstage/config'; const createBitbucketCloudRepository = async (opts: { - owner: string; + workspace: string; + project: string; repo: string; description: string; repoVisibility: 'private' | 'public'; authorization: string; }) => { - const { owner, repo, description, repoVisibility, authorization } = opts; + const { workspace, project, repo, description, repoVisibility, authorization } = opts; const options: RequestInit = { method: 'POST', @@ -40,6 +41,7 @@ const createBitbucketCloudRepository = async (opts: { scm: 'git', description: description, is_private: repoVisibility === 'private', + project: { key: project }, }), headers: { Authorization: authorization, @@ -50,7 +52,7 @@ const createBitbucketCloudRepository = async (opts: { let response: Response; try { response = await fetch( - `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`, + `https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}`, options, ); } catch (e) { @@ -80,7 +82,7 @@ const createBitbucketCloudRepository = async (opts: { const createBitbucketServerRepository = async (opts: { host: string; - owner: string; + project: string; repo: string; description: string; repoVisibility: 'private' | 'public'; @@ -89,7 +91,7 @@ const createBitbucketServerRepository = async (opts: { }) => { const { host, - owner, + project, repo, description, authorization, @@ -258,7 +260,23 @@ export function createPublishBitbucketAction(options: { enableLFS = false, } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { workspace, project, repo, host } = parseRepoUrl(repoUrl); + + // Workspace is only required for bitbucket cloud + if (host === 'bitbucket.org') { + if (!workspace) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`, + ); + } + } + + // Project is required for both bitbucket cloud and bitbucket server + if (!project) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`, + ); + } const integrationConfig = integrations.bitbucket.byHost(host); @@ -272,14 +290,15 @@ export function createPublishBitbucketAction(options: { const apiBaseUrl = integrationConfig.config.apiBaseUrl; const createMethod = - host === 'bitbucket.org' - ? createBitbucketCloudRepository - : createBitbucketServerRepository; + host === 'bitbucket.org' + ? createBitbucketCloudRepository + : createBitbucketServerRepository; const { remoteUrl, repoContentsUrl } = await createMethod({ authorization, host, - owner, + workspace: workspace || '', + project, repo, repoVisibility, 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 deleted file mode 100644 index dea10e6ae1..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright 2021 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.mock('../../../stages/publish/helpers'); - -import { createPublishBitbucketCloudAction } from './bitbucketCloud'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { msw } from '@backstage/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 '../../../stages/publish/helpers'; - -describe('publish:bitbucketcloud', () => { - const integrations = ScmIntegrations.fromConfig( - new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'bitbucket.org', - token: 'tokenlols', - }, - ], - }, - }), - ); - const action = createPublishBitbucketCloudAction({ integrations }); - const mockContext = { - input: { - repoUrl: 'bitbucket.org?repo=repo&workspace=workspace&project=project', - repoVisibility: 'private', - }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; - const server = setupServer(); - msw.setupDefaultHandlers(server); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('should throw an error when the repoUrl is not well formed', async () => { - - await expect( - action.handler({ - ...mockContext, - input: { repoUrl: 'bitbucket.org?repo=bob&workspace=workspace1' }, - }), - ).rejects.toThrow(/missing project/); - - await expect( - action.handler({ - ...mockContext, - input: { repoUrl: 'bitbucket.org?repo=bob&project=project1' }, - }), - ).rejects.toThrow(/missing workspace/); - - await expect( - action.handler({ - ...mockContext, - input: { repoUrl: 'bitbucket.org?workspace=workspace1&project=project1' }, - }), - ).rejects.toThrow(/missing repo/); - }); - - it('should call the correct APIs when the host is bitbucket cloud', 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('Bearer tokenlols'); - 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 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', - auth: { username: 'x-token-auth', password: 'tokenlols' }, - logger: mockContext.logger, - }); - }); - - 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', - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts deleted file mode 100644 index db5aa3aff2..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright 2021 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 { InputError } from '@backstage/errors'; -import { - BitbucketIntegrationConfig, - ScmIntegrationRegistry, -} from '@backstage/integration'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; -import { getRepoSourceDirectory, parseRepoUrlForBitbucket } from './util'; -import fetch from 'cross-fetch'; -import { createTemplateAction } from '../../createTemplateAction'; - -const createBitbucketCloudRepository = async (opts: { - workspace: string; - project: string; - repo: string; - description: string; - repoVisibility: 'private' | 'public'; - authorization: string; -}) => { - const { workspace, project, repo, description, repoVisibility, authorization } = 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( - `https://api.bitbucket.org/2.0/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; - } - } - - // TODO use the urlReader to get the default branch - const repoContentsUrl = `${r.links.html.href}/src/master`; - return { remoteUrl, repoContentsUrl }; -}; - -const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => { - 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. Please add either username + appPassword or token to the Integrations config`, - ); -}; - -export function createPublishBitbucketCloudAction(options: { - integrations: ScmIntegrationRegistry; -}) { - const { integrations } = options; - - return createTemplateAction<{ - repoUrl: string; - description: string; - repoVisibility: 'private' | 'public'; - sourcePath?: string; - }>({ - id: 'publish:bitbucketcloud', - description: - 'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.', - schema: { - input: { - type: 'object', - required: ['repoUrl'], - properties: { - repoUrl: { - title: 'Repository Location', - type: 'string', - }, - description: { - title: 'Repository Description', - type: 'string', - }, - repoVisibility: { - title: 'Repository Visiblity', - type: 'string', - enum: ['private', 'public'], - }, - sourcePath: { - title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', - type: 'string', - }, - }, - }, - 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, repoVisibility = 'private' } = ctx.input; - - const { workspace, project, repo, host } = parseRepoUrlForBitbucket(repoUrl); - - const integrationConfig = integrations.bitbucket.byHost(host); - - if (!integrationConfig) { - throw new InputError( - `No matching integration configuration for host ${host}, please check your integrations config`, - ); - } - - const authorization = getAuthorizationHeader(integrationConfig.config); - - const createMethod = createBitbucketCloudRepository; - - const { remoteUrl, repoContentsUrl } = await createMethod({ - authorization, - workspace, - project, - repo, - repoVisibility, - description, - }); - - await initRepoAndPush({ - dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), - remoteUrl, - auth: { - username: integrationConfig.config.username - ? integrationConfig.config.username - : 'x-token-auth', - password: integrationConfig.config.appPassword - ? integrationConfig.config.appPassword - : integrationConfig.config.token ?? '', - }, - logger: ctx.logger, - }); - - 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 bdc1577ee5..a292438d3a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -16,7 +16,6 @@ export { createPublishAzureAction } from './azure'; export { createPublishBitbucketAction } from './bitbucket'; -export { createPublishBitbucketCloudAction } from './bitbucketCloud'; export { createPublishFileAction } from './file'; export { createPublishGithubAction } from './github'; export { createPublishGithubPullRequestAction } from './githubPullRequest'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts index 1d74ab8328..c86c4d8f38 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -47,19 +47,12 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => { ); } const host = parsed.host; + const type = parsed.searchParams.get('type'); const owner = parsed.searchParams.get('owner'); + const workspace = parsed.searchParams.get('workspace'); + const project = parsed.searchParams.get('project'); - if (!owner) { - throw new InputError( - `Invalid repo URL passed to publisher: ${repoUrl}, missing owner`, - ); - } - const repo = parsed.searchParams.get('repo'); - if (!repo) { - throw new InputError( - `Invalid repo URL passed to publisher: ${repoUrl}, missing repo`, - ); - } + if (type === 'bitbucket') { const organization = parsed.searchParams.get('organization') ?? undefined; @@ -84,23 +77,30 @@ export const parseRepoUrlForBitbucket = (repoUrl: string) => { `Invalid repo URL passed to publisher: ${repoUrl}, missing workspace`, ); } - - const project = parsed.searchParams.get('project'); if (!project) { throw new InputError( `Invalid repo URL passed to publisher: ${repoUrl}, missing project`, ); } + } - const repo = parsed.searchParams.get('repo'); - if (!repo) { + else { + if (!owner) { throw new InputError( - `Invalid repo URL passed to publisher: ${repoUrl}, missing repo`, + `Invalid repo URL passed to publisher: ${repoUrl}, missing owner`, ); } + } - const organization = parsed.searchParams.get('organization'); + const repo = parsed.searchParams.get('repo'); + if (!repo) { + throw new InputError( + `Invalid repo URL passed to publisher: ${repoUrl}, missing repo`, + ); + } - return { host, workspace, project, repo, organization }; + const organization = parsed.searchParams.get('organization'); + return { host, owner, repo, organization, workspace, project }; }; + diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 47a1d8cc62..37d78d0fda 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -79,10 +79,6 @@ export interface ScaffolderApi { allowedHosts: string[]; }): Promise<{ type: string; title: string; host: string }[]>; - getIntegration(options: { - type: string; - }): Promise; - // Returns a list of all installed actions. listActions(): Promise; @@ -121,23 +117,6 @@ export class ScaffolderClient implements ScaffolderApi { .filter(c => options.allowedHosts.includes(c.host)); } - async getIntegration(options: { type: string }) { - switch (options.type) { - case 'azure': - return this.scmIntegrationsApi.azure.list() - case 'bitbucket': - return this.scmIntegrationsApi.bitbucket.list() - case 'github': - return this.scmIntegrationsApi.github.list() - case 'gitlab': - return this.scmIntegrationsApi.gitlab.list() - default: - throw new Error( - `No integration found for ${options.type}`, - ); - } - } - async getTemplateParameterSchema( templateName: EntityName, ): Promise { diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 93bd5d3fd3..77c8240974 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -24,7 +24,6 @@ const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), - getIntegration: jest.fn(), getTask: jest.fn(), streamLogs: jest.fn(), listActions: jest.fn(), diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index ef2acd6de3..e42bcbdbdc 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -40,7 +40,6 @@ const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), getTemplateParameterSchema: jest.fn(), getIntegrationsList: jest.fn(), - getIntegration: jest.fn(), getTask: jest.fn(), streamLogs: jest.fn(), listActions: jest.fn(), diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 4287d1147b..8d936ebb46 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -28,36 +28,50 @@ import { Progress } from '@backstage/core-components'; function splitFormData(url: string | undefined) { let host = undefined; + let type = undefined; let owner = undefined; let repo = undefined; let organization = undefined; + let workspace = undefined; + let project = undefined; try { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; + type = parsed.searchParams.get('type') || undefined; owner = parsed.searchParams.get('owner') || undefined; repo = parsed.searchParams.get('repo') || undefined; // This is azure dev ops specific. not used for any other provider. organization = parsed.searchParams.get('organization') || undefined; + // These are bitbucket specific, not used for any other provider. + workspace = parsed.searchParams.get('workspace') || undefined; + project = parsed.searchParams.get('project') || undefined; } } catch { /* ok */ } - return { host, owner, repo, organization }; + return { host, type, owner, repo, organization, workspace, project }; } function serializeFormData(data: { host?: string; + type?: string; owner?: string; repo?: string; organization?: string; + workspace?: string; + project?: string; }) { if (!data.host) { return undefined; } + const params = new URLSearchParams(); + if (data.type) { + params.set('type', data.type); + } if (data.owner) { params.set('owner', data.owner); } @@ -67,6 +81,12 @@ function serializeFormData(data: { if (data.organization) { params.set('organization', data.organization); } + if (data.workspace) { + params.set('workspace', data.workspace); + } + if (data.project) { + params.set('project', data.project); + } return `${data.host}?${params.toString()}`; } @@ -84,18 +104,22 @@ export const RepoUrlPicker = ({ return await api.getIntegrationsList({ allowedHosts }); }); - const { host, owner, repo, organization } = splitFormData(formData); + const { host, type, owner, repo, organization, workspace, project } = splitFormData(formData); const updateHost = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => { onChange( serializeFormData({ host: evt.target.value as string, + type: (integrations?getIntegrationTypeByHost(evt.target.value as string, integrations): undefined), owner, repo, organization, + workspace, + project, }), - ), - [onChange, owner, repo, organization], + ) + }, + [onChange, integrations, owner, repo, organization, workspace, project], ); const updateOwner = useCallback( @@ -103,12 +127,15 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, + type, owner: evt.target.value as string, repo, organization, + workspace, + project, }), ), - [onChange, host, repo, organization], + [onChange, host, type, repo, organization, workspace, project], ); const updateRepo = useCallback( @@ -116,12 +143,15 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, + type, owner, repo: evt.target.value as string, organization, + workspace, + project, }), ), - [onChange, host, owner, organization], + [onChange, host, type, owner, organization, workspace, project], ); const updateOrganization = useCallback( @@ -129,12 +159,47 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, + type, owner, repo, organization: evt.target.value as string, + workspace, + project, }), ), - [onChange, host, owner, repo], + [onChange, host , type, owner, repo, workspace, project], + ); + + const updateWorkspace = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + onChange( + serializeFormData({ + host, + type, + owner, + repo, + organization, + workspace: evt.target.value as string, + project, + }), + ), + [onChange, host, type, owner, repo, organization, project], + ); + + const updateProject = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + onChange( + serializeFormData({ + host, + type, + owner, + repo, + organization, + workspace, + project: evt.target.value as string, + }), + ), + [onChange, host, type, owner, repo, organization, workspace], ); useEffect(() => { @@ -142,13 +207,16 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host: integrations[0].host, + type: integrations[0].type, owner, repo, organization, + workspace, + project, }), ); } - }, [onChange, integrations, host, owner, repo, organization]); + }, [onChange, integrations, host, type, owner, repo, organization, workspace, project]); if (loading) { return ; @@ -179,6 +247,7 @@ export const RepoUrlPicker = ({ The host where the repository will be created + {/* Show this for dev.azure.com only */} {host === 'dev.azure.com' && ( The name of the organization )} - 0 && !owner} - > - Owner - - - The organization, user or project that this repo will belong to - - + {/* Show this for bitbucket.org only */} + {type === 'bitbucket' && ( + <> + {host === 'bitbucket.org' && ( + 0 && !workspace} + > + Workspace + + + The workspace where the repository will be created + + + )} + 0 && !project} + > + Project + + + The project where the repository will be created + + + + )} + {/* Show this for all hosts except bitbucket */} + {type !== 'bitbucket' && ( + <> + 0 && !owner} + > + Owner + + + The organization, user or project that this repo will belong to + + + + )} + {/* Show this for all hosts */} { - - const api = useApi(scaffolderApiRef); - const allowedHosts: string[] = ['bitbucket.org']; - - const { value: integrations, loading: loadingIntegrations } = useAsync(async () => { - return await api.getIntegrationsList({ allowedHosts }); - }); - - const { value: data, loading: loadingData } = useAsync(async () => { - const bitbucketIntegrations: BitbucketIntegration[] = await api.getIntegration({ type: 'bitbucket' }) as BitbucketIntegration[]; - const bitbucketIntegration = bitbucketIntegrations.filter(element => element.config.host === 'bitbucket.org')[0]; - const workspaces = bitbucketIntegration.config.workspaces; - const projects = workspaces?.[0]?.projects; - - return { workspaces, projects }; - }); - - const { host, workspace, project, repo } = splitFormData(formData); - const updateHost = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host: evt.target.value as string, - workspace, - project, - repo, - }), - ), - [onChange, workspace, project, repo], - ); - - const updateWorkspace = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => { - if (data) { - data.projects = data?.workspaces?.find(w => w.name === evt.target.value)?.projects; - } - onChange( - serializeFormData({ - host, - workspace: evt.target.value as string, - project, - repo, - }), - ); - }, - [onChange, data, host, project, repo], - ); - - const updateProject = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => { - onChange( - serializeFormData({ - host, - workspace, - project: evt.target.value as string, - repo, - }), - ); - }, - [onChange, host, workspace, repo], - ); - - const updateRepo = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - workspace, - project, - repo: evt.target.value as string, - }), - ), - [onChange, host, workspace, project], - ); - - useEffect(() => { - onChange( - serializeFormData({ - host: (host === undefined ? (integrations?.[0]?.host) : undefined), - workspace: workspace === undefined ? (data?.workspaces?.[0]?.name) : undefined, - project: project === undefined ? (data?.workspaces?.[0]?.projects?.[0]?.key) : undefined, - repo, - }), - ); - }, [onChange, integrations, data, host, workspace, project, repo]); - - if (loadingIntegrations || loadingData) { - return ; - } - - return ( - <> - 0 && !host} - > - Host - - - The host where the repository will be created - - - 0 && !workspace} - > - Workspace - - - The workspace where the repository will be created - - - 0 && !project} - > - Project - - The bitbucket project this repo should be added to - - 0 && !repo} - > - Repository - - The name of the repository - - - ); -};