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 1/7] 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 + + + ); +}; From b0a622490477626046f55a8b28c719511acd32e3 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Fri, 30 Apr 2021 08:42:40 +1200 Subject: [PATCH 2/7] Use default RepoUrlPicker for bitbucket fields Also updated the default bitbucket publisher to handle workspace and project fields Signed-off-by: Mustansar Anwar ul Samad --- packages/integration/config.d.ts | 38 --- packages/integration/src/bitbucket/config.ts | 31 --- .../template.yaml | 15 +- .../template/catalog-info.yaml | 0 .../sample-templates/local-templates.yaml | 2 +- .../actions/builtin/createBuiltinActions.ts | 4 - .../actions/builtin/publish/bitbucket.test.ts | 47 ++-- .../actions/builtin/publish/bitbucket.ts | 39 ++- .../builtin/publish/bitbucketCloud.test.ts | 187 -------------- .../actions/builtin/publish/bitbucketCloud.ts | 198 --------------- .../actions/builtin/publish/index.ts | 1 - .../actions/builtin/publish/util.ts | 36 +-- plugins/scaffolder/src/api.ts | 21 -- .../ActionsPage/ActionsPage.test.tsx | 1 - .../TemplatePage/TemplatePage.test.tsx | 1 - .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 144 +++++++++-- .../RepoUrlPickerBitbucketCloud.tsx | 239 ------------------ 17 files changed, 209 insertions(+), 795 deletions(-) rename plugins/scaffolder-backend/sample-templates/{bitbucket-cloud-demo => bitbucket-demo}/template.yaml (86%) rename plugins/scaffolder-backend/sample-templates/{bitbucket-cloud-demo => bitbucket-demo}/template/catalog-info.yaml (100%) delete mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.test.ts delete mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts delete 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 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 - - - ); -}; From 0f101a87d9b5982790aa5f6ead9ef898dc75b278 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Wed, 23 Jun 2021 10:57:15 +1200 Subject: [PATCH 3/7] Fix RepoUrlPicker to use integration api directly Signed-off-by: Mustansar Anwar ul Samad --- app-config.yaml | 5 +++ .../bitbucket-demo/template.yaml | 2 -- .../actions/builtin/publish/bitbucket.ts | 5 ++- .../actions/builtin/publish/github.ts | 8 ++++- .../actions/builtin/publish/gitlab.ts | 8 ++++- .../actions/builtin/publish/util.ts | 24 +++++++++---- .../src/scaffolder/tasks/TaskWorker.test.ts | 29 +++++++++++++-- .../src/scaffolder/tasks/TaskWorker.ts | 3 +- .../scaffolder-backend/src/service/router.ts | 1 + .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 36 +++++++------------ .../fields/RepoUrlPicker/validation.ts | 2 +- 11 files changed, 85 insertions(+), 38 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index f2df97b7b2..c1d0121dff 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -152,6 +152,11 @@ integrations: - host: bitbucket.org username: ${BITBUCKET_USERNAME} appPassword: ${BITBUCKET_APP_PASSWORD} + ### Example for how to add your bitbucket server instance using the API: + # - host: server.bitbucket.com + # apiBaseUrl: server.bitbucket.com + # username: ${BITBUCKET_SERVER_USERNAME} + # appPassword: ${BITBUCKET_SERVER_APP_PASSWORD} azure: - host: dev.azure.com token: ${AZURE_TOKEN} diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml index fda1324e42..87f4d449f5 100644 --- a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml @@ -19,7 +19,6 @@ spec: ui:field: RepoUrlPicker ui:options: allowedHosts: - - github.com - bitbucket.org - server.bitbucket.com - title: Fill in some steps @@ -64,7 +63,6 @@ spec: name: Publish action: publish:bitbucket input: - allowedHosts: ['bitbucket.org'] description: 'This is {{ parameters.name }}' repoUrl: '{{ parameters.repoUrl }}' 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 092390d08e..1291684719 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -260,7 +260,10 @@ export function createPublishBitbucketAction(options: { enableLFS = false, } = ctx.input; - const { workspace, project, repo, host } = parseRepoUrl(repoUrl); + const { workspace, project, repo, host } = parseRepoUrl( + repoUrl, + integrations, + ); // Workspace is only required for bitbucket cloud if (host === 'bitbucket.org') { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 640ae5dfe5..f767d6d38c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -144,7 +144,13 @@ export function createPublishGithubAction(options: { topics, } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError( + `No owner provided for host: ${host}, and repo ${repo}`, + ); + } const credentialsProvider = credentialsProviders.get(host); const integrationConfig = integrations.github.byHost(host); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index fbd2dcaaae..eead310777 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -84,7 +84,13 @@ export function createPublishGitlabAction(options: { defaultBranch = 'master', } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError( + `No owner provided for host: ${host}, and repo ${repo}`, + ); + } const integrationConfig = integrations.gitlab.byHost(host); 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 c86c4d8f38..0cc0830fd7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -16,6 +16,7 @@ import { InputError } from '@backstage/errors'; import { join as joinPath, normalize as normalizePath } from 'path'; +import { ScmIntegrationRegistry } from '@backstage/integration'; export const getRepoSourceDirectory = ( workspacePath: string, @@ -33,11 +34,16 @@ export const getRepoSourceDirectory = ( export type RepoSpec = { repo: string; host: string; - owner: string; + owner?: string; organization?: string; + workspace?: string; + project?: string; }; -export const parseRepoUrl = (repoUrl: string): RepoSpec => { +export const parseRepoUrl = ( + repoUrl: string, + integrations: ScmIntegrationRegistry, +): RepoSpec => { let parsed; try { parsed = new URL(`https://${repoUrl}`); @@ -47,10 +53,16 @@ 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'); + const owner = parsed.searchParams.get('owner') ?? undefined; + const organization = parsed.searchParams.get('organization') ?? undefined; + const workspace = parsed.searchParams.get('workspace') ?? undefined; + const project = parsed.searchParams.get('project') ?? undefined; + + const type = integrations.byHost(host)?.type; + + if (!type) { + throw new InputError(`Unable to find host ${host} in integrations`); + } if (type === 'bitbucket') { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index b5e8d19e62..6b18d0dac3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -41,6 +41,14 @@ describe('TaskWorker', () => { let storage: DatabaseTaskStore; let actionRegistry = new TemplateActionRegistry(); + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'token' }], + }, + }), + ); + beforeAll(async () => { storage = await createStore(); }); @@ -65,6 +73,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ steps: [{ id: 'test', name: 'test', action: 'not-found-action' }], @@ -90,6 +99,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -142,6 +152,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -331,6 +342,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -388,6 +400,12 @@ describe('TaskWorker', () => { organization: { type: 'string', }, + workspace: { + type: 'string', + }, + project: { + type: 'string', + }, }, }, }, @@ -396,7 +414,10 @@ describe('TaskWorker', () => { async handler(ctx) { ctx.output('host', ctx.input.destination.host); ctx.output('repo', ctx.input.destination.repo); - ctx.output('owner', ctx.input.destination.owner); + + if (ctx.input.destination.owner) { + ctx.output('owner', ctx.input.destination.owner); + } if (ctx.input.destination.host !== 'github.com') { throw new Error( @@ -410,7 +431,10 @@ describe('TaskWorker', () => { ); } - if (ctx.input.destination.owner !== 'owner') { + if ( + ctx.input.destination.owner && + ctx.input.destination.owner !== 'owner' + ) { throw new Error( `expected repo to be "owner" got ${ctx.input.destination.owner}`, ); @@ -425,6 +449,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index b4ba936719..d34979599d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -33,6 +33,7 @@ type Options = { taskBroker: TaskBroker; workingDirectory: string; actionRegistry: TemplateActionRegistry; + integrations: ScmIntegrations; }; export class TaskWorker { @@ -45,7 +46,7 @@ export class TaskWorker { // scary right now, so we're going to lock it off like the component API is // in the frontend until we can work out a nice way to do it. this.handlebars.registerHelper('parseRepoUrl', repoUrl => { - return JSON.stringify(parseRepoUrl(repoUrl)); + return JSON.stringify(parseRepoUrl(repoUrl, options.integrations)); }); this.handlebars.registerHelper('projectSlug', repoUrl => { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index cee9d2cfb4..f2a9c5b708 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -90,6 +90,7 @@ export async function createRouter( taskBroker, actionRegistry, workingDirectory, + integrations, }); workers.push(worker); } diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 8d936ebb46..278f1c3ae8 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -16,6 +16,7 @@ import React, { useCallback, useEffect } from 'react'; import { FieldProps } from '@rjsf/core'; import { scaffolderApiRef } from '../../../api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { useAsync } from 'react-use'; import Select from '@material-ui/core/Select'; import InputLabel from '@material-ui/core/InputLabel'; @@ -28,7 +29,6 @@ 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; @@ -39,7 +39,6 @@ function splitFormData(url: string | undefined) { 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. @@ -52,12 +51,11 @@ function splitFormData(url: string | undefined) { /* ok */ } - return { host, type, owner, repo, organization, workspace, project }; + return { host, owner, repo, organization, workspace, project }; } function serializeFormData(data: { host?: string; - type?: string; owner?: string; repo?: string; organization?: string; @@ -69,9 +67,6 @@ function serializeFormData(data: { } const params = new URLSearchParams(); - if (data.type) { - params.set('type', data.type); - } if (data.owner) { params.set('owner', data.owner); } @@ -97,11 +92,12 @@ export const RepoUrlPicker = ({ rawErrors, formData, }: FieldProps) => { - const api = useApi(scaffolderApiRef); + const scaffolderApi = useApi(scaffolderApiRef); + const integrationApi = useApi(scmIntegrationsApiRef); const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; const { value: integrations, loading } = useAsync(async () => { - return await api.getIntegrationsList({ allowedHosts }); + return await scaffolderApi.getIntegrationsList({ allowedHosts }); }); const { host, type, owner, repo, organization, workspace, project } = splitFormData(formData); @@ -119,7 +115,7 @@ export const RepoUrlPicker = ({ }), ) }, - [onChange, integrations, owner, repo, organization, workspace, project], + [onChange, owner, repo, organization, workspace, project], ); const updateOwner = useCallback( @@ -127,7 +123,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, - type, owner: evt.target.value as string, repo, organization, @@ -135,7 +130,7 @@ export const RepoUrlPicker = ({ project, }), ), - [onChange, host, type, repo, organization, workspace, project], + [onChange, host, repo, organization, workspace, project], ); const updateRepo = useCallback( @@ -143,7 +138,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, - type, owner, repo: evt.target.value as string, organization, @@ -151,7 +145,7 @@ export const RepoUrlPicker = ({ project, }), ), - [onChange, host, type, owner, organization, workspace, project], + [onChange, host, owner, organization, workspace, project], ); const updateOrganization = useCallback( @@ -159,7 +153,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, - type, owner, repo, organization: evt.target.value as string, @@ -175,7 +168,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, - type, owner, repo, organization, @@ -183,7 +175,7 @@ export const RepoUrlPicker = ({ project, }), ), - [onChange, host, type, owner, repo, organization, project], + [onChange, host, owner, repo, organization, project], ); const updateProject = useCallback( @@ -191,7 +183,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host, - type, owner, repo, organization, @@ -199,7 +190,7 @@ export const RepoUrlPicker = ({ project: evt.target.value as string, }), ), - [onChange, host, type, owner, repo, organization, workspace], + [onChange, host, owner, repo, organization, workspace], ); useEffect(() => { @@ -207,7 +198,6 @@ export const RepoUrlPicker = ({ onChange( serializeFormData({ host: integrations[0].host, - type: integrations[0].type, owner, repo, organization, @@ -263,9 +253,9 @@ export const RepoUrlPicker = ({ The name of the organization )} - {/* Show this for bitbucket.org only */} - {type === 'bitbucket' && ( + {host && integrationApi.byHost(host)?.type === 'bitbucket' && ( <> + {/* Show this for bitbucket.org only */} {host === 'bitbucket.org' && ( )} {/* Show this for all hosts except bitbucket */} - {type !== 'bitbucket' && ( + {host && integrationApi.byHost(host)?.type !== 'bitbucket' && ( <> { try { const { host, searchParams } = new URL(`https://${value}`); - if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { + if (!host || !searchParams.get('repo')) { validation.addError('Incomplete repository location provided'); } } catch { From dc962cab25c25365a4ba3e0ad8b1df6d0a5fc206 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Mon, 9 Aug 2021 21:32:33 +1200 Subject: [PATCH 4/7] Rebase on upstream to get context for validator Signed-off-by: Mustansar Anwar ul Samad --- .../builtin/github/githubActionsDispatch.ts | 8 ++++- .../actions/builtin/publish/azure.ts | 5 ++- .../actions/builtin/publish/bitbucket.test.ts | 23 +++++++----- .../actions/builtin/publish/bitbucket.ts | 33 ++++++++++------- .../builtin/publish/githubPullRequest.ts | 8 ++++- .../actions/builtin/publish/util.ts | 36 ++++--------------- .../src/scaffolder/tasks/TaskWorker.test.ts | 4 +++ .../src/scaffolder/tasks/TaskWorker.ts | 3 +- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 32 +++++++++++++---- 9 files changed, 91 insertions(+), 61 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index 7f48a155ed..5dfba7776e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -69,7 +69,13 @@ export function createGithubActionsDispatchAction(options: { async handler(ctx) { const { repoUrl, workflowId, branchOrTagName } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError( + `No owner provided for host: ${host}, and repo ${repo}`, + ); + } ctx.logger.info( `Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index f5c7879fa9..7e394a8d8b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -80,7 +80,10 @@ export function createPublishAzureAction(options: { async handler(ctx) { const { repoUrl, defaultBranch = 'master' } = ctx.input; - const { owner, repo, host, organization } = parseRepoUrl(repoUrl); + const { owner, repo, host, organization } = parseRepoUrl( + repoUrl, + integrations, + ); if (!organization) { throw new InputError( 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 8efd644bbe..61421857de 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?type=bitbucket&workspace=workspace&project=project&repo=repo', + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', repoVisibility: 'private', }, workspacePath: 'lol', @@ -70,21 +70,21 @@ describe('publish:bitbucket', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'bitbucket.org?type=bitbucket&project=project&repo=repo' }, + input: { repoUrl: 'bitbucket.org?project=project&repo=repo' }, }), ).rejects.toThrow(/missing workspace/); await expect( action.handler({ ...mockContext, - input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&repo=repo' }, + input: { repoUrl: 'bitbucket.org?workspace=workspace&repo=repo' }, }), ).rejects.toThrow(/missing project/); await expect( action.handler({ ...mockContext, - input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&project=project' }, + input: { repoUrl: 'bitbucket.org?workspace=workspace&project=project' }, }), ).rejects.toThrow(/missing repo/); }); @@ -93,7 +93,9 @@ describe('publish:bitbucket', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'missing.com?type=bitbucket&workspace=workspace&project=project&repo=repo' }, + input: { + repoUrl: 'missing.com?workspace=workspace&project=project&repo=repo', + }, }), ).rejects.toThrow(/No matching integration configuration/); }); @@ -103,7 +105,8 @@ describe('publish:bitbucket', () => { action.handler({ ...mockContext, input: { - repoUrl: 'notoken.bitbucket.com?type=bitbucket&workspace=workspace&project=project&repo=repo', + repoUrl: + 'notoken.bitbucket.com?workspace=workspace&project=project&repo=repo', }, }), ).rejects.toThrow(/Authorization has not been provided for Bitbucket/); @@ -116,7 +119,11 @@ describe('publish:bitbucket', () => { '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' } }); + expect(req.body).toEqual({ + is_private: true, + scm: 'git', + project: { key: 'project' }, + }); return res( ctx.status(200), ctx.set('Content-Type', 'application/json'), @@ -176,7 +183,7 @@ describe('publish:bitbucket', () => { ...mockContext, input: { ...mockContext.input, - repoUrl: 'hosted.bitbucket.com?type=bitbucket&project=project&repo=repo', + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', }, }); }); 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 1291684719..daa6bc249b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -33,7 +33,14 @@ const createBitbucketCloudRepository = async (opts: { repoVisibility: 'private' | 'public'; authorization: string; }) => { - const { workspace, project, repo, description, repoVisibility, authorization } = opts; + const { + workspace, + project, + repo, + description, + repoVisibility, + authorization, + } = opts; const options: RequestInit = { method: 'POST', @@ -115,7 +122,7 @@ const createBitbucketServerRepository = async (opts: { try { const baseUrl = apiBaseUrl ? apiBaseUrl : `https://${host}/rest/api/1.0`; - response = await fetch(`${baseUrl}/projects/${owner}/repos`, options); + response = await fetch(`${baseUrl}/projects/${project}/repos`, options); } catch (e) { throw new Error(`Unable to create repository, ${e}`); } @@ -162,10 +169,10 @@ const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => { const performEnableLFS = async (opts: { authorization: string; host: string; - owner: string; + project: string; repo: string; }) => { - const { authorization, host, owner, repo } = opts; + const { authorization, host, project, repo } = opts; const options: RequestInit = { method: 'PUT', @@ -175,7 +182,7 @@ const performEnableLFS = async (opts: { }; const { ok, status, statusText } = await fetch( - `https://${host}/rest/git-lfs/admin/projects/${owner}/repos/${repo}/enabled`, + `https://${host}/rest/git-lfs/admin/projects/${project}/repos/${repo}/enabled`, options, ); @@ -268,10 +275,10 @@ export function createPublishBitbucketAction(options: { // 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`, - ); - } + 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 @@ -293,9 +300,9 @@ 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, @@ -333,7 +340,7 @@ export function createPublishBitbucketAction(options: { }); if (enableLFS && host !== 'bitbucket.org') { - await performEnableLFS({ authorization, host, owner, repo }); + await performEnableLFS({ authorization, host, project, repo }); } ctx.output('remoteUrl', remoteUrl); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 408ec243e7..e21ffc5cd0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -173,7 +173,13 @@ export const createPublishGithubPullRequestAction = ({ sourcePath, } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError( + `No owner provided for host: ${host}, and repo ${repo}`, + ); + } const client = await clientFactory({ integrations, host, owner, repo }); const fileRoot = sourcePath 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 0cc0830fd7..5c10e66e39 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -65,38 +65,19 @@ export const parseRepoUrl = ( } if (type === 'bitbucket') { - - const organization = parsed.searchParams.get('organization') ?? undefined; - - 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`, - ); + if (host === 'bitbucket.org') { + if (!workspace) { + throw new InputError( + `Invalid repo URL passed to publisher: ${repoUrl}, missing workspace`, + ); + } } if (!project) { throw new InputError( `Invalid repo URL passed to publisher: ${repoUrl}, missing project`, ); } - } - - else { + } else { if (!owner) { throw new InputError( `Invalid repo URL passed to publisher: ${repoUrl}, missing owner`, @@ -111,8 +92,5 @@ export const parseRepoUrlForBitbucket = (repoUrl: string) => { ); } - const organization = parsed.searchParams.get('organization'); - return { host, owner, repo, organization, workspace, project }; }; - diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index 6b18d0dac3..2c274df7fc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -22,6 +22,7 @@ import { RepoSpec } from '../actions/builtin/publish/util'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { StorageTaskBroker } from './StorageTaskBroker'; import { TaskWorker } from './TaskWorker'; +import { ScmIntegrations } from '@backstage/integration'; async function createStore(): Promise { const manager = DatabaseManager.fromConfig( @@ -188,6 +189,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -221,6 +223,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -254,6 +257,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index d34979599d..819df56109 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -27,6 +27,7 @@ import { parseRepoUrl } from '../actions/builtin/publish/util'; import { TemplateActionRegistry } from '../actions/TemplateActionRegistry'; import { isTruthy } from './helper'; import { Task, TaskBroker } from './types'; +import { ScmIntegrations } from '@backstage/integration'; type Options = { logger: Logger; @@ -50,7 +51,7 @@ export class TaskWorker { }); this.handlebars.registerHelper('projectSlug', repoUrl => { - const { owner, repo } = parseRepoUrl(repoUrl); + const { owner, repo } = parseRepoUrl(repoUrl, options.integrations); return `${owner}/${repo}`; }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 278f1c3ae8..43ca5bd52e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -100,20 +100,21 @@ export const RepoUrlPicker = ({ return await scaffolderApi.getIntegrationsList({ allowedHosts }); }); - const { host, type, owner, repo, organization, workspace, project } = splitFormData(formData); + const { host, owner, repo, organization, workspace, project } = splitFormData( + formData, + ); const updateHost = useCallback( (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, workspace, project], ); @@ -160,7 +161,7 @@ export const RepoUrlPicker = ({ project, }), ), - [onChange, host , type, owner, repo, workspace, project], + [onChange, host, owner, repo, workspace, project], ); const updateWorkspace = useCallback( @@ -206,7 +207,16 @@ export const RepoUrlPicker = ({ }), ); } - }, [onChange, integrations, host, type, owner, repo, organization, workspace, project]); + }, [ + onChange, + integrations, + host, + owner, + repo, + organization, + workspace, + project, + ]); if (loading) { return ; @@ -263,7 +273,11 @@ export const RepoUrlPicker = ({ error={rawErrors?.length > 0 && !workspace} > Workspace - + The workspace where the repository will be created @@ -275,7 +289,11 @@ export const RepoUrlPicker = ({ error={rawErrors?.length > 0 && !project} > Project - + The project where the repository will be created From e30646aebbecef7325c4c1e2d63f3e9426064e99 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Tue, 10 Aug 2021 17:04:49 +1200 Subject: [PATCH 5/7] Update RepoUrlPicker validation for bitbucket fields Also, - Add and update tests - Add changeset Signed-off-by: Mustansar Anwar ul Samad --- .changeset/perfect-seals-burn.md | 6 + .../actions/builtin/publish/azure.test.ts | 6 +- .../actions/builtin/publish/bitbucket.test.ts | 46 +++--- .../actions/builtin/publish/util.ts | 4 +- .../fields/RepoUrlPicker/validation.test.ts | 153 +++++++++++++++++- .../fields/RepoUrlPicker/validation.ts | 44 ++++- 6 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 .changeset/perfect-seals-burn.md diff --git a/.changeset/perfect-seals-burn.md b/.changeset/perfect-seals-burn.md new file mode 100644 index 0000000000..10503363de --- /dev/null +++ b/.changeset/perfect-seals-burn.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend': minor +--- + +Add bitbucket workspace and project fields to RepoUrlPicker to support bitbucket cloud and server diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index 0af8b7b975..cee99e1646 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -68,21 +68,21 @@ describe('publish:azure', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'azure.com?repo=bob' }, + input: { repoUrl: 'dev.azure.com?repo=bob' }, }), ).rejects.toThrow(/missing owner/); await expect( action.handler({ ...mockContext, - input: { repoUrl: 'azure.com?owner=owner' }, + input: { repoUrl: 'dev.azure.com?owner=owner' }, }), ).rejects.toThrow(/missing repo/); await expect( action.handler({ ...mockContext, - input: { repoUrl: 'azure.com?owner=owner&repo=repo' }, + input: { repoUrl: 'dev.azure.com?owner=owner&repo=repo' }, }), ).rejects.toThrow(/missing organization/); }); 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 61421857de..4eee4b4b96 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 @@ -145,7 +145,13 @@ describe('publish:bitbucket', () => { ), ); - await action.handler(mockContext); + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + }, + }); }); it('should call the correct APIs when the host is hosted bitbucket', async () => { @@ -209,7 +215,7 @@ describe('publish:bitbucket', () => { expect.assertions(1); 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', (_, res, ctx) => { return res( ctx.status(201), @@ -219,7 +225,7 @@ describe('publish:bitbucket', () => { }, ), rest.put( - 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/owner/repos/repo/enabled', + 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled', (req, res, ctx) => { expect(req.headers.get('Authorization')).toBe('Bearer thing'); return res(ctx.status(204)); @@ -231,7 +237,7 @@ describe('publish:bitbucket', () => { ...mockContext, input: { ...mockContext.input, - repoUrl: 'hosted.bitbucket.com?owner=owner&repo=repo', + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', enableLFS: true, }, }); @@ -240,7 +246,7 @@ describe('publish:bitbucket', () => { it('should report an error if enabling LFS fails', async () => { 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', (_, res, ctx) => { return res( ctx.status(201), @@ -250,7 +256,7 @@ describe('publish:bitbucket', () => { }, ), rest.put( - 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/owner/repos/repo/enabled', + 'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled', (_, res, ctx) => { return res(ctx.status(500)); }, @@ -262,7 +268,7 @@ describe('publish:bitbucket', () => { ...mockContext, input: { ...mockContext.input, - repoUrl: 'hosted.bitbucket.com?owner=owner&repo=repo', + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', enableLFS: true, }, }), @@ -299,7 +305,7 @@ describe('publish:bitbucket', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, - remoteUrl: 'https://bitbucket.org/owner/cloneurl', + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', defaultBranch: 'master', auth: { username: 'x-token-auth', password: 'tokenlols' }, logger: mockContext.logger, @@ -310,7 +316,7 @@ describe('publish:bitbucket', () => { it('should call initAndPush with the correct default branch', 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), @@ -318,12 +324,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', }, ], }, @@ -342,7 +348,7 @@ describe('publish:bitbucket', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, - remoteUrl: 'https://bitbucket.org/owner/cloneurl', + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', defaultBranch: 'main', auth: { username: 'x-token-auth', password: 'tokenlols' }, logger: mockContext.logger, @@ -385,7 +391,7 @@ describe('publish:bitbucket', () => { 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), @@ -393,12 +399,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', }, ], }, @@ -411,7 +417,7 @@ describe('publish:bitbucket', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, - remoteUrl: 'https://bitbucket.org/owner/cloneurl', + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', auth: { username: 'x-token-auth', password: 'tokenlols' }, logger: mockContext.logger, defaultBranch: 'master', @@ -451,7 +457,7 @@ describe('publish:bitbucket', () => { 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), @@ -459,12 +465,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', }, ], }, @@ -477,7 +483,7 @@ describe('publish:bitbucket', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: mockContext.workspacePath, - remoteUrl: 'https://bitbucket.org/owner/cloneurl', + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', auth: { username: 'x-token-auth', password: 'tokenlols' }, logger: mockContext.logger, defaultBranch: 'master', 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 5c10e66e39..dea180dc04 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -61,7 +61,9 @@ export const parseRepoUrl = ( const type = integrations.byHost(host)?.type; if (!type) { - throw new InputError(`Unable to find host ${host} in integrations`); + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); } if (type === 'bitbucket') { diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts index d47285bf56..45e44b4072 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts @@ -16,6 +16,9 @@ import { repoPickerValidation } from './validation'; import { FieldValidation } from '@rjsf/core'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/core-app-api'; +import { ApiHolder } from '@backstage/core-plugin-api'; describe('RepoPicker Validation', () => { const fieldValidator = () => @@ -23,30 +26,60 @@ describe('RepoPicker Validation', () => { addError: jest.fn(), } as unknown as FieldValidation); + const config = new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'bitbucket.org', + }, + { + host: 'server.bitbucket.com', + }, + ], + github: [ + { + host: 'github.com', + }, + ], + }, + }); + + const scmIntegrations = ScmIntegrations.fromConfig(config); + + const apiHolderMock: jest.Mocked = { + get: jest.fn().mockImplementation(() => { + return scmIntegrations; + }), + }; + it('validates when no repo', () => { const mockFieldValidation = fieldValidator(); - repoPickerValidation('github.com?owner=a', mockFieldValidation); + repoPickerValidation('github.com?owner=a', mockFieldValidation, { + apiHolder: apiHolderMock, + }); expect(mockFieldValidation.addError).toHaveBeenCalledWith( - 'Incomplete repository location provided', + 'Incomplete repository location provided, repo not provided', ); }); it('validates when no owner', () => { const mockFieldValidation = fieldValidator(); - repoPickerValidation('github.com?repo=a', mockFieldValidation); + repoPickerValidation('github.com?repo=a', mockFieldValidation, { + apiHolder: apiHolderMock, + }); expect(mockFieldValidation.addError).toHaveBeenCalledWith( - 'Incomplete repository location provided', + 'Incomplete repository location provided, owner not provided', ); }); it('validates when not a real url', () => { const mockFieldValidation = fieldValidator(); - repoPickerValidation('', mockFieldValidation); + repoPickerValidation('', mockFieldValidation, { apiHolder: apiHolderMock }); expect(mockFieldValidation.addError).toHaveBeenCalledWith( 'Unable to parse the Repository URL', @@ -56,8 +89,116 @@ describe('RepoPicker Validation', () => { it('validates properly with proper input', () => { const mockFieldValidation = fieldValidator(); - repoPickerValidation('github.com?owner=a&repo=b', mockFieldValidation); + repoPickerValidation('github.com?owner=a&repo=b', mockFieldValidation, { + apiHolder: apiHolderMock, + }); expect(mockFieldValidation.addError).not.toHaveBeenCalled(); }); + + it('validates when no workspace, project or repo provided for bitbucket cloud', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('bitbucket.org', mockFieldValidation, { + apiHolder: apiHolderMock, + }); + + expect(mockFieldValidation.addError).toHaveBeenNthCalledWith( + 1, + 'Incomplete repository location provided, workspace not provided', + ); + expect(mockFieldValidation.addError).toHaveBeenNthCalledWith( + 2, + 'Incomplete repository location provided, project not provided', + ); + expect(mockFieldValidation.addError).toHaveBeenNthCalledWith( + 3, + 'Incomplete repository location provided, repo not provided', + ); + }); + + it('validates when no workspace provided for bitbucket cloud', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation( + 'bitbucket.org?project=p&repo=r', + mockFieldValidation, + { apiHolder: apiHolderMock }, + ); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided, workspace not provided', + ); + }); + + it('validates when no project provided for bitbucket cloud', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation( + 'bitbucket.org?workspace=w&repo=r', + mockFieldValidation, + { apiHolder: apiHolderMock }, + ); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided, project not provided', + ); + }); + + it('validates when no repo provided for bitbucket cloud', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation( + 'bitbucket.org?workspace=w&project=p', + mockFieldValidation, + { apiHolder: apiHolderMock }, + ); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided, repo not provided', + ); + }); + + it('validates when no project or repo provided for bitbucket server', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('server.bitbucket.com', mockFieldValidation, { + apiHolder: apiHolderMock, + }); + + expect(mockFieldValidation.addError).toHaveBeenNthCalledWith( + 1, + 'Incomplete repository location provided, project not provided', + ); + expect(mockFieldValidation.addError).toHaveBeenNthCalledWith( + 2, + 'Incomplete repository location provided, repo not provided', + ); + }); + + it('validates when no project provided for bitbucket server', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('server.bitbucket.com?repo=r', mockFieldValidation, { + apiHolder: apiHolderMock, + }); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided, project not provided', + ); + }); + + it('validates when no repo provided for bitbucket server', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation( + 'server.bitbucket.com?project=p', + mockFieldValidation, + { apiHolder: apiHolderMock }, + ); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided, repo not provided', + ); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts index 02a910ff2d..85232b0588 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -15,16 +15,56 @@ */ import { FieldValidation } from '@rjsf/core'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; export const repoPickerValidation = ( value: string, validation: FieldValidation, + context: { apiHolder: ApiHolder }, ) => { try { const { host, searchParams } = new URL(`https://${value}`); - if (!host || !searchParams.get('repo')) { - validation.addError('Incomplete repository location provided'); + + const integrationApi = context.apiHolder.get(scmIntegrationsApiRef); + + if (!host) { + validation.addError( + 'Incomplete repository location provided, host not provided', + ); + } else { + if (integrationApi?.byHost(host)?.type === 'bitbucket') { + // workspace is only applicable for bitbucket cloud + if (host === 'bitbucket.org' && !searchParams.get('workspace')) { + validation.addError( + 'Incomplete repository location provided, workspace not provided', + ); + } + + if (!searchParams.get('project')) { + validation.addError( + 'Incomplete repository location provided, project not provided', + ); + } + } + // For anything other than bitbucket + else { + if (!searchParams.get('owner')) { + validation.addError( + 'Incomplete repository location provided, owner not provided', + ); + } + } + + // Do this for all hosts + if (!searchParams.get('repo')) { + validation.addError( + 'Incomplete repository location provided, repo not provided', + ); + } } + + // if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { } catch { validation.addError('Unable to parse the Repository URL'); } From 56ce4e1a84737236d8ae3eba494c4f2f26a422d1 Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Tue, 10 Aug 2021 18:47:02 +1200 Subject: [PATCH 6/7] Remove local-templates.yaml as it is not in master anymore Signed-off-by: Mustansar Anwar ul Samad --- .../sample-templates/local-templates.yaml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 plugins/scaffolder-backend/sample-templates/local-templates.yaml diff --git a/plugins/scaffolder-backend/sample-templates/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml deleted file mode 100644 index 5c13f0ab40..0000000000 --- a/plugins/scaffolder-backend/sample-templates/local-templates.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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-demo/template.yaml From 9ecb8a595e7d535d2881d835c262dbbc9d3b3e4a Mon Sep 17 00:00:00 2001 From: Mustansar Anwar ul Samad Date: Wed, 11 Aug 2021 08:04:13 +1200 Subject: [PATCH 7/7] Rebase for prettier issues and add Bitbucket to vocab Signed-off-by: Mustansar Anwar ul Samad --- .changeset/perfect-seals-burn.md | 2 +- .github/styles/vocab.txt | 1 + .../src/components/fields/RepoUrlPicker/validation.ts | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/perfect-seals-burn.md b/.changeset/perfect-seals-burn.md index 10503363de..8d3a1c7619 100644 --- a/.changeset/perfect-seals-burn.md +++ b/.changeset/perfect-seals-burn.md @@ -3,4 +3,4 @@ '@backstage/plugin-scaffolder-backend': minor --- -Add bitbucket workspace and project fields to RepoUrlPicker to support bitbucket cloud and server +Add Bitbucket workspace and project fields to RepoUrlPicker to support Bitbucket cloud and server diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 6f28680ecb..fae8af80a0 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -15,6 +15,7 @@ Avro backrub Bigtable Billett +Bitbucket Bitrise Blackbox bool diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts index 85232b0588..24a1c2f5ce 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -63,8 +63,6 @@ export const repoPickerValidation = ( ); } } - - // if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { } catch { validation.addError('Unable to parse the Repository URL'); }