Merge pull request #5381 from msamad/msamad/add-repourlpicker-for-bitbucketcloud

Add repourlpicker and publisher for bitbucket cloud
This commit is contained in:
Fredrik Adelöw
2021-08-11 16:46:57 +02:00
committed by GitHub
20 changed files with 630 additions and 102 deletions
@@ -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 }}'
@@ -0,0 +1,8 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: {{cookiecutter.name | jsonify}}
spec:
type: website
lifecycle: experimental
owner: {{cookiecutter.owner | jsonify}}
@@ -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}`,
@@ -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/);
});
@@ -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(
@@ -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',
);
});
});
@@ -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);
@@ -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);
@@ -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
@@ -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);
@@ -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;
if (!owner) {
const type = integrations.byHost(host)?.type;
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 };
};
@@ -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<DatabaseTaskStore> {
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({
@@ -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}`;
});
@@ -90,6 +90,7 @@ export async function createRouter(
taskBroker,
actionRegistry,
workingDirectory,
integrations,
});
workers.push(worker);
}
@@ -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<string>) => {
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],
[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, 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 <Progress />;
@@ -179,6 +247,7 @@ export const RepoUrlPicker = ({
The host where the repository will be created
</FormHelperText>
</FormControl>
{/* Show this for dev.azure.com only */}
{host === 'dev.azure.com' && (
<FormControl
margin="normal"
@@ -194,17 +263,60 @@ export const RepoUrlPicker = ({
<FormHelperText>The name of the organization</FormHelperText>
</FormControl>
)}
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !owner}
>
<InputLabel htmlFor="ownerInput">Owner</InputLabel>
<Input id="ownerInput" onChange={updateOwner} value={owner} />
<FormHelperText>
The organization, user or project that this repo will belong to
</FormHelperText>
</FormControl>
{host && integrationApi.byHost(host)?.type === 'bitbucket' && (
<>
{/* Show this for bitbucket.org only */}
{host === 'bitbucket.org' && (
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !workspace}
>
<InputLabel htmlFor="wokrspaceInput">Workspace</InputLabel>
<Input
id="wokrspaceInput"
onChange={updateWorkspace}
value={workspace}
/>
<FormHelperText>
The workspace where the repository will be created
</FormHelperText>
</FormControl>
)}
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !project}
>
<InputLabel htmlFor="wokrspaceInput">Project</InputLabel>
<Input
id="wokrspaceInput"
onChange={updateProject}
value={project}
/>
<FormHelperText>
The project where the repository will be created
</FormHelperText>
</FormControl>
</>
)}
{/* Show this for all hosts except bitbucket */}
{host && integrationApi.byHost(host)?.type !== 'bitbucket' && (
<>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !owner}
>
<InputLabel htmlFor="ownerInput">Owner</InputLabel>
<Input id="ownerInput" onChange={updateOwner} value={owner} />
<FormHelperText>
The organization, user or project that this repo will belong to
</FormHelperText>
</FormControl>
</>
)}
{/* Show this for all hosts */}
<FormControl
margin="normal"
required
@@ -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<ApiHolder> = {
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',
);
});
});
@@ -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');