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:
@@ -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 }}'
|
||||
+8
@@ -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,
|
||||
|
||||
+187
@@ -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 };
|
||||
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+239
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user