diff --git a/.changeset/friendly-masks-type.md b/.changeset/friendly-masks-type.md new file mode 100644 index 0000000000..6f38ddefa3 --- /dev/null +++ b/.changeset/friendly-masks-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': minor +--- + +Add support for `token` for `bitbucketCloud` integration diff --git a/.changeset/light-avocados-worry.md b/.changeset/light-avocados-worry.md new file mode 100644 index 0000000000..a3605a1451 --- /dev/null +++ b/.changeset/light-avocados-worry.md @@ -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` diff --git a/.changeset/selfish-turtles-jog.md b/.changeset/selfish-turtles-jog.md new file mode 100644 index 0000000000..02506ec068 --- /dev/null +++ b/.changeset/selfish-turtles-jog.md @@ -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 diff --git a/.changeset/young-donuts-swim.md b/.changeset/young-donuts-swim.md new file mode 100644 index 0000000000..edd854f6dc --- /dev/null +++ b/.changeset/young-donuts-swim.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +Add support for `bitbucketCloud` autocomplete in `RepoUrlPicker` diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index f9a8426cf5..4c8e8accf2 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -186,6 +186,7 @@ export type BitbucketCloudIntegrationConfig = { apiBaseUrl: string; username?: string; appPassword?: string; + token?: string; }; // @public @deprecated diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index 5ecabfedc4..32a2af9313 100644 --- a/packages/integration/src/bitbucketCloud/config.ts +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -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; }; /** diff --git a/plugins/bitbucket-cloud-common/api-report.md b/plugins/bitbucket-cloud-common/api-report.md index 0f4e82d5d1..34c245facc 100644 --- a/plugins/bitbucket-cloud-common/api-report.md +++ b/plugins/bitbucket-cloud-common/api-report.md @@ -12,11 +12,20 @@ export class BitbucketCloudClient { config: BitbucketCloudIntegrationConfig, ): BitbucketCloudClient; // (undocumented) + listProjectsByWorkspace( + workspace: string, + options?: FilterAndSortOptions & PartialResponseOptions, + ): WithPagination; + // (undocumented) listRepositoriesByWorkspace( workspace: string, options?: FilterAndSortOptions & PartialResponseOptions, ): WithPagination; // (undocumented) + listWorkspaces( + options?: FilterAndSortOptions & PartialResponseOptions, + ): WithPagination; + // (undocumented) searchCode( workspace: string, query: string, @@ -200,9 +209,15 @@ export namespace Models { size?: number; values?: Array | Set; } + export interface PaginatedProjects extends Paginated { + values?: Set; + } export interface PaginatedRepositories extends Paginated { values?: Set; } + export interface PaginatedWorkspaces extends Paginated { + values?: Set; + } export interface Participant extends ModelObject { // (undocumented) approved?: boolean; diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts index c5c0d604be..f625f0daf8 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts @@ -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'); + }); }); diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index 3ac005fe2b..f1b7d60e41 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -69,6 +69,32 @@ export class BitbucketCloudClient { ); } + listProjectsByWorkspace( + workspace: string, + options?: FilterAndSortOptions & PartialResponseOptions, + ): WithPagination { + const workspaceEnc = encodeURIComponent(workspace); + + return new WithPagination( + paginationOptions => + this.createUrl(`/workspaces/${workspaceEnc}/projects`, { + ...paginationOptions, + ...options, + }), + url => this.getTypeMapped(url), + ); + } + + listWorkspaces( + options?: FilterAndSortOptions & PartialResponseOptions, + ): WithPagination { + 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; diff --git a/plugins/bitbucket-cloud-common/src/models/index.ts b/plugins/bitbucket-cloud-common/src/models/index.ts index 8fce389a32..2cdc143d06 100644 --- a/plugins/bitbucket-cloud-common/src/models/index.ts +++ b/plugins/bitbucket-cloud-common/src/models/index.ts @@ -253,6 +253,28 @@ export namespace Models { values?: Set; } + /** + * A paginated list of projects. + * @public + */ + export interface PaginatedProjects extends Paginated { + /** + * The values of the current page. + */ + values?: Set; + } + + /** + * A paginated list of workspaces. + * @public + */ + export interface PaginatedWorkspaces extends Paginated { + /** + * The values of the current page. + */ + values?: Set; + } + /** * Object describing a user's role on resources like commits or pull requests. * @public diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index 8e1fd1facd..e54f255094 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json @@ -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", diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts new file mode 100644 index 0000000000..0447ff2d28 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts @@ -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 = { + 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); + }); +}); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts new file mode 100644 index 0000000000..31a2103088 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts @@ -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; +}): 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}`); + } +} diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts index 81afbf8c88..5bf49f7cd9 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts @@ -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, + }); }, }); }, diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 4c7b11ea43..4d75ed8421 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -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; + // (undocumented) catalogClient: CatalogApi; concurrentTasksLimit?: number; // (undocumented) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 386a1d829f..9730ee78bb 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -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:^", diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index a8213cf585..c83c6d437d 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -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 = {}; + 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); }, diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 8ba26c68a4..f0e78c75c7 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -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', + }); + }); + }); }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 384b9f5340..9b3f5173c8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -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; } 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(); diff --git a/plugins/scaffolder-node/api-report-alpha.md b/plugins/scaffolder-node/api-report-alpha.md index c2a2a1597e..aae32d7203 100644 --- a/plugins/scaffolder-node/api-report-alpha.md +++ b/plugins/scaffolder-node/api-report-alpha.md @@ -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; +}) => Promise<{ + results: { + title: string; + }[]; +}>; + // @alpha export interface ScaffolderActionsExtensionPoint { // (undocumented) @@ -18,6 +33,21 @@ export interface ScaffolderActionsExtensionPoint { // @alpha export const scaffolderActionsExtensionPoint: ExtensionPoint; +// @alpha +export interface ScaffolderAutocompleteExtensionPoint { + // (undocumented) + addAutocompleteProvider({ + id, + handler, + }: { + id: string; + handler: AutocompleteHandler; + }): void; +} + +// @alpha +export const scaffolderAutocompleteExtensionPoint: ExtensionPoint; + // @alpha export interface ScaffolderTaskBrokerExtensionPoint { // (undocumented) diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha.ts index b85da5b590..ef4d5ab289 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha.ts @@ -79,3 +79,41 @@ export const scaffolderTemplatingExtensionPoint = createExtensionPoint({ id: 'scaffolder.templating', }); + +/** + * Autocomplete handler for the scaffolder. + * @alpha + */ +export type AutocompleteHandler = ({ + resource, + token, + context, +}: { + resource: string; + token: string; + context: Record; +}) => 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({ + id: 'scaffolder.autocomplete', + }); diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index 850c30ced8..a14490b80c 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -179,6 +179,17 @@ export type ReviewStepProps = { // @public export interface ScaffolderApi { + // (undocumented) + autocomplete?(options: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ + results: { + title: string; + }[]; + }>; cancelTask(taskId: string): Promise; // (undocumented) dryRun?(options: ScaffolderDryRunOptions): Promise; diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index b4a3deea54..eaef54ff1b 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -229,4 +229,11 @@ export interface ScaffolderApi { streamLogs(options: ScaffolderStreamLogsOptions): Observable; dryRun?(options: ScaffolderDryRunOptions): Promise; + + autocomplete?(options: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ results: { title: string }[] }>; } diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 27fde2083f..3a2aa1469f 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -36,6 +36,7 @@ const scaffolderApiMock: jest.Mocked = { streamLogs: jest.fn(), listActions: jest.fn(), listTasks: jest.fn(), + autocomplete: jest.fn(), }; const catalogApiMock: jest.Mocked = { getEntityByRef: jest.fn(), diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 5c766af890..f479b13ec4 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -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; + }): Promise<{ + results: { + title: string; + }[]; + }>; + // (undocumented) cancelTask(taskId: string): Promise; // (undocumented) dryRun( diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 6ef5fff588..1809ed4c15 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -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; + }): 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 }; + } } diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 614e3fcff0..b548bcbad4 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -33,6 +33,7 @@ const scaffolderApiMock: jest.Mocked = { streamLogs: jest.fn(), listActions: jest.fn(), listTasks: jest.fn(), + autocomplete: jest.fn(), }; const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index f799d68c2d..2ae89233ff 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -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 = { + 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( - , + + + , ); expect(await findByText('owner1')).toBeInTheDocument(); @@ -38,7 +53,13 @@ describe('BitbucketRepoPicker', () => { const state = { host: 'bitbucket.org', workspace: 'lolsWorkspace' }; const { getAllByRole } = render( - , + + + , ); expect(getAllByRole('textbox')).toHaveLength(2); @@ -51,7 +72,13 @@ describe('BitbucketRepoPicker', () => { }; const { getAllByRole } = render( - , + + + , ); 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( - , + + + , ); 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( - , + + + , ); 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( - , + + + , ); 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( - , + + + , ); 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( - , + + + , ); 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( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + // Verify that the available repos are updated + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ + availableRepos: ['repositories_example'], + }), + ); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index d070e3fee5..10d4a6e6c3 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -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([]); + const [availableProjects, setAvailableProjects] = useState([]); + + // 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} /> ) : ( - <> - Workspace - onChange({ workspace: e.target.value })} - value={workspace} - /> - + { + onChange({ workspace: newValue || '' }); + }} + options={availableWorkspaces} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> )} The Workspace that this repo will belong to @@ -109,14 +212,18 @@ export const BitbucketRepoPicker = (props: { items={projectItems} /> ) : ( - <> - Project - onChange({ project: e.target.value })} - value={project} - /> - + { + onChange({ project: newValue || '' }); + }} + options={availableProjects} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> )} The Project that this repo will belong to diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 59c09c106c..002652cfab 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -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); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 85245639b0..e0d714ffda 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -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} /> ); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx index 4c392d646c..95407084c6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx @@ -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( + , + ); + + // 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]); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx index f2d68b08f3..01878cb619 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx @@ -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} /> ) : ( - <> - Repository - onChange(String(e.target.value))} - value={repoName} - /> - + { + onChange(newValue || ''); + }} + options={availableRepos || []} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> )} The name of the repository diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts index 5e979e789e..1430b10b84 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts @@ -20,4 +20,5 @@ export interface RepoUrlPickerState { organization?: string; workspace?: string; project?: string; + availableRepos?: string[]; } diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx index 269ff81271..2639e8149c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -51,6 +51,7 @@ const scaffolderApiMock: jest.Mocked = { streamLogs: jest.fn(), listActions: jest.fn(), listTasks: jest.fn(), + autocomplete: jest.fn(), }; const catalogApiMock: jest.Mocked = { diff --git a/yarn.lock b/yarn.lock index d41120ce21..e3cec34102 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"