From 9b41ea27b628476e21fa0db861e3f93711f43023 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Mon, 19 Apr 2021 13:18:18 +1200 Subject: [PATCH] Add repourlpicker and publisher for bitbucket cloud The bitbucket cloud api needs project key to create repo under a project. Signed-off-by: Mustansar Anwar ul Samad --- packages/integration/config.d.ts | 38 +++ packages/integration/src/bitbucket/config.ts | 31 +++ .../bitbucket-cloud-demo/template.yaml | 75 ++++++ .../template/catalog-info.yaml | 8 + .../sample-templates/local-templates.yaml | 14 + .../actions/builtin/createBuiltinActions.ts | 4 + .../builtin/publish/bitbucketCloud.test.ts | 187 ++++++++++++++ .../actions/builtin/publish/bitbucketCloud.ts | 198 +++++++++++++++ .../actions/builtin/publish/index.ts | 1 + .../actions/builtin/publish/util.ts | 39 +++ plugins/scaffolder/src/api.ts | 21 ++ .../ActionsPage/ActionsPage.test.tsx | 1 + .../TemplatePage/TemplatePage.test.tsx | 1 + .../RepoUrlPickerBitbucketCloud.tsx | 239 ++++++++++++++++++ 14 files changed, 857 insertions(+) create mode 100644 plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template.yaml create mode 100644 plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml create mode 100644 plugins/scaffolder-backend/sample-templates/local-templates.yaml create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerBitbucketCloud.tsx diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 501373de53..46e560a7c6 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -58,6 +58,44 @@ 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 b2ddfa3532..6706c86afc 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -60,8 +60,29 @@ 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. * @@ -75,6 +96,15 @@ 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( @@ -94,6 +124,7 @@ 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-cloud-demo/template.yaml new file mode 100644 index 0000000000..dc97ba9c6c --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template.yaml @@ -0,0 +1,75 @@ +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 +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPickerBitbucketCloud + - title: Fill in some steps + required: + - name + - owner + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:cookiecutter + input: + url: ./template + values: + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + + - id: publish + name: Publish + action: publish:bitbucketcloud + input: + allowedHosts: ['bitbucket.org'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' + + output: + remoteUrl: '{{ steps.publish.output.remoteUrl }}' + entityRef: '{{ steps.register.output.entityRef }}' diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml new file mode 100644 index 0000000000..875664d2a8 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-cloud-demo/template/catalog-info.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{cookiecutter.name | jsonify}} +spec: + type: website + lifecycle: experimental + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml new file mode 100644 index 0000000000..58c7419716 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/local-templates.yaml @@ -0,0 +1,14 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-templates-local + description: A collection of locally available Backstage example templates +spec: + targets: + - ./create-react-app/template.yaml + - ./docs-template/template.yaml + - ./react-ssr-template/template.yaml + - ./springboot-grpc-template/template.yaml + - ./v1beta2-demo/template.yaml + - ./pull-request/template.yaml + - ./bitbucket-cloud-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 5a16ef9a55..eecda4e11f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -33,6 +33,7 @@ import { import { createPublishAzureAction, createPublishBitbucketAction, + createPublishBitbucketCloudAction, createPublishGithubAction, createPublishGithubPullRequestAction, createPublishGitlabAction, @@ -78,6 +79,9 @@ export const createBuiltinActions = (options: { integrations, config, }), + createPublishBitbucketCloudAction({ + integrations, + }), createPublishAzureAction({ integrations, config, 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..dea10e6ae1 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts @@ -0,0 +1,187 @@ +/* + * 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 new file mode 100644 index 0000000000..db5aa3aff2 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts @@ -0,0 +1,198 @@ +/* + * 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 a292438d3a..bdc1577ee5 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,7 @@ 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 e7d4e75f6a..1d74ab8328 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -65,3 +65,42 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => { return { host, owner, repo, organization }; }; + +export const parseRepoUrlForBitbucket = (repoUrl: string) => { + let parsed; + try { + parsed = new URL(`https://${repoUrl}`); + } catch (error) { + throw new InputError( + `Invalid repo URL passed to publisher, got ${repoUrl}, ${error}`, + ); + } + const host = parsed.host; + + const workspace = parsed.searchParams.get('workspace'); + + if (!workspace) { + throw new InputError( + `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) { + throw new InputError( + `Invalid repo URL passed to publisher: ${repoUrl}, missing repo`, + ); + } + + const organization = parsed.searchParams.get('organization'); + + return { host, workspace, project, repo, organization }; + +}; diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 37d78d0fda..47a1d8cc62 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -79,6 +79,10 @@ 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; @@ -117,6 +121,23 @@ 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 77c8240974..93bd5d3fd3 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -24,6 +24,7 @@ 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 e42bcbdbdc..ef2acd6de3 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -40,6 +40,7 @@ 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/RepoUrlPickerBitbucketCloud.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerBitbucketCloud.tsx new file mode 100644 index 0000000000..51172c07c1 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerBitbucketCloud.tsx @@ -0,0 +1,239 @@ +/* + * 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 React, { useCallback, useEffect } from 'react'; +import { Field } from '@rjsf/core'; +import { useApi, Progress } from '@backstage/core'; +import { scaffolderApiRef } from '../../../api'; +import { useAsync } from 'react-use'; +import Select from '@material-ui/core/Select'; +import InputLabel from '@material-ui/core/InputLabel'; +import Input from '@material-ui/core/Input'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import { BitbucketIntegration } from '@backstage/integration'; + +function splitFormData(url: string | undefined) { + let host = undefined; + let workspace = undefined; + let project = undefined; + let repo = undefined; + + try { + if (url) { + const parsed = new URL(`https://${url}`); + host = parsed.host; + workspace = parsed.searchParams.get('workspace') || undefined; + project = parsed.searchParams.get('project') || undefined; + repo = parsed.searchParams.get('repo') || undefined; + } + } catch { + /* ok */ + } + + return { host, workspace, project, repo }; +} + +function serializeFormData(data: { + host?: string; + workspace?: string; + project?: string; + repo?: string; +}) { + if (!data.host) { + return undefined; + } + const params = new URLSearchParams(); + if (data.workspace) { + params.set('workspace', data.workspace); + } + if (data.project) { + params.set('project', data.project); + } + if (data.repo) { + params.set('repo', data.repo); + } + + return `${data.host}?${params.toString()}`; +} + +export const RepoUrlPickerBitbucketCloud: Field = ({ + onChange, + rawErrors, + formData, +}) => { + + 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 + + + ); +};