Add repourlpicker and publisher for bitbucket cloud

The bitbucket cloud api needs project key to create repo under
a project.

Signed-off-by: Mustansar Anwar ul Samad <mustansar.samad@gmail.com>
This commit is contained in:
Mustansar Anwar ul Samad
2021-04-19 13:18:18 +12:00
parent 0c58dd73d0
commit 9b41ea27b6
14 changed files with 857 additions and 0 deletions
@@ -0,0 +1,75 @@
apiVersion: backstage.io/v1beta2
kind: Template
metadata:
name: bitbucket-cloud
title: Test Bitbucket Cloud RepoUrlPicker template
description: scaffolder v1beta2 template demo publishing to bitbucket cloud
spec:
owner: backstage/techdocs-core
type: service
parameters:
- title: Choose a location
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPickerBitbucketCloud
- title: Fill in some steps
required:
- name
- owner
properties:
name:
title: Name
type: string
description: Unique name of the component
ui:autofocus: true
ui:options:
rows: 5
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
steps:
- id: fetch-base
name: Fetch Base
action: fetch:cookiecutter
input:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
- id: fetch-docs
name: Fetch Docs
action: fetch:plain
input:
targetPath: ./community
url: https://github.com/backstage/community/tree/main/backstage-community-sessions
- id: publish
name: Publish
action: publish:bitbucketcloud
input:
allowedHosts: ['bitbucket.org']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
output:
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
entityRef: '{{ steps.register.output.entityRef }}'
@@ -0,0 +1,8 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: {{cookiecutter.name | jsonify}}
spec:
type: website
lifecycle: experimental
owner: {{cookiecutter.owner | jsonify}}
@@ -0,0 +1,14 @@
apiVersion: backstage.io/v1alpha1
kind: Location
metadata:
name: example-templates-local
description: A collection of locally available Backstage example templates
spec:
targets:
- ./create-react-app/template.yaml
- ./docs-template/template.yaml
- ./react-ssr-template/template.yaml
- ./springboot-grpc-template/template.yaml
- ./v1beta2-demo/template.yaml
- ./pull-request/template.yaml
- ./bitbucket-cloud-demo/template.yaml
@@ -33,6 +33,7 @@ import {
import {
createPublishAzureAction,
createPublishBitbucketAction,
createPublishBitbucketCloudAction,
createPublishGithubAction,
createPublishGithubPullRequestAction,
createPublishGitlabAction,
@@ -78,6 +79,9 @@ export const createBuiltinActions = (options: {
integrations,
config,
}),
createPublishBitbucketCloudAction({
integrations,
}),
createPublishAzureAction({
integrations,
config,
@@ -0,0 +1,187 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
import { createPublishBitbucketCloudAction } from './bitbucketCloud';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
describe('publish:bitbucketcloud', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
],
},
}),
);
const action = createPublishBitbucketCloudAction({ integrations });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?repo=repo&workspace=workspace&project=project',
repoVisibility: 'private',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const server = setupServer();
msw.setupDefaultHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?repo=bob&workspace=workspace1' },
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?repo=bob&project=project1' },
}),
).rejects.toThrow(/missing workspace/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'bitbucket.org?workspace=workspace1&project=project1' },
}),
).rejects.toThrow(/missing repo/);
});
it('should call the correct APIs when the host is bitbucket cloud', async () => {
expect.assertions(2);
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer tokenlols');
expect(req.body).toEqual({ is_private: true, scm: 'git', project: { key: 'project' } });
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/repo',
},
],
},
}),
);
},
),
);
await action.handler(mockContext);
});
it('should call initAndPush with the correct values', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call outputs with the correct urls', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.org/workspace/cloneurl',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.org/workspace/repo/src/master',
);
});
});
@@ -0,0 +1,198 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import {
BitbucketIntegrationConfig,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { getRepoSourceDirectory, parseRepoUrlForBitbucket } from './util';
import fetch from 'cross-fetch';
import { createTemplateAction } from '../../createTemplateAction';
const createBitbucketCloudRepository = async (opts: {
workspace: string;
project: string;
repo: string;
description: string;
repoVisibility: 'private' | 'public';
authorization: string;
}) => {
const { workspace, project, repo, description, repoVisibility, authorization } = opts;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
scm: 'git',
description: description,
is_private: repoVisibility === 'private',
project: { key: project },
}),
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
let response: Response;
try {
response = await fetch(
`https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}`,
options,
);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status !== 200) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// TODO use the urlReader to get the default branch
const repoContentsUrl = `${r.links.html.href}/src/master`;
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => {
if (config.username && config.appPassword) {
const buffer = Buffer.from(
`${config.username}:${config.appPassword}`,
'utf8',
);
return `Basic ${buffer.toString('base64')}`;
}
if (config.token) {
return `Bearer ${config.token}`;
}
throw new Error(
`Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`,
);
};
export function createPublishBitbucketCloudAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
description: string;
repoVisibility: 'private' | 'public';
sourcePath?: string;
}>({
id: 'publish:bitbucketcloud',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
repoVisibility: {
title: 'Repository Visiblity',
type: 'string',
enum: ['private', 'public'],
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.',
type: 'string',
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const { repoUrl, description, repoVisibility = 'private' } = ctx.input;
const { workspace, project, repo, host } = parseRepoUrlForBitbucket(repoUrl);
const integrationConfig = integrations.bitbucket.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const authorization = getAuthorizationHeader(integrationConfig.config);
const createMethod = createBitbucketCloudRepository;
const { remoteUrl, repoContentsUrl } = await createMethod({
authorization,
workspace,
project,
repo,
repoVisibility,
description,
});
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
auth: {
username: integrationConfig.config.username
? integrationConfig.config.username
: 'x-token-auth',
password: integrationConfig.config.appPassword
? integrationConfig.config.appPassword
: integrationConfig.config.token ?? '',
},
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -16,6 +16,7 @@
export { createPublishAzureAction } from './azure';
export { createPublishBitbucketAction } from './bitbucket';
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishFileAction } from './file';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
@@ -65,3 +65,42 @@ export const parseRepoUrl = (repoUrl: string): RepoSpec => {
return { host, owner, repo, organization };
};
export const parseRepoUrlForBitbucket = (repoUrl: string) => {
let parsed;
try {
parsed = new URL(`https://${repoUrl}`);
} catch (error) {
throw new InputError(
`Invalid repo URL passed to publisher, got ${repoUrl}, ${error}`,
);
}
const host = parsed.host;
const workspace = parsed.searchParams.get('workspace');
if (!workspace) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing workspace`,
);
}
const project = parsed.searchParams.get('project');
if (!project) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing project`,
);
}
const repo = parsed.searchParams.get('repo');
if (!repo) {
throw new InputError(
`Invalid repo URL passed to publisher: ${repoUrl}, missing repo`,
);
}
const organization = parsed.searchParams.get('organization');
return { host, workspace, project, repo, organization };
};
+21
View File
@@ -79,6 +79,10 @@ 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>;
@@ -117,6 +121,23 @@ export class ScaffolderClient implements ScaffolderApi {
.filter(c => options.allowedHosts.includes(c.host));
}
async getIntegration(options: { type: string }) {
switch (options.type) {
case 'azure':
return this.scmIntegrationsApi.azure.list()
case 'bitbucket':
return this.scmIntegrationsApi.bitbucket.list()
case 'github':
return this.scmIntegrationsApi.github.list()
case 'gitlab':
return this.scmIntegrationsApi.gitlab.list()
default:
throw new Error(
`No integration found for ${options.type}`,
);
}
}
async getTemplateParameterSchema(
templateName: EntityName,
): Promise<TemplateParameterSchema> {
@@ -24,6 +24,7 @@ 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,6 +40,7 @@ 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(),
@@ -0,0 +1,239 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect } from 'react';
import { Field } from '@rjsf/core';
import { useApi, Progress } from '@backstage/core';
import { scaffolderApiRef } from '../../../api';
import { useAsync } from 'react-use';
import Select from '@material-ui/core/Select';
import InputLabel from '@material-ui/core/InputLabel';
import Input from '@material-ui/core/Input';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import { BitbucketIntegration } from '@backstage/integration';
function splitFormData(url: string | undefined) {
let host = undefined;
let workspace = undefined;
let project = undefined;
let repo = undefined;
try {
if (url) {
const parsed = new URL(`https://${url}`);
host = parsed.host;
workspace = parsed.searchParams.get('workspace') || undefined;
project = parsed.searchParams.get('project') || undefined;
repo = parsed.searchParams.get('repo') || undefined;
}
} catch {
/* ok */
}
return { host, workspace, project, repo };
}
function serializeFormData(data: {
host?: string;
workspace?: string;
project?: string;
repo?: string;
}) {
if (!data.host) {
return undefined;
}
const params = new URLSearchParams();
if (data.workspace) {
params.set('workspace', data.workspace);
}
if (data.project) {
params.set('project', data.project);
}
if (data.repo) {
params.set('repo', data.repo);
}
return `${data.host}?${params.toString()}`;
}
export const RepoUrlPickerBitbucketCloud: Field = ({
onChange,
rawErrors,
formData,
}) => {
const api = useApi(scaffolderApiRef);
const allowedHosts: string[] = ['bitbucket.org'];
const { value: integrations, loading: loadingIntegrations } = useAsync(async () => {
return await api.getIntegrationsList({ allowedHosts });
});
const { value: data, loading: loadingData } = useAsync(async () => {
const bitbucketIntegrations: BitbucketIntegration[] = await api.getIntegration({ type: 'bitbucket' }) as BitbucketIntegration[];
const bitbucketIntegration = bitbucketIntegrations.filter(element => element.config.host === 'bitbucket.org')[0];
const workspaces = bitbucketIntegration.config.workspaces;
const projects = workspaces?.[0]?.projects;
return { workspaces, projects };
});
const { host, workspace, project, repo } = splitFormData(formData);
const updateHost = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host: evt.target.value as string,
workspace,
project,
repo,
}),
),
[onChange, workspace, project, repo],
);
const updateWorkspace = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) => {
if (data) {
data.projects = data?.workspaces?.find(w => w.name === evt.target.value)?.projects;
}
onChange(
serializeFormData({
host,
workspace: evt.target.value as string,
project,
repo,
}),
);
},
[onChange, data, host, project, repo],
);
const updateProject = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) => {
onChange(
serializeFormData({
host,
workspace,
project: evt.target.value as string,
repo,
}),
);
},
[onChange, host, workspace, repo],
);
const updateRepo = useCallback(
(evt: React.ChangeEvent<{ name?: string; value: unknown }>) =>
onChange(
serializeFormData({
host,
workspace,
project,
repo: evt.target.value as string,
}),
),
[onChange, host, workspace, project],
);
useEffect(() => {
onChange(
serializeFormData({
host: (host === undefined ? (integrations?.[0]?.host) : undefined),
workspace: workspace === undefined ? (data?.workspaces?.[0]?.name) : undefined,
project: project === undefined ? (data?.workspaces?.[0]?.projects?.[0]?.key) : undefined,
repo,
}),
);
}, [onChange, integrations, data, host, workspace, project, repo]);
if (loadingIntegrations || loadingData) {
return <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>
</>
);
};