Merge pull request #25132 from benjidotsh/feat/scaffolder-bitbucket-autocomplete

feat(scaffolder): add autocompletion for Bitbucket
This commit is contained in:
Ben Lambert
2024-06-25 11:00:28 +02:00
committed by GitHub
36 changed files with 969 additions and 73 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': minor
---
Add support for `token` for `bitbucketCloud` integration
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch
'@backstage/plugin-bitbucket-cloud-common': patch
---
Add support for `autocomplete` handler to provide autocomplete options for `RepoUrlPicker`
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-node': patch
---
Add support for `autocomplete` extension point to provide additional `autocomplete` handlers
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder': minor
---
Add support for `bitbucketCloud` autocomplete in `RepoUrlPicker`
+1
View File
@@ -186,6 +186,7 @@ export type BitbucketCloudIntegrationConfig = {
apiBaseUrl: string;
username?: string;
appPassword?: string;
token?: string;
};
// @public @deprecated
@@ -46,6 +46,11 @@ export type BitbucketCloudIntegrationConfig = {
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
*/
appPassword?: string;
/**
* The access token to use for requests to Bitbucket Cloud (bitbucket.org).
*/
token?: string;
};
/**
@@ -12,11 +12,20 @@ export class BitbucketCloudClient {
config: BitbucketCloudIntegrationConfig,
): BitbucketCloudClient;
// (undocumented)
listProjectsByWorkspace(
workspace: string,
options?: FilterAndSortOptions & PartialResponseOptions,
): WithPagination<Models.PaginatedProjects, Models.Project>;
// (undocumented)
listRepositoriesByWorkspace(
workspace: string,
options?: FilterAndSortOptions & PartialResponseOptions,
): WithPagination<Models.PaginatedRepositories, Models.Repository>;
// (undocumented)
listWorkspaces(
options?: FilterAndSortOptions & PartialResponseOptions,
): WithPagination<Models.PaginatedWorkspaces, Models.Workspace>;
// (undocumented)
searchCode(
workspace: string,
query: string,
@@ -200,9 +209,15 @@ export namespace Models {
size?: number;
values?: Array<TResultItem> | Set<TResultItem>;
}
export interface PaginatedProjects extends Paginated<Project> {
values?: Set<Project>;
}
export interface PaginatedRepositories extends Paginated<Repository> {
values?: Set<Repository>;
}
export interface PaginatedWorkspaces extends Paginated<Workspace> {
values?: Set<Workspace>;
}
export interface Participant extends ModelObject {
// (undocumented)
approved?: boolean;
@@ -107,4 +107,59 @@ describe('BitbucketCloudClient', () => {
expect(results).toHaveLength(1);
expect(results[0].slug).toEqual('repo1');
});
it('listProjectsByWorkspace', async () => {
server.use(
rest.get(
'https://api.bitbucket.org/2.0/workspaces/ws/projects',
(_, res, ctx) => {
const response = {
values: [
{
type: 'project',
slug: 'project1',
} as Models.Project,
],
};
return res(ctx.json(response));
},
),
);
const pagination = client.listProjectsByWorkspace('ws');
const results = [];
for await (const result of pagination.iterateResults()) {
results.push(result);
}
expect(results).toHaveLength(1);
expect(results[0].slug).toEqual('project1');
});
it('listWorkspaces', async () => {
server.use(
rest.get('https://api.bitbucket.org/2.0/workspaces', (_, res, ctx) => {
const response = {
values: [
{
type: 'workspace',
slug: 'workspace1',
} as Models.Workspace,
],
};
return res(ctx.json(response));
}),
);
const pagination = client.listWorkspaces();
const results = [];
for await (const result of pagination.iterateResults()) {
results.push(result);
}
expect(results).toHaveLength(1);
expect(results[0].slug).toEqual('workspace1');
});
});
@@ -69,6 +69,32 @@ export class BitbucketCloudClient {
);
}
listProjectsByWorkspace(
workspace: string,
options?: FilterAndSortOptions & PartialResponseOptions,
): WithPagination<Models.PaginatedProjects, Models.Project> {
const workspaceEnc = encodeURIComponent(workspace);
return new WithPagination(
paginationOptions =>
this.createUrl(`/workspaces/${workspaceEnc}/projects`, {
...paginationOptions,
...options,
}),
url => this.getTypeMapped(url),
);
}
listWorkspaces(
options?: FilterAndSortOptions & PartialResponseOptions,
): WithPagination<Models.PaginatedWorkspaces, Models.Workspace> {
return new WithPagination(
paginationOptions =>
this.createUrl('/workspaces', { ...paginationOptions, ...options }),
url => this.getTypeMapped(url),
);
}
private createUrl(endpoint: string, options?: RequestOptions): URL {
const request = new URL(this.config.apiBaseUrl + endpoint);
for (const key in options) {
@@ -113,6 +139,8 @@ export class BitbucketCloudClient {
'utf8',
);
headers.Authorization = `Basic ${buffer.toString('base64')}`;
} else if (this.config.token) {
headers.Authorization = `Bearer ${this.config.token}`;
}
return headers;
@@ -253,6 +253,28 @@ export namespace Models {
values?: Set<Repository>;
}
/**
* A paginated list of projects.
* @public
*/
export interface PaginatedProjects extends Paginated<Project> {
/**
* The values of the current page.
*/
values?: Set<Project>;
}
/**
* A paginated list of workspaces.
* @public
*/
export interface PaginatedWorkspaces extends Paginated<Workspace> {
/**
* The values of the current page.
*/
values?: Set<Workspace>;
}
/**
* Object describing a user's role on resources like commits or pull requests.
* @public
@@ -46,6 +46,7 @@
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"fs-extra": "^11.2.0",
"node-fetch": "^2.6.7",
@@ -0,0 +1,111 @@
/*
* Copyright 2024 The Backstage Authors
*
* 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 { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common';
import { handleAutocompleteRequest } from './autocomplete';
import { InputError } from '@backstage/errors';
describe('handleAutocompleteRequest', () => {
const client: Partial<BitbucketCloudClient> = {
listWorkspaces: jest.fn().mockReturnValue({
iteratePages: jest
.fn()
.mockReturnValue([{ values: [{ slug: 'workspace1' }] }]),
}),
listProjectsByWorkspace: jest.fn().mockReturnValue({
iteratePages: jest
.fn()
.mockReturnValue([{ values: [{ key: 'project1' }] }]),
}),
listRepositoriesByWorkspace: jest.fn().mockReturnValue({
iteratePages: jest
.fn()
.mockReturnValue([{ values: [{ slug: 'repository1' }] }]),
}),
};
const fromConfig = jest
.spyOn(BitbucketCloudClient, 'fromConfig')
.mockReturnValue(client as BitbucketCloudClient);
it('should pass the token to the client', async () => {
const accessToken = 'foo';
await handleAutocompleteRequest({
token: accessToken,
context: {},
resource: 'workspaces',
});
expect(fromConfig).toHaveBeenCalledWith(
expect.objectContaining({ token: accessToken }),
);
});
it('should return workspaces', async () => {
const result = await handleAutocompleteRequest({
token: 'foo',
context: {},
resource: 'workspaces',
});
expect(result).toEqual({ results: [{ title: 'workspace1' }] });
});
it('should return projects', async () => {
const result = await handleAutocompleteRequest({
token: 'foo',
context: {
workspace: 'workspace1',
},
resource: 'projects',
});
expect(result).toEqual({ results: [{ title: 'project1' }] });
});
it('should return repositories', async () => {
const result = await handleAutocompleteRequest({
token: 'foo',
resource: 'repositories',
context: {
workspace: 'workspace1',
project: 'project1',
},
});
expect(result).toEqual({ results: [{ title: 'repository1' }] });
});
it('should throw an error when passing an invalid resource', async () => {
await expect(
handleAutocompleteRequest({
token: 'token',
resource: 'invalid',
context: {},
}),
).rejects.toThrow(InputError);
});
it('should throw an error when there are missing parameters', async () => {
await expect(
handleAutocompleteRequest({
token: 'token',
resource: 'projects',
context: {},
}),
).rejects.toThrow(InputError);
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2024 The Backstage Authors
*
* 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 { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common';
export async function handleAutocompleteRequest({
resource,
token,
context,
}: {
resource: string;
token: string;
context: Record<string, string>;
}): Promise<{ results: { title: string }[] }> {
const client = BitbucketCloudClient.fromConfig({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
token,
});
switch (resource) {
case 'workspaces': {
const result: string[] = [];
for await (const page of client.listWorkspaces().iteratePages()) {
const slugs = [...page.values!].map(p => p.slug!);
result.push(...slugs);
}
return { results: result.map(title => ({ title })) };
}
case 'projects': {
if (!context.workspace)
throw new InputError('Missing workspace context parameter');
const result: string[] = [];
for await (const page of client
.listProjectsByWorkspace(context.workspace)
.iteratePages()) {
const keys = [...page.values!].map(p => p.key!);
result.push(...keys);
}
return { results: result.map(title => ({ title })) };
}
case 'repositories': {
if (!context.workspace || !context.project)
throw new InputError(
'Missing workspace and/or project context parameter',
);
const result: string[] = [];
for await (const page of client
.listRepositoriesByWorkspace(context.workspace, {
q: `project.key="${context.project}"`,
})
.iteratePages()) {
const slugs = [...page.values!].map(p => p.slug!);
result.push(...slugs);
}
return { results: result.map(title => ({ title })) };
}
default:
throw new InputError(`Invalid resource: ${resource}`);
}
}
@@ -17,12 +17,16 @@ import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import {
scaffolderActionsExtensionPoint,
scaffolderAutocompleteExtensionPoint,
} from '@backstage/plugin-scaffolder-node/alpha';
import {
createBitbucketPipelinesRunAction,
createPublishBitbucketCloudAction,
} from './actions';
import { ScmIntegrations } from '@backstage/integration';
import { handleAutocompleteRequest } from './autocomplete/autocomplete';
/**
* @public
@@ -35,15 +39,21 @@ export const bitbucketCloudModule = createBackendModule({
registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
autocomplete: scaffolderAutocompleteExtensionPoint,
config: coreServices.rootConfig,
},
async init({ scaffolder, config }) {
async init({ scaffolder, config, autocomplete }) {
const integrations = ScmIntegrations.fromConfig(config);
scaffolder.addActions(
createPublishBitbucketCloudAction({ integrations, config }),
createBitbucketPipelinesRunAction({ integrations }),
);
autocomplete.addAutocompleteProvider({
id: 'bitbucket-cloud',
handler: handleAutocompleteRequest,
});
},
});
},
+3
View File
@@ -7,6 +7,7 @@
import { ActionContext as ActionContext_2 } from '@backstage/plugin-scaffolder-node';
import { AuthService } from '@backstage/backend-plugin-api';
import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha';
import * as azure from '@backstage/plugin-scaffolder-backend-module-azure';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import * as bitbucket from '@backstage/plugin-scaffolder-backend-module-bitbucket';
@@ -478,6 +479,8 @@ export interface RouterOptions {
// (undocumented)
auth?: AuthService;
// (undocumented)
autocompleteHandlers?: Record<string, AutocompleteHandler>;
// (undocumented)
catalogClient: CatalogApi;
concurrentTasksLimit?: number;
// (undocumented)
+2
View File
@@ -70,6 +70,7 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -113,6 +114,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-scaffolder-node-test-utils": "workspace:^",
@@ -28,7 +28,9 @@ import {
TemplateGlobal,
} from '@backstage/plugin-scaffolder-node';
import {
AutocompleteHandler,
scaffolderActionsExtensionPoint,
scaffolderAutocompleteExtensionPoint,
scaffolderTaskBrokerExtensionPoint,
scaffolderTemplatingExtensionPoint,
} from '@backstage/plugin-scaffolder-node/alpha';
@@ -82,6 +84,13 @@ export const scaffolderPlugin = createBackendPlugin({
},
});
const autocompleteHandlers: Record<string, AutocompleteHandler> = {};
env.registerExtensionPoint(scaffolderAutocompleteExtensionPoint, {
addAutocompleteProvider(provider) {
autocompleteHandlers[provider.id] = provider.handler;
},
});
env.registerInit({
deps: {
logger: coreServices.logger,
@@ -162,6 +171,7 @@ export const scaffolderPlugin = createBackendPlugin({
httpAuth,
discovery,
permissions,
autocompleteHandlers,
});
httpRouter.use(router);
},
@@ -46,6 +46,8 @@ import {
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha';
import { MiddlewareFactory } from '@backstage/backend-app-api';
const mockAccess = jest.fn();
@@ -1461,5 +1463,75 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
expect(subscriber!.closed).toBe(true);
});
});
describe('GET /v2/autocomplete/:provider/:resource', () => {
let handleAutocompleteRequest: AutocompleteHandler;
beforeEach(async () => {
handleAutocompleteRequest = jest.fn().mockResolvedValue({
results: [{ title: 'blob' }],
});
const logger = mockServices.logger.mock();
const middleware = MiddlewareFactory.create({ config, logger });
const router = await createRouter({
logger: loggerToWinstonLogger(mockServices.logger.mock()),
config: new ConfigReader({}),
database: createDatabase(),
catalogClient,
reader: mockUrlReader,
taskBroker,
permissions: permissionApi,
auth,
httpAuth,
discovery,
autocompleteHandlers: {
'test-provider': handleAutocompleteRequest,
},
});
app = express().use(router).use(middleware.error());
});
it('should throw an error when the provider is not registered', async () => {
const response = await request(app)
.post('/v2/autocomplete/unknown-provider/resource')
.send({
token: 'token',
context: {},
});
expect(response.status).toEqual(400);
expect(response.body).toEqual(
expect.objectContaining({
error: {
message: 'Unsupported provider: unknown-provider',
name: 'InputError',
},
}),
);
});
it('should call the autocomplete handler', async () => {
const context = { mock: 'context' };
const mockToken = 'mocktoken';
const response = await request(app)
.post('/v2/autocomplete/test-provider/resource')
.send({
token: mockToken,
context,
});
expect(response.status).toEqual(200);
expect(response.body).toEqual({ results: [{ title: 'blob' }] });
expect(handleAutocompleteRequest).toHaveBeenCalledWith({
token: mockToken,
context,
resource: 'resource',
});
});
});
});
});
@@ -94,6 +94,7 @@ import {
} from '@backstage/plugin-auth-node';
import { InternalTaskSecrets } from '../scaffolder/tasks/types';
import { checkPermission } from '../util/checkPermissions';
import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha';
/**
*
@@ -166,6 +167,8 @@ export interface RouterOptions {
httpAuth?: HttpAuthService;
identity?: IdentityApi;
discovery?: DiscoveryService;
autocompleteHandlers?: Record<string, AutocompleteHandler>;
}
function isSupportedTemplate(entity: TemplateEntityV1beta3) {
@@ -273,6 +276,7 @@ export async function createRouter(
permissionRules,
discovery = HostDiscovery.fromConfig(config),
identity = buildDefaultIdentityClient(options),
autocompleteHandlers = {},
} = options;
const { auth, httpAuth } = createLegacyAuthAdapters({
@@ -771,6 +775,24 @@ export async function createRouter(
base64Content: file.content.toString('base64'),
})),
});
})
.post('/v2/autocomplete/:provider/:resource', async (req, res) => {
const { token, context } = req.body;
const { provider, resource } = req.params;
if (!token) throw new InputError('Missing token query parameter');
if (!autocompleteHandlers[provider]) {
throw new InputError(`Unsupported provider: ${provider}`);
}
const { results } = await autocompleteHandlers[provider]({
resource,
token,
context,
});
res.status(200).json({ results });
});
const app = express();
@@ -9,6 +9,21 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { TemplateFilter } from '@backstage/plugin-scaffolder-node';
import { TemplateGlobal } from '@backstage/plugin-scaffolder-node';
// @alpha
export type AutocompleteHandler = ({
resource,
token,
context,
}: {
resource: string;
token: string;
context: Record<string, string>;
}) => Promise<{
results: {
title: string;
}[];
}>;
// @alpha
export interface ScaffolderActionsExtensionPoint {
// (undocumented)
@@ -18,6 +33,21 @@ export interface ScaffolderActionsExtensionPoint {
// @alpha
export const scaffolderActionsExtensionPoint: ExtensionPoint<ScaffolderActionsExtensionPoint>;
// @alpha
export interface ScaffolderAutocompleteExtensionPoint {
// (undocumented)
addAutocompleteProvider({
id,
handler,
}: {
id: string;
handler: AutocompleteHandler;
}): void;
}
// @alpha
export const scaffolderAutocompleteExtensionPoint: ExtensionPoint<ScaffolderAutocompleteExtensionPoint>;
// @alpha
export interface ScaffolderTaskBrokerExtensionPoint {
// (undocumented)
+38
View File
@@ -79,3 +79,41 @@ export const scaffolderTemplatingExtensionPoint =
createExtensionPoint<ScaffolderTemplatingExtensionPoint>({
id: 'scaffolder.templating',
});
/**
* Autocomplete handler for the scaffolder.
* @alpha
*/
export type AutocompleteHandler = ({
resource,
token,
context,
}: {
resource: string;
token: string;
context: Record<string, string>;
}) => Promise<{ results: { title: string }[] }>;
/**
* Extension point for adding autocomplete handler providers
* @alpha
*/
export interface ScaffolderAutocompleteExtensionPoint {
addAutocompleteProvider({
id,
handler,
}: {
id: string;
handler: AutocompleteHandler;
}): void;
}
/**
* Extension point for adding template filters and globals.
*
* @alpha
*/
export const scaffolderAutocompleteExtensionPoint =
createExtensionPoint<ScaffolderAutocompleteExtensionPoint>({
id: 'scaffolder.autocomplete',
});
+11
View File
@@ -179,6 +179,17 @@ export type ReviewStepProps = {
// @public
export interface ScaffolderApi {
// (undocumented)
autocomplete?(options: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{
results: {
title: string;
}[];
}>;
cancelTask(taskId: string): Promise<void>;
// (undocumented)
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
@@ -229,4 +229,11 @@ export interface ScaffolderApi {
streamLogs(options: ScaffolderStreamLogsOptions): Observable<LogEvent>;
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
autocomplete?(options: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{ results: { title: string }[] }>;
}
@@ -36,6 +36,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
+16
View File
@@ -502,6 +502,22 @@ export class ScaffolderClient implements ScaffolderApi_2 {
useLongPollingLogs?: boolean;
});
// (undocumented)
autocomplete({
token,
resource,
provider,
context,
}: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{
results: {
title: string;
}[];
}>;
// (undocumented)
cancelTask(taskId: string): Promise<void>;
// (undocumented)
dryRun(
+34
View File
@@ -337,4 +337,38 @@ export class ScaffolderClient implements ScaffolderApi {
return await response.json();
}
async autocomplete({
token,
resource,
provider,
context,
}: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{ results: { title: string }[] }> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}`;
const response = await this.fetchApi.fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
context: context ?? {},
}),
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { results } = await response.json();
return { results };
}
}
@@ -33,6 +33,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]);
@@ -16,18 +16,33 @@
import React from 'react';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestApiProvider } from '@backstage/test-utils';
import {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { act } from 'react-dom/test-utils';
describe('BitbucketRepoPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest.fn().mockImplementation(opts => ({
results: [{ title: `${opts.resource}_example` }],
})),
};
it('renders a select if there is a list of allowed owners', async () => {
const allowedOwners = ['owner1', 'owner2'];
const { findByText } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedOwners={allowedOwners}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedOwners={allowedOwners}
/>
</TestApiProvider>,
);
expect(await findByText('owner1')).toBeInTheDocument();
@@ -38,7 +53,13 @@ describe('BitbucketRepoPicker', () => {
const state = { host: 'bitbucket.org', workspace: 'lolsWorkspace' };
const { getAllByRole } = render(
<BitbucketRepoPicker onChange={jest.fn()} rawErrors={[]} state={state} />,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={state}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -51,7 +72,13 @@ describe('BitbucketRepoPicker', () => {
};
const { getAllByRole } = render(
<BitbucketRepoPicker onChange={jest.fn()} rawErrors={[]} state={state} />,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={state}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(1);
@@ -61,16 +88,24 @@ describe('BitbucketRepoPicker', () => {
it('calls onChange when the workspace changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>
</TestApiProvider>,
);
const workspaceInput = getAllByRole('textbox')[0];
fireEvent.change(workspaceInput, { target: { value: 'test-workspace' } });
act(() => {
workspaceInput.focus();
fireEvent.change(workspaceInput, {
target: { value: 'test-workspace' },
});
workspaceInput.blur();
});
expect(onChange).toHaveBeenCalledWith({ workspace: 'test-workspace' });
});
@@ -80,27 +115,35 @@ describe('BitbucketRepoPicker', () => {
it('calls onChange when the project changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
/>
</TestApiProvider>,
);
const projectInput = getAllByRole('textbox')[1];
fireEvent.change(projectInput, { target: { value: 'test-project' } });
act(() => {
projectInput.focus();
fireEvent.change(projectInput, { target: { value: 'test-project' } });
projectInput.blur();
});
expect(onChange).toHaveBeenCalledWith({ project: 'test-project' });
});
it('Does not render a select if the list of allowed projects does not exist', async () => {
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -109,12 +152,14 @@ describe('BitbucketRepoPicker', () => {
it('Does not render a select if the list of allowed projects is empty', async () => {
const { getAllByRole } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={[]}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={[]}
/>
</TestApiProvider>,
);
expect(getAllByRole('textbox')).toHaveLength(2);
@@ -124,16 +169,104 @@ describe('BitbucketRepoPicker', () => {
it('Does render a select if there is a list of allowed projects', async () => {
const allowedProjects = ['project1', 'project2'];
const { findByText } = render(
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={allowedProjects}
/>,
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={jest.fn()}
rawErrors={[]}
state={{ host: 'bitbucket.org', repoName: 'repo' }}
allowedProjects={allowedProjects}
/>
</TestApiProvider>,
);
expect(await findByText('project1')).toBeInTheDocument();
expect(await findByText('project2')).toBeInTheDocument();
});
});
describe('autocompletion', () => {
it('should populate workspaces if host is set and accessToken is provided', async () => {
const onChange = jest.fn();
const { getAllByRole, getByText } = render(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org' }}
accessToken="foo"
/>
</TestApiProvider>,
);
// Open the Autcomplete dropdown
const workspaceInput = getAllByRole('textbox')[0];
await userEvent.click(workspaceInput);
// Verify that the available workspaces are shown
await waitFor(() =>
expect(getByText('workspaces_example')).toBeInTheDocument(),
);
// Verify that selecting an option calls onChange
await userEvent.click(getByText('workspaces_example'));
expect(onChange).toHaveBeenCalledWith({
workspace: 'workspaces_example',
});
});
it('should populate projects if host and workspace are set and accessToken is provided', async () => {
const onChange = jest.fn();
const { getAllByRole, getByText } = render(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'bitbucket.org', workspace: 'workspace1' }}
accessToken="foo"
/>
</TestApiProvider>,
);
// Open the Autcomplete dropdown
const projectInput = getAllByRole('textbox')[1];
await userEvent.click(projectInput);
// Verify that the available projects are shown
await waitFor(() =>
expect(getByText('projects_example')).toBeInTheDocument(),
);
// Verify that selecting an option calls onChange
await userEvent.click(getByText('projects_example'));
expect(onChange).toHaveBeenCalledWith({ project: 'projects_example' });
});
it('should populate repositories if host, workspace and project are set and accessToken is provided', async () => {
const onChange = jest.fn();
render(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<BitbucketRepoPicker
onChange={onChange}
rawErrors={[]}
state={{
host: 'bitbucket.org',
workspace: 'workspace1',
project: 'project1',
}}
accessToken="foo"
/>
</TestApiProvider>,
);
// Verify that the available repos are updated
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
availableRepos: ['repositories_example'],
}),
);
});
});
});
@@ -13,13 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Input from '@material-ui/core/Input';
import InputLabel from '@material-ui/core/InputLabel';
import { Select, SelectItem } from '@backstage/core-components';
import { RepoUrlPickerState } from './types';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import useDebounce from 'react-use/esm/useDebounce';
import { useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
/**
* The underlying component that is rendered in the form for the `BitbucketRepoPicker`
@@ -36,6 +39,7 @@ export const BitbucketRepoPicker = (props: {
onChange: (state: RepoUrlPickerState) => void;
state: RepoUrlPickerState;
rawErrors: string[];
accessToken?: string;
}) => {
const {
allowedOwners = [],
@@ -43,6 +47,7 @@ export const BitbucketRepoPicker = (props: {
onChange,
rawErrors,
state,
accessToken,
} = props;
const { host, workspace, project } = state;
const ownerItems: SelectItem[] = allowedOwners
@@ -58,6 +63,100 @@ export const BitbucketRepoPicker = (props: {
}
}, [allowedOwners, host, onChange]);
const scaffolderApi = useApi(scaffolderApiRef);
const [availableWorkspaces, setAvailableWorkspaces] = useState<string[]>([]);
const [availableProjects, setAvailableProjects] = useState<string[]>([]);
// Update available workspaces when client is available
useDebounce(
() => {
const updateAvailableWorkspaces = async () => {
if (
host === 'bitbucket.org' &&
accessToken &&
scaffolderApi.autocomplete
) {
const { results } = await scaffolderApi.autocomplete({
token: accessToken,
resource: 'workspaces',
context: {},
provider: 'bitbucket-cloud',
});
setAvailableWorkspaces(results.map(r => r.title));
} else {
setAvailableWorkspaces([]);
}
};
updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([]));
},
500,
[host, accessToken],
);
// Update available projects when client is available and workspace changes
useDebounce(
() => {
const updateAvailableProjects = async () => {
if (
host === 'bitbucket.org' &&
accessToken &&
workspace &&
scaffolderApi.autocomplete
) {
const { results } = await scaffolderApi.autocomplete({
token: accessToken,
resource: 'projects',
context: { workspace },
provider: 'bitbucket-cloud',
});
setAvailableProjects(results.map(r => r.title));
} else {
setAvailableProjects([]);
}
};
updateAvailableProjects().catch(() => setAvailableProjects([]));
},
500,
[host, accessToken, workspace],
);
// Update available repositories when client is available and workspace or project changes
useDebounce(
() => {
const updateAvailableRepositories = async () => {
if (
host === 'bitbucket.org' &&
accessToken &&
workspace &&
project &&
scaffolderApi.autocomplete
) {
const { results } = await scaffolderApi.autocomplete({
token: accessToken,
resource: 'repositories',
context: { workspace, project },
provider: 'bitbucket-cloud',
});
onChange({ availableRepos: results.map(r => r.title) });
} else {
onChange({ availableRepos: [] });
}
};
updateAvailableRepositories().catch(() =>
onChange({ availableRepos: [] }),
);
},
500,
[host, accessToken, workspace, project],
);
return (
<>
{host === 'bitbucket.org' && (
@@ -78,14 +177,18 @@ export const BitbucketRepoPicker = (props: {
items={ownerItems}
/>
) : (
<>
<InputLabel htmlFor="workspaceInput">Workspace</InputLabel>
<Input
id="workspaceInput"
onChange={e => onChange({ workspace: e.target.value })}
value={workspace}
/>
</>
<Autocomplete
value={workspace}
onChange={(_, newValue) => {
onChange({ workspace: newValue || '' });
}}
options={availableWorkspaces}
renderInput={params => (
<TextField {...params} label="Workspace" required />
)}
freeSolo
autoSelect
/>
)}
<FormHelperText>
The Workspace that this repo will belong to
@@ -109,14 +212,18 @@ export const BitbucketRepoPicker = (props: {
items={projectItems}
/>
) : (
<>
<InputLabel htmlFor="projectInput">Project</InputLabel>
<Input
id="projectInput"
onChange={e => onChange({ project: e.target.value })}
value={project}
/>
</>
<Autocomplete
value={project}
onChange={(_, newValue) => {
onChange({ project: newValue || '' });
}}
options={availableProjects}
renderInput={params => (
<TextField {...params} label="Project" required />
)}
freeSolo
autoSelect
/>
)}
<FormHelperText>
The Project that this repo will belong to
@@ -98,8 +98,15 @@ describe('RepoUrlPicker', () => {
const [ownerInput, repoInput] = getAllByRole('textbox');
const submitButton = getByRole('button');
fireEvent.change(ownerInput, { target: { value: 'backstage' } });
fireEvent.change(repoInput, { target: { value: 'repo123' } });
act(() => {
ownerInput.focus();
fireEvent.change(ownerInput, { target: { value: 'backstage' } });
ownerInput.blur();
repoInput.focus();
fireEvent.change(repoInput, { target: { value: 'repo123' } });
repoInput.blur();
});
fireEvent.click(submitButton);
@@ -203,6 +203,10 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => {
rawErrors={rawErrors}
state={state}
onChange={updateLocalState}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
/>
)}
{hostType === 'azure' && (
@@ -228,6 +232,7 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => {
setState(prevState => ({ ...prevState, repoName: repo }))
}
rawErrors={rawErrors}
availableRepos={state.availableRepos}
/>
</>
);
@@ -16,6 +16,8 @@
import React from 'react';
import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName';
import { render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react-dom/test-utils';
describe('RepoUrlPickerRepoName', () => {
it('should call onChange with the first allowed repo if there is none set already', async () => {
@@ -69,8 +71,39 @@ describe('RepoUrlPickerRepoName', () => {
expect(textArea).toBeVisible();
fireEvent.change(textArea, { target: { value: 'foo' } });
act(() => {
textArea.focus();
fireEvent.change(textArea, { target: { value: 'foo' } });
textArea.blur();
});
expect(onChange).toHaveBeenCalledWith('foo');
});
it('should autocomplete with provided availableRepos', async () => {
const availableRepos = ['foo', 'bar'];
const onChange = jest.fn();
const { getByRole, getByText } = render(
<RepoUrlPickerRepoName
onChange={onChange}
availableRepos={availableRepos}
rawErrors={[]}
/>,
);
// Open the Autocomplete dropdown
const input = getByRole('textbox');
await userEvent.click(input);
// Verify that available repos are shown
for (const repo of availableRepos) {
expect(getByText(repo)).toBeInTheDocument();
}
// Verify that selecting an option calls onChange
await userEvent.click(getByText(availableRepos[0]));
expect(onChange).toHaveBeenCalledWith(availableRepos[0]);
});
});
@@ -17,16 +17,17 @@ import React, { useEffect } from 'react';
import { Select, SelectItem } from '@backstage/core-components';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Input from '@material-ui/core/Input';
import InputLabel from '@material-ui/core/InputLabel';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
export const RepoUrlPickerRepoName = (props: {
repoName?: string;
allowedRepos?: string[];
onChange: (host: string) => void;
rawErrors: string[];
availableRepos?: string[];
}) => {
const { repoName, allowedRepos, onChange, rawErrors } = props;
const { repoName, allowedRepos, onChange, rawErrors, availableRepos } = props;
useEffect(() => {
// If there is no repoName chosen currently
@@ -61,14 +62,18 @@ export const RepoUrlPickerRepoName = (props: {
items={repoItems}
/>
) : (
<>
<InputLabel htmlFor="repoNameInput">Repository</InputLabel>
<Input
id="repoNameInput"
onChange={e => onChange(String(e.target.value))}
value={repoName}
/>
</>
<Autocomplete
value={repoName}
onChange={(_, newValue) => {
onChange(newValue || '');
}}
options={availableRepos || []}
renderInput={params => (
<TextField {...params} label="Repository" required />
)}
freeSolo
autoSelect
/>
)}
<FormHelperText>The name of the repository</FormHelperText>
</FormControl>
@@ -20,4 +20,5 @@ export interface RepoUrlPickerState {
organization?: string;
workspace?: string;
project?: string;
availableRepos?: string[];
}
@@ -51,6 +51,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
+3
View File
@@ -6684,6 +6684,7 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-bitbucket-cloud-common": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/plugin-scaffolder-node-test-utils": "workspace:^"
fs-extra: ^11.2.0
@@ -6937,6 +6938,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-scaffolder-backend@workspace:plugins/scaffolder-backend"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
@@ -6948,6 +6950,7 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-bitbucket-cloud-common": "workspace:^"
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"