Merge pull request #25132 from benjidotsh/feat/scaffolder-bitbucket-autocomplete
feat(scaffolder): add autocompletion for Bitbucket
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Add support for `token` for `bitbucketCloud` integration
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': minor
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Add support for `bitbucketCloud` autocomplete in `RepoUrlPicker`
|
||||
@@ -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",
|
||||
|
||||
+111
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+171
-38
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+34
-1
@@ -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> = {
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user