Use default RepoUrlPicker for bitbucket fields

Also updated the default bitbucket publisher to handle workspace
and project fields

Signed-off-by: Mustansar Anwar ul Samad <mustansar.samad@gmail.com>
This commit is contained in:
Mustansar Anwar ul Samad
2021-04-30 08:42:40 +12:00
parent 9b41ea27b6
commit b0a6224904
17 changed files with 209 additions and 795 deletions
-38
View File
@@ -58,44 +58,6 @@ export interface Config {
* @visibility secret
*/
appPassword?: string;
/**
* Bitbucket workspaces
* @visibility frontend
*/
workspaces?: Array<{
/**
* The name of workspace
* @visibility frontend
*/
name: string;
/**
* The description for a workspace
* @visibility frontend
*/
description?: string;
/**
* Bitbucket projects in the the workspace
* @visibility frontend
*/
projects?: Array<{
/**
* The name of the project in bitbucket
* @visibility frontend
*/
name: string;
/**
* The key for the project in bitbucket
* @visibility frontend
*/
key: string;
/**
* The description for the project in bitbucket
* @visibility frontend
*/
description?: string;
}>;
}>;
}>;
/** Integration configuration for GitHub */
@@ -60,29 +60,8 @@ export type BitbucketIntegrationConfig = {
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
*/
appPassword?: string;
/**
* Bitbucket workspaces, can be organization or user
*/
workspaces?: BitbucketWorkspace[];
};
export type BitbucketWorkspace = {
name: string;
description?: string;
/**
* Bitbucket projects in the organization to add repos to
*/
projects?: BitbucketProject[];
}
export type BitbucketProject = {
name: string;
key: string;
description?: string;
}
/**
* Reads a single Bitbucket integration config.
*
@@ -96,15 +75,6 @@ export function readBitbucketIntegrationConfig(
const token = config.getOptionalString('token');
const username = config.getOptionalString('username');
const appPassword = config.getOptionalString('appPassword');
const workspaces = config.getOptionalConfigArray('workspaces')?.map(w => ({
name: w.getString('name'),
description: w.getOptionalString('description'),
projects : w.getOptionalConfigArray('projects')?.map(p => ({
name: p.getString('name'),
key: p.getString('key'),
description: p.getOptionalString('description'),
})),
}));
if (!isValidHost(host)) {
throw new Error(
@@ -124,7 +94,6 @@ export function readBitbucketIntegrationConfig(
token,
username,
appPassword,
workspaces
};
}
@@ -1,9 +1,9 @@
apiVersion: backstage.io/v1beta2
kind: Template
metadata:
name: bitbucket-cloud
title: Test Bitbucket Cloud RepoUrlPicker template
description: scaffolder v1beta2 template demo publishing to bitbucket cloud
name: bitbucket-demo
title: Test Bitbucket RepoUrlPicker template
description: scaffolder v1beta2 template demo publishing to bitbucket
spec:
owner: backstage/techdocs-core
type: service
@@ -16,7 +16,12 @@ spec:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPickerBitbucketCloud
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
- bitbucket.org
- server.bitbucket.com
- title: Fill in some steps
required:
- name
@@ -57,7 +62,7 @@ spec:
- id: publish
name: Publish
action: publish:bitbucketcloud
action: publish:bitbucket
input:
allowedHosts: ['bitbucket.org']
description: 'This is {{ parameters.name }}'
@@ -11,4 +11,4 @@ spec:
- ./springboot-grpc-template/template.yaml
- ./v1beta2-demo/template.yaml
- ./pull-request/template.yaml
- ./bitbucket-cloud-demo/template.yaml
- ./bitbucket-demo/template.yaml
@@ -33,7 +33,6 @@ import {
import {
createPublishAzureAction,
createPublishBitbucketAction,
createPublishBitbucketCloudAction,
createPublishGithubAction,
createPublishGithubPullRequestAction,
createPublishGitlabAction,
@@ -79,9 +78,6 @@ export const createBuiltinActions = (options: {
integrations,
config,
}),
createPublishBitbucketCloudAction({
integrations,
}),
createPublishAzureAction({
integrations,
config,
@@ -50,7 +50,7 @@ describe('publish:bitbucket', () => {
const action = createPublishBitbucketAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?repo=repo&owner=owner',
repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&project=project&repo=repo',
repoVisibility: 'private',
},
workspacePath: 'lol',
@@ -70,14 +70,21 @@ describe('publish:bitbucket', () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.com?repo=bob' },
input: { repoUrl: 'bitbucket.org?type=bitbucket&project=project&repo=repo' },
}),
).rejects.toThrow(/missing owner/);
).rejects.toThrow(/missing workspace/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.com?owner=owner' },
input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&repo=repo' },
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?type=bitbucket&workspace=workspace&project=project' },
}),
).rejects.toThrow(/missing repo/);
});
@@ -86,7 +93,7 @@ describe('publish:bitbucket', () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'missing.com?repo=bob&owner=owner' },
input: { repoUrl: 'missing.com?type=bitbucket&workspace=workspace&project=project&repo=repo' },
}),
).rejects.toThrow(/No matching integration configuration/);
});
@@ -96,7 +103,7 @@ describe('publish:bitbucket', () => {
action.handler({
...mockContext,
input: {
repoUrl: 'notoken.bitbucket.com?repo=bob&owner=owner',
repoUrl: 'notoken.bitbucket.com?type=bitbucket&workspace=workspace&project=project&repo=repo',
},
}),
).rejects.toThrow(/Authorization has not been provided for Bitbucket/);
@@ -106,22 +113,22 @@ describe('publish:bitbucket', () => {
expect.assertions(2);
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer tokenlols');
expect(req.body).toEqual({ is_private: true, scm: 'git' });
expect(req.body).toEqual({ is_private: true, scm: 'git', project: { key: 'project' } });
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/repo',
href: 'https://bitbucket.org/workspace/repo',
},
],
},
@@ -138,7 +145,7 @@ describe('publish:bitbucket', () => {
expect.assertions(2);
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/owner/repos',
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
@@ -169,7 +176,7 @@ describe('publish:bitbucket', () => {
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?owner=owner&repo=repo',
repoUrl: 'hosted.bitbucket.com?type=bitbucket&project=project&repo=repo',
},
});
});
@@ -259,7 +266,7 @@ describe('publish:bitbucket', () => {
it('should call initAndPush with the correct values', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
@@ -267,12 +274,12 @@ describe('publish:bitbucket', () => {
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/cloneurl',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
@@ -475,7 +482,7 @@ describe('publish:bitbucket', () => {
it('should call outputs with the correct urls', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
@@ -483,12 +490,12 @@ describe('publish:bitbucket', () => {
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/cloneurl',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
@@ -501,11 +508,11 @@ describe('publish:bitbucket', () => {
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.org/owner/cloneurl',
'https://bitbucket.org/workspace/cloneurl',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.org/owner/repo/src/master',
'https://bitbucket.org/workspace/repo/src/master',
);
});
});
@@ -26,13 +26,14 @@ import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createBitbucketCloudRepository = async (opts: {
owner: string;
workspace: string;
project: string;
repo: string;
description: string;
repoVisibility: 'private' | 'public';
authorization: string;
}) => {
const { owner, repo, description, repoVisibility, authorization } = opts;
const { workspace, project, repo, description, repoVisibility, authorization } = opts;
const options: RequestInit = {
method: 'POST',
@@ -40,6 +41,7 @@ const createBitbucketCloudRepository = async (opts: {
scm: 'git',
description: description,
is_private: repoVisibility === 'private',
project: { key: project },
}),
headers: {
Authorization: authorization,
@@ -50,7 +52,7 @@ const createBitbucketCloudRepository = async (opts: {
let response: Response;
try {
response = await fetch(
`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`,
`https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}`,
options,
);
} catch (e) {
@@ -80,7 +82,7 @@ const createBitbucketCloudRepository = async (opts: {
const createBitbucketServerRepository = async (opts: {
host: string;
owner: string;
project: string;
repo: string;
description: string;
repoVisibility: 'private' | 'public';
@@ -89,7 +91,7 @@ const createBitbucketServerRepository = async (opts: {
}) => {
const {
host,
owner,
project,
repo,
description,
authorization,
@@ -258,7 +260,23 @@ export function createPublishBitbucketAction(options: {
enableLFS = false,
} = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
const { workspace, project, repo, host } = parseRepoUrl(repoUrl);
// Workspace is only required for bitbucket cloud
if (host === 'bitbucket.org') {
if (!workspace) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`,
);
}
}
// Project is required for both bitbucket cloud and bitbucket server
if (!project) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`,
);
}
const integrationConfig = integrations.bitbucket.byHost(host);
@@ -272,14 +290,15 @@ export function createPublishBitbucketAction(options: {
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
const createMethod =
host === 'bitbucket.org'
? createBitbucketCloudRepository
: createBitbucketServerRepository;
host === 'bitbucket.org'
? createBitbucketCloudRepository
: createBitbucketServerRepository;
const { remoteUrl, repoContentsUrl } = await createMethod({
authorization,
host,
owner,
workspace: workspace || '',
project,
repo,
repoVisibility,
description,
@@ -1,187 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
import { createPublishBitbucketCloudAction } from './bitbucketCloud';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
describe('publish:bitbucketcloud', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
],
},
}),
);
const action = createPublishBitbucketCloudAction({ integrations });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?repo=repo&workspace=workspace&project=project',
repoVisibility: 'private',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const server = setupServer();
msw.setupDefaultHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?repo=bob&workspace=workspace1' },
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?repo=bob&project=project1' },
}),
).rejects.toThrow(/missing workspace/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?workspace=workspace1&project=project1' },
}),
).rejects.toThrow(/missing repo/);
});
it('should call the correct APIs when the host is bitbucket cloud', async () => {
expect.assertions(2);
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer tokenlols');
expect(req.body).toEqual({ is_private: true, scm: 'git', project: { key: 'project' } });
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/repo',
},
],
},
}),
);
},
),
);
await action.handler(mockContext);
});
it('should call initAndPush with the correct values', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call outputs with the correct urls', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.org/workspace/cloneurl',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.org/workspace/repo/src/master',
);
});
});
@@ -1,198 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import {
BitbucketIntegrationConfig,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { getRepoSourceDirectory, parseRepoUrlForBitbucket } from './util';
import fetch from 'cross-fetch';
import { createTemplateAction } from '../../createTemplateAction';
const createBitbucketCloudRepository = async (opts: {
workspace: string;
project: string;
repo: string;
description: string;
repoVisibility: 'private' | 'public';
authorization: string;
}) => {
const { workspace, project, repo, description, repoVisibility, authorization } = opts;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
scm: 'git',
description: description,
is_private: repoVisibility === 'private',
project: { key: project },
}),
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
let response: Response;
try {
response = await fetch(
`https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}`,
options,
);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status !== 200) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// TODO use the urlReader to get the default branch
const repoContentsUrl = `${r.links.html.href}/src/master`;
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => {
if (config.username && config.appPassword) {
const buffer = Buffer.from(
`${config.username}:${config.appPassword}`,
'utf8',
);
return `Basic ${buffer.toString('base64')}`;
}
if (config.token) {
return `Bearer ${config.token}`;
}
throw new Error(
`Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`,
);
};
export function createPublishBitbucketCloudAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
description: string;
repoVisibility: 'private' | 'public';
sourcePath?: string;
}>({
id: 'publish:bitbucketcloud',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
repoVisibility: {
title: 'Repository Visiblity',
type: 'string',
enum: ['private', 'public'],
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.',
type: 'string',
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const { repoUrl, description, repoVisibility = 'private' } = ctx.input;
const { workspace, project, repo, host } = parseRepoUrlForBitbucket(repoUrl);
const integrationConfig = integrations.bitbucket.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const authorization = getAuthorizationHeader(integrationConfig.config);
const createMethod = createBitbucketCloudRepository;
const { remoteUrl, repoContentsUrl } = await createMethod({
authorization,
workspace,
project,
repo,
repoVisibility,
description,
});
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
auth: {
username: integrationConfig.config.username
? integrationConfig.config.username
: 'x-token-auth',
password: integrationConfig.config.appPassword
? integrationConfig.config.appPassword
: integrationConfig.config.token ?? '',
},
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -16,7 +16,6 @@
export { createPublishAzureAction } from './azure';
export { createPublishBitbucketAction } from './bitbucket';
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishFileAction } from './file';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
@@ -47,19 +47,12 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => {
);
}
const host = parsed.host;
const type = parsed.searchParams.get('type');
const owner = parsed.searchParams.get('owner');
const workspace = parsed.searchParams.get('workspace');
const project = parsed.searchParams.get('project');
if (!owner) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing owner`,
);
}
const repo = parsed.searchParams.get('repo');
if (!repo) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing repo`,
);
}
if (type === 'bitbucket') {
const organization = parsed.searchParams.get('organization') ?? undefined;
@@ -84,23 +77,30 @@ export const parseRepoUrlForBitbucket = (repoUrl: string) => {
`Invalid repo URL passed to publisher: ${repoUrl}, missing workspace`,
);
}
const project = parsed.searchParams.get('project');
if (!project) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing project`,
);
}
}
const repo = parsed.searchParams.get('repo');
if (!repo) {
else {
if (!owner) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing repo`,
`Invalid repo URL passed to publisher: ${repoUrl}, missing owner`,
);
}
}
const organization = parsed.searchParams.get('organization');
const repo = parsed.searchParams.get('repo');
if (!repo) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing repo`,
);
}
return { host, workspace, project, repo, organization };
const organization = parsed.searchParams.get('organization');
return { host, owner, repo, organization, workspace, project };
};
-21
View File
@@ -79,10 +79,6 @@ export interface ScaffolderApi {
allowedHosts: string[];
}): Promise<{ type: string; title: string; host: string }[]>;
getIntegration(options: {
type: string;
}): Promise<AzureIntegration[] | BitbucketIntegration[] | GitHubIntegration[] | GitLabIntegration[] | undefined>;
// Returns a list of all installed actions.
listActions(): Promise<ListActionsResponse>;
@@ -121,23 +117,6 @@ export class ScaffolderClient implements ScaffolderApi {
.filter(c => options.allowedHosts.includes(c.host));
}
async getIntegration(options: { type: string }) {
switch (options.type) {
case 'azure':
return this.scmIntegrationsApi.azure.list()
case 'bitbucket':
return this.scmIntegrationsApi.bitbucket.list()
case 'github':
return this.scmIntegrationsApi.github.list()
case 'gitlab':
return this.scmIntegrationsApi.gitlab.list()
default:
throw new Error(
`No integration found for ${options.type}`,
);
}
}
async getTemplateParameterSchema(
templateName: EntityName,
): Promise<TemplateParameterSchema> {
@@ -24,7 +24,6 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getIntegration: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
@@ -40,7 +40,6 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getIntegration: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
@@ -28,36 +28,50 @@ import { Progress } from '@backstage/core-components';
function splitFormData(url: string | undefined) {
let host = undefined;
let type = undefined;
let owner = undefined;
let repo = undefined;
let organization = undefined;
let workspace = undefined;
let project = undefined;
try {
if (url) {
const parsed = new URL(`https://${url}`);
host = parsed.host;
type = parsed.searchParams.get('type') || undefined;
owner = parsed.searchParams.get('owner') || undefined;
repo = parsed.searchParams.get('repo') || undefined;
// This is azure dev ops specific. not used for any other provider.
organization = parsed.searchParams.get('organization') || undefined;
// These are bitbucket specific, not used for any other provider.
workspace = parsed.searchParams.get('workspace') || undefined;
project = parsed.searchParams.get('project') || undefined;
}
} catch {
/* ok */
}
return { host, owner, repo, organization };
return { host, type, owner, repo, organization, workspace, project };
}
function serializeFormData(data: {
host?: string;
type?: string;
owner?: string;
repo?: string;
organization?: string;
workspace?: string;
project?: string;
}) {
if (!data.host) {
return undefined;
}
const params = new URLSearchParams();
if (data.type) {
params.set('type', data.type);
}
if (data.owner) {
params.set('owner', data.owner);
}
@@ -67,6 +81,12 @@ function serializeFormData(data: {
if (data.organization) {
params.set('organization', data.organization);
}
if (data.workspace) {
params.set('workspace', data.workspace);
}
if (data.project) {
params.set('project', data.project);
}
return `${data.host}?${params.toString()}`;
}
@@ -84,18 +104,22 @@ export const RepoUrlPicker = ({
return await api.getIntegrationsList({ allowedHosts });
});
const { host, owner, repo, organization } = splitFormData(formData);
const { host, type, owner, repo, organization, workspace, project } = splitFormData(formData);
const updateHost = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) => {
onChange(
serializeFormData({
host: evt.target.value as string,
type: (integrations?getIntegrationTypeByHost(evt.target.value as string, integrations): undefined),
owner,
repo,
organization,
workspace,
project,
}),
),
[onChange, owner, repo, organization],
)
},
[onChange, integrations, owner, repo, organization, workspace, project],
);
const updateOwner = useCallback(
@@ -103,12 +127,15 @@ export const RepoUrlPicker = ({
onChange(
serializeFormData({
host,
type,
owner: evt.target.value as string,
repo,
organization,
workspace,
project,
}),
),
[onChange, host, repo, organization],
[onChange, host, type, repo, organization, workspace, project],
);
const updateRepo = useCallback(
@@ -116,12 +143,15 @@ export const RepoUrlPicker = ({
onChange(
serializeFormData({
host,
type,
owner,
repo: evt.target.value as string,
organization,
workspace,
project,
}),
),
[onChange, host, owner, organization],
[onChange, host, type, owner, organization, workspace, project],
);
const updateOrganization = useCallback(
@@ -129,12 +159,47 @@ export const RepoUrlPicker = ({
onChange(
serializeFormData({
host,
type,
owner,
repo,
organization: evt.target.value as string,
workspace,
project,
}),
),
[onChange, host, owner, repo],
[onChange, host , type, owner, repo, workspace, project],
);
const updateWorkspace = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host,
type,
owner,
repo,
organization,
workspace: evt.target.value as string,
project,
}),
),
[onChange, host, type, owner, repo, organization, project],
);
const updateProject = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host,
type,
owner,
repo,
organization,
workspace,
project: evt.target.value as string,
}),
),
[onChange, host, type, owner, repo, organization, workspace],
);
useEffect(() => {
@@ -142,13 +207,16 @@ export const RepoUrlPicker = ({
onChange(
serializeFormData({
host: integrations[0].host,
type: integrations[0].type,
owner,
repo,
organization,
workspace,
project,
}),
);
}
}, [onChange, integrations, host, owner, repo, organization]);
}, [onChange, integrations, host, type, owner, repo, organization, workspace, project]);
if (loading) {
return <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,52 @@ 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>
{/* Show this for bitbucket.org only */}
{type === 'bitbucket' && (
<>
{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 */}
{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
@@ -1,239 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect } from 'react';
import { Field } from '@rjsf/core';
import { useApi, Progress } from '@backstage/core';
import { scaffolderApiRef } from '../../../api';
import { useAsync } from 'react-use';
import Select from '@material-ui/core/Select';
import InputLabel from '@material-ui/core/InputLabel';
import Input from '@material-ui/core/Input';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import { BitbucketIntegration } from '@backstage/integration';
function splitFormData(url: string | undefined) {
let host = undefined;
let workspace = undefined;
let project = undefined;
let repo = undefined;
try {
if (url) {
const parsed = new URL(`https://${url}`);
host = parsed.host;
workspace = parsed.searchParams.get('workspace') || undefined;
project = parsed.searchParams.get('project') || undefined;
repo = parsed.searchParams.get('repo') || undefined;
}
} catch {
/* ok */
}
return { host, workspace, project, repo };
}
function serializeFormData(data: {
host?: string;
workspace?: string;
project?: string;
repo?: string;
}) {
if (!data.host) {
return undefined;
}
const params = new URLSearchParams();
if (data.workspace) {
params.set('workspace', data.workspace);
}
if (data.project) {
params.set('project', data.project);
}
if (data.repo) {
params.set('repo', data.repo);
}
return `${data.host}?${params.toString()}`;
}
export const RepoUrlPickerBitbucketCloud: Field = ({
onChange,
rawErrors,
formData,
}) => {
const api = useApi(scaffolderApiRef);
const allowedHosts: string[] = ['bitbucket.org'];
const { value: integrations, loading: loadingIntegrations } = useAsync(async () => {
return await api.getIntegrationsList({ allowedHosts });
});
const { value: data, loading: loadingData } = useAsync(async () => {
const bitbucketIntegrations: BitbucketIntegration[] = await api.getIntegration({ type: 'bitbucket' }) as BitbucketIntegration[];
const bitbucketIntegration = bitbucketIntegrations.filter(element => element.config.host === 'bitbucket.org')[0];
const workspaces = bitbucketIntegration.config.workspaces;
const projects = workspaces?.[0]?.projects;
return { workspaces, projects };
});
const { host, workspace, project, repo } = splitFormData(formData);
const updateHost = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host: evt.target.value as string,
workspace,
project,
repo,
}),
),
[onChange, workspace, project, repo],
);
const updateWorkspace = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) => {
if (data) {
data.projects = data?.workspaces?.find(w => w.name === evt.target.value)?.projects;
}
onChange(
serializeFormData({
host,
workspace: evt.target.value as string,
project,
repo,
}),
);
},
[onChange, data, host, project, repo],
);
const updateProject = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) => {
onChange(
serializeFormData({
host,
workspace,
project: evt.target.value as string,
repo,
}),
);
},
[onChange, host, workspace, repo],
);
const updateRepo = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host,
workspace,
project,
repo: evt.target.value as string,
}),
),
[onChange, host, workspace, project],
);
useEffect(() => {
onChange(
serializeFormData({
host: (host === undefined ? (integrations?.[0]?.host) : undefined),
workspace: workspace === undefined ? (data?.workspaces?.[0]?.name) : undefined,
project: project === undefined ? (data?.workspaces?.[0]?.projects?.[0]?.key) : undefined,
repo,
}),
);
}, [onChange, integrations, data, host, workspace, project, repo]);
if (loadingIntegrations || loadingData) {
return <Progress />;
}
return (
<>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !host}
>
<InputLabel htmlFor="hostInput">Host</InputLabel>
<Select native id="hostInput" onChange={updateHost} value={host}>
{integrations ? (
integrations
.filter(i => allowedHosts?.includes(i.host))
.map(i => (
<option key={i.host} value={i.host}>
{i.title}
</option>
))
) : (
<p>loading</p>
)}
</Select>
<FormHelperText>
The host where the repository will be created
</FormHelperText>
</FormControl>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !workspace}
>
<InputLabel htmlFor="workspaceInput">Workspace</InputLabel>
<Select native id="workspaceInput" onChange={updateWorkspace} value={workspace}>
{data?.workspaces? (
data.workspaces.map((w, index) => (
<option key={index} value={w.name}>{w.description?w.description:w.name}</option>
))
) : (
<p>loading</p>
)}
</Select>
<FormHelperText>
The workspace where the repository will be created
</FormHelperText>
</FormControl>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !project}
>
<InputLabel htmlFor="projectInput">Project</InputLabel>
<Select native id="projectInput" onChange={updateProject} value={project}>
{data?.projects ? (
data.projects.map((p, index) => (
<option key={index} value={p.key}>{p.description?p.description:p.name}</option>
))
) : (
<p>loading</p>
)}
</Select>
<FormHelperText>The bitbucket project this repo should be added to</FormHelperText>
</FormControl>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !repo}
>
<InputLabel htmlFor="repoInput">Repository</InputLabel>
<Input id="repoInput" onChange={updateRepo} value={repo} />
<FormHelperText>The name of the repository</FormHelperText>
</FormControl>
</>
);
};