diff --git a/.changeset/perfect-seals-burn.md b/.changeset/perfect-seals-burn.md new file mode 100644 index 0000000000000..8d3a1c7619780 --- /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/.github/styles/vocab.txt b/.github/styles/vocab.txt index 88cbd172a8dea..57510f2c3f2ce 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/app-config.yaml b/app-config.yaml index f2df97b7b21c2..c1d0121dff577 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 new file mode 100644 index 0000000000000..87f4d449f58b9 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml @@ -0,0 +1,78 @@ +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: bitbucket-demo + title: Test Bitbucket RepoUrlPicker template + description: scaffolder v1beta2 template demo publishing to bitbucket +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - bitbucket.org + - server.bitbucket.com + - 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:bitbucket + input: + 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-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template/catalog-info.yaml new file mode 100644 index 0000000000000..875664d2a87b4 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-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/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index 7f48a155eddea..5dfba7776eb2b 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.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index 0af8b7b975dd7..cee99e16462c5 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/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index f5c7879fa9ec0..7e394a8d8bcdb 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 180b6cc579e32..4eee4b4b96e4b 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?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?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?workspace=workspace&repo=repo' }, + }), + ).rejects.toThrow(/missing project/); + + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'bitbucket.org?workspace=workspace&project=project' }, }), ).rejects.toThrow(/missing repo/); }); @@ -86,7 +93,9 @@ describe('publish:bitbucket', () => { await expect( action.handler({ ...mockContext, - input: { repoUrl: 'missing.com?repo=bob&owner=owner' }, + input: { + repoUrl: 'missing.com?workspace=workspace&project=project&repo=repo', + }, }), ).rejects.toThrow(/No matching integration configuration/); }); @@ -96,7 +105,8 @@ describe('publish:bitbucket', () => { action.handler({ ...mockContext, input: { - repoUrl: 'notoken.bitbucket.com?repo=bob&owner=owner', + repoUrl: + 'notoken.bitbucket.com?workspace=workspace&project=project&repo=repo', }, }), ).rejects.toThrow(/Authorization has not been provided for Bitbucket/); @@ -106,22 +116,26 @@ 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', }, ], }, @@ -131,14 +145,20 @@ 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 () => { 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 +189,7 @@ describe('publish:bitbucket', () => { ...mockContext, input: { ...mockContext.input, - repoUrl: 'hosted.bitbucket.com?owner=owner&repo=repo', + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', }, }); }); @@ -195,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), @@ -205,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)); @@ -217,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, }, }); @@ -226,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), @@ -236,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)); }, @@ -248,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, }, }), @@ -259,7 +279,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 +287,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', }, ], }, @@ -285,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, @@ -296,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), @@ -304,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', }, ], }, @@ -328,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, @@ -371,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), @@ -379,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', }, ], }, @@ -397,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', @@ -437,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), @@ -445,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', }, ], }, @@ -463,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', @@ -475,7 +495,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 +503,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 +521,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 41d71c9ab55a1..daa6bc249be90 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,21 @@ 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 +48,7 @@ const createBitbucketCloudRepository = async (opts: { scm: 'git', description: description, is_private: repoVisibility === 'private', + project: { key: project }, }), headers: { Authorization: authorization, @@ -50,7 +59,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 +89,7 @@ const createBitbucketCloudRepository = async (opts: { const createBitbucketServerRepository = async (opts: { host: string; - owner: string; + project: string; repo: string; description: string; repoVisibility: 'private' | 'public'; @@ -89,7 +98,7 @@ const createBitbucketServerRepository = async (opts: { }) => { const { host, - owner, + project, repo, description, authorization, @@ -113,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}`); } @@ -160,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', @@ -173,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, ); @@ -258,7 +267,26 @@ export function createPublishBitbucketAction(options: { enableLFS = false, } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl); + const { workspace, project, repo, host } = parseRepoUrl( + repoUrl, + integrations, + ); + + // 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); @@ -279,7 +307,8 @@ export function createPublishBitbucketAction(options: { const { remoteUrl, repoContentsUrl } = await createMethod({ authorization, host, - owner, + workspace: workspace || '', + project, repo, repoVisibility, description, @@ -311,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/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 640ae5dfe574a..f767d6d38c5a1 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/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 408ec243e7801..e21ffc5cd0f17 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/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index fbd2dcaaae957..eead310777aba 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 e7d4e75f6a342..dea180dc047be 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,13 +53,40 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => { ); } const host = parsed.host; - const owner = parsed.searchParams.get('owner'); + 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 (!owner) { + if (!type) { throw new InputError( - `Invalid repo URL passed to publisher: ${repoUrl}, missing owner`, + `No matching integration configuration for host ${host}, please check your integrations config`, ); } + + if (type === 'bitbucket') { + 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 { + 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( @@ -61,7 +94,5 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => { ); } - const organization = parsed.searchParams.get('organization') ?? undefined; - - return { host, owner, repo, 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 b5e8d19e62ba9..2c274df7fc5fc 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( @@ -41,6 +42,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 +74,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 +100,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -142,6 +153,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -177,6 +189,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -210,6 +223,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -243,6 +257,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -331,6 +346,7 @@ describe('TaskWorker', () => { workingDirectory: os.tmpdir(), actionRegistry, taskBroker: broker, + integrations, }); const { taskId } = await broker.dispatch({ @@ -388,6 +404,12 @@ describe('TaskWorker', () => { organization: { type: 'string', }, + workspace: { + type: 'string', + }, + project: { + type: 'string', + }, }, }, }, @@ -396,7 +418,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 +435,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 +453,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 b4ba936719457..819df56109a9f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -27,12 +27,14 @@ 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; taskBroker: TaskBroker; workingDirectory: string; actionRegistry: TemplateActionRegistry; + integrations: ScmIntegrations; }; export class TaskWorker { @@ -45,11 +47,11 @@ 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 => { - const { owner, repo } = parseRepoUrl(repoUrl); + const { owner, repo } = parseRepoUrl(repoUrl, options.integrations); return `${owner}/${repo}`; }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index cee9d2cfb49ca..f2a9c5b708362 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 4287d1147bb82..43ca5bd52e5a3 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'; @@ -31,6 +32,8 @@ function splitFormData(url: string | undefined) { let owner = undefined; let repo = undefined; let organization = undefined; + let workspace = undefined; + let project = undefined; try { if (url) { @@ -40,12 +43,15 @@ function splitFormData(url: string | 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, owner, repo, organization, workspace, project }; } function serializeFormData(data: { @@ -53,10 +59,13 @@ function serializeFormData(data: { owner?: string; repo?: string; organization?: string; + workspace?: string; + project?: string; }) { if (!data.host) { return undefined; } + const params = new URLSearchParams(); if (data.owner) { params.set('owner', data.owner); @@ -67,6 +76,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()}`; } @@ -77,25 +92,31 @@ 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, owner, repo, organization } = splitFormData(formData); + const { host, 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, owner, repo, organization, + workspace, + project, }), - ), - [onChange, owner, repo, organization], + ); + }, + [onChange, owner, repo, organization, workspace, project], ); const updateOwner = useCallback( @@ -106,9 +127,11 @@ export const RepoUrlPicker = ({ owner: evt.target.value as string, repo, organization, + workspace, + project, }), ), - [onChange, host, repo, organization], + [onChange, host, repo, organization, workspace, project], ); const updateRepo = useCallback( @@ -119,9 +142,11 @@ export const RepoUrlPicker = ({ owner, repo: evt.target.value as string, organization, + workspace, + project, }), ), - [onChange, host, owner, organization], + [onChange, host, owner, organization, workspace, project], ); const updateOrganization = useCallback( @@ -132,9 +157,41 @@ export const RepoUrlPicker = ({ owner, repo, organization: evt.target.value as string, + workspace, + project, + }), + ), + [onChange, host, owner, repo, workspace, project], + ); + + const updateWorkspace = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + onChange( + serializeFormData({ + host, + owner, + repo, + organization, + workspace: evt.target.value as string, + project, }), ), - [onChange, host, owner, repo], + [onChange, host, owner, repo, organization, project], + ); + + const updateProject = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + onChange( + serializeFormData({ + host, + owner, + repo, + organization, + workspace, + project: evt.target.value as string, + }), + ), + [onChange, host, owner, repo, organization, workspace], ); useEffect(() => { @@ -145,10 +202,21 @@ export const RepoUrlPicker = ({ owner, repo, organization, + workspace, + project, }), ); } - }, [onChange, integrations, host, owner, repo, organization]); + }, [ + onChange, + integrations, + host, + 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 - - + {host && integrationApi.byHost(host)?.type === 'bitbucket' && ( + <> + {/* Show this for bitbucket.org only */} + {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 */} + {host && integrationApi.byHost(host)?.type !== 'bitbucket' && ( + <> + 0 && !owner} + > + Owner + + + The organization, user or project that this repo will belong to + + + + )} + {/* Show this for all hosts */} { 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 760b9f78905b0..24a1c2f5ce2eb 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -15,15 +15,53 @@ */ 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('owner') || !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', + ); + } } } catch { validation.addError('Unable to parse the Repository URL');