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'); }