From 39e5dfd79b85ee45fb4122fcc8125d96ccb52b5a Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 6 Jun 2024 09:54:41 +0200 Subject: [PATCH 01/33] feat: fetch workspaces and projects in BitbucketRepoPicker; add support for access tokens to BitbucketCloudClient Signed-off-by: Benjamin Janssens --- .../integration/src/bitbucketCloud/config.ts | 5 ++ .../src/BitbucketCloudClient.ts | 19 +++++++ .../src/models/index.ts | 11 ++++ plugins/scaffolder/package.json | 1 + .../RepoUrlPicker/BitbucketRepoPicker.tsx | 54 ++++++++++++++++++- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 4 ++ yarn.lock | 1 + 7 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index 5ecabfedc4..2f533480cc 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). + */ + accessToken?: string; }; /** diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index 3ac005fe2b..97d3d0e52e 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -69,6 +69,22 @@ 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), + ); + } + private createUrl(endpoint: string, options?: RequestOptions): URL { const request = new URL(this.config.apiBaseUrl + endpoint); for (const key in options) { @@ -115,6 +131,9 @@ export class BitbucketCloudClient { headers.Authorization = `Basic ${buffer.toString('base64')}`; } + if (this.config.accessToken) + headers.Authorization = `Bearer ${this.config.accessToken}`; + return headers; } } diff --git a/plugins/bitbucket-cloud-common/src/models/index.ts b/plugins/bitbucket-cloud-common/src/models/index.ts index 8fce389a32..ace8ee4939 100644 --- a/plugins/bitbucket-cloud-common/src/models/index.ts +++ b/plugins/bitbucket-cloud-common/src/models/index.ts @@ -253,6 +253,17 @@ export namespace Models { values?: Set; } + /** + * A paginated list of projects. + * @public + */ + export interface PaginatedProjects 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/package.json b/plugins/scaffolder/package.json index d1b58c8eca..a17fc14105 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -67,6 +67,7 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", + "@backstage/plugin-bitbucket-cloud-common": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-permission-react": "workspace:^", diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index d070e3fee5..5227f8a093 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -13,13 +13,14 @@ * 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 { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; /** * The underlying component that is rendered in the form for the `BitbucketRepoPicker` @@ -36,6 +37,7 @@ export const BitbucketRepoPicker = (props: { onChange: (state: RepoUrlPickerState) => void; state: RepoUrlPickerState; rawErrors: string[]; + accessToken?: string; }) => { const { allowedOwners = [], @@ -43,6 +45,7 @@ export const BitbucketRepoPicker = (props: { onChange, rawErrors, state, + accessToken, } = props; const { host, workspace, project } = state; const ownerItems: SelectItem[] = allowedOwners @@ -58,6 +61,53 @@ export const BitbucketRepoPicker = (props: { } }, [allowedOwners, host, onChange]); + const [client, setClient] = useState(); + const [availableProjects, setAvailableProjects] = useState([]); + const [availableRepositories, setAvailableRepositories] = useState( + [], + ); + + useEffect(() => { + if (accessToken) + setClient( + BitbucketCloudClient.fromConfig({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + accessToken, + }), + ); + }, [accessToken]); + + const onChangeWorkspace = async () => { + if (client) + if (!workspace) { + setAvailableProjects([]); + } else { + for await (const page of client + .listProjectsByWorkspace(workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + setAvailableProjects([...availableProjects, ...keys]); + } + } + }; + + const onChangeProject = async () => { + if (client && workspace) + if (!project) { + setAvailableRepositories([]); + } else { + for await (const page of client + .listRepositoriesByWorkspace(workspace, { + q: `project.key="${project}"`, + }) + .iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + setAvailableRepositories([...availableRepositories, ...keys]); + } + } + }; + return ( <> {host === 'bitbucket.org' && ( @@ -84,6 +134,7 @@ export const BitbucketRepoPicker = (props: { id="workspaceInput" onChange={e => onChange({ workspace: e.target.value })} value={workspace} + onBlur={() => onChangeWorkspace()} /> )} @@ -115,6 +166,7 @@ export const BitbucketRepoPicker = (props: { id="projectInput" onChange={e => onChange({ project: e.target.value })} value={project} + onBlur={() => onChangeProject()} /> )} diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 85245639b0..cd749a08a1 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' && ( diff --git a/yarn.lock b/yarn.lock index f0f0d02a5d..1719070c2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7268,6 +7268,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" + "@backstage/plugin-bitbucket-cloud-common": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" From fbbf152f50cc455eb15f179c94ca949bdf413ba4 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 6 Jun 2024 11:23:11 +0200 Subject: [PATCH 02/33] chore: add error handling Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 5227f8a093..75ae840407 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax */ /* * Copyright 2021 The Backstage Authors * @@ -21,6 +22,8 @@ import InputLabel from '@material-ui/core/InputLabel'; import { Select, SelectItem } from '@backstage/core-components'; import { RepoUrlPickerState } from './types'; import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; /** * The underlying component that is rendered in the form for the `BitbucketRepoPicker` @@ -83,11 +86,15 @@ export const BitbucketRepoPicker = (props: { if (!workspace) { setAvailableProjects([]); } else { - for await (const page of client - .listProjectsByWorkspace(workspace) - .iteratePages()) { - const keys = [...page.values!].map(p => p.key!); - setAvailableProjects([...availableProjects, ...keys]); + try { + for await (const page of client + .listProjectsByWorkspace(workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + setAvailableProjects([...availableProjects, ...keys]); + } + } catch { + setAvailableProjects([]); } } }; @@ -160,15 +167,17 @@ export const BitbucketRepoPicker = (props: { items={projectItems} /> ) : ( - <> - Project - onChange({ project: e.target.value })} - value={project} - onBlur={() => onChangeProject()} - /> - + { + onChange({ project: newValue || '' }); + }} + options={availableProjects} + renderInput={params => } + freeSolo + onBlur={() => onChangeProject()} + autoSelect + /> )} The Project that this repo will belong to From 0aaf020359d48d57074ca259e59d1c47ee96de17 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 7 Jun 2024 13:15:14 +0200 Subject: [PATCH 03/33] feat: add auto-completion to workspaces and repositories Signed-off-by: Benjamin Janssens --- .../src/BitbucketCloudClient.ts | 10 ++ .../src/models/index.ts | 11 ++ .../RepoUrlPicker/BitbucketRepoPicker.tsx | 133 +++++++++++------- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 1 + .../RepoUrlPicker/RepoUrlPickerRepoName.tsx | 26 ++-- .../components/fields/RepoUrlPicker/types.ts | 1 + 6 files changed, 124 insertions(+), 58 deletions(-) diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index 97d3d0e52e..a06f7425d0 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -85,6 +85,16 @@ export class BitbucketCloudClient { ); } + 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) { diff --git a/plugins/bitbucket-cloud-common/src/models/index.ts b/plugins/bitbucket-cloud-common/src/models/index.ts index ace8ee4939..2cdc143d06 100644 --- a/plugins/bitbucket-cloud-common/src/models/index.ts +++ b/plugins/bitbucket-cloud-common/src/models/index.ts @@ -264,6 +264,17 @@ export namespace Models { 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/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 75ae840407..a7c2efc75e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -17,8 +17,6 @@ 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 { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; @@ -65,10 +63,8 @@ export const BitbucketRepoPicker = (props: { }, [allowedOwners, host, onChange]); const [client, setClient] = useState(); + const [availableWorkspaces, setAvailableWorkspaces] = useState([]); const [availableProjects, setAvailableProjects] = useState([]); - const [availableRepositories, setAvailableRepositories] = useState( - [], - ); useEffect(() => { if (accessToken) @@ -81,39 +77,78 @@ export const BitbucketRepoPicker = (props: { ); }, [accessToken]); - const onChangeWorkspace = async () => { - if (client) - if (!workspace) { - setAvailableProjects([]); - } else { - try { - for await (const page of client - .listProjectsByWorkspace(workspace) - .iteratePages()) { - const keys = [...page.values!].map(p => p.key!); - setAvailableProjects([...availableProjects, ...keys]); - } - } catch { - setAvailableProjects([]); - } - } - }; + // Update available workspaces when host changes + useEffect(() => { + const updateAvailableWorkspaces = async () => { + if (client) + if (!host) { + setAvailableWorkspaces([]); + } else { + const result: string[] = []; - const onChangeProject = async () => { - if (client && workspace) - if (!project) { - setAvailableRepositories([]); - } else { - for await (const page of client - .listRepositoriesByWorkspace(workspace, { - q: `project.key="${project}"`, - }) - .iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - setAvailableRepositories([...availableRepositories, ...keys]); + for await (const page of client.listWorkspaces().iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + result.push(...keys); + } + + setAvailableWorkspaces(result); } - } - }; + }; + + updateAvailableWorkspaces(); + }, [client, host]); + + // Update available repositories when workspace or project changes + useEffect(() => { + const updateAvailableRepositories = async () => { + if (client && workspace) + if (!project) { + onChange({ availableRepos: [] }); + } else { + const availableRepos: string[] = []; + + for await (const page of client + .listRepositoriesByWorkspace(workspace, { + q: `project.key="${project}"`, + }) + .iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + availableRepos.push(...keys); + } + + onChange({ availableRepos }); + } + }; + + updateAvailableRepositories(); + }, [client, workspace, project, onChange]); + + // Update available projects when workspace changes + useEffect(() => { + const updateAvailableProjects = async () => { + if (client) + if (!workspace) { + setAvailableProjects([]); + } else { + try { + const result: string[] = []; + + for await (const page of client + .listProjectsByWorkspace(workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + result.push(...keys); + } + + setAvailableProjects(result); + } catch { + setAvailableProjects([]); + } + } + }; + + updateAvailableProjects(); + }, [client, workspace]); return ( <> @@ -135,15 +170,18 @@ export const BitbucketRepoPicker = (props: { items={ownerItems} /> ) : ( - <> - Workspace - onChange({ workspace: e.target.value })} - value={workspace} - onBlur={() => onChangeWorkspace()} - /> - + { + onChange({ workspace: newValue || '' }); + }} + options={availableWorkspaces} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> )} The Workspace that this repo will belong to @@ -173,9 +211,10 @@ export const BitbucketRepoPicker = (props: { onChange({ project: newValue || '' }); }} options={availableProjects} - renderInput={params => } + renderInput={params => ( + + )} freeSolo - onBlur={() => onChangeProject()} autoSelect /> )} diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index cd749a08a1..e0d714ffda 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -232,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.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx index f2d68b08f3..80851b0cbb 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,17 @@ export const RepoUrlPickerRepoName = (props: { items={repoItems} /> ) : ( - <> - Repository - onChange(String(e.target.value))} - value={repoName} - /> - + { + onChange(newValue || ''); + }} + options={availableRepos || []} + renderInput={params => ( + + )} + freeSolo + /> )} 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[]; } From c9af3ea5e2d8d377c2ea68f9a1de9b283923a10d Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 7 Jun 2024 13:44:37 +0200 Subject: [PATCH 04/33] test: add tests for BitbucketCloudClient Signed-off-by: Benjamin Janssens --- .../src/BitbucketCloudClient.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts index c5c0d604be..92e3261410 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: 'repository', + 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'); + }); }); From 0c5a38e854f37125c33cf8554929ba2062b7fe37 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 7 Jun 2024 15:48:34 +0200 Subject: [PATCH 05/33] fix: use onInputChange and useDebounce Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 111 ++++++++++-------- .../RepoUrlPicker/RepoUrlPickerRepoName.tsx | 2 +- 2 files changed, 61 insertions(+), 52 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index a7c2efc75e..abbbe7a8e4 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -22,6 +22,7 @@ import { RepoUrlPickerState } from './types'; import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; import Autocomplete from '@material-ui/lab/Autocomplete'; import TextField from '@material-ui/core/TextField'; +import useDebounce from 'react-use/esm/useDebounce'; /** * The underlying component that is rendered in the form for the `BitbucketRepoPicker` @@ -78,59 +79,67 @@ export const BitbucketRepoPicker = (props: { }, [accessToken]); // Update available workspaces when host changes - useEffect(() => { - const updateAvailableWorkspaces = async () => { - if (client) - if (!host) { - setAvailableWorkspaces([]); - } else { - const result: string[] = []; + useDebounce( + () => { + const updateAvailableWorkspaces = async () => { + if (client) + if (!host) { + setAvailableWorkspaces([]); + } else { + const result: string[] = []; - for await (const page of client.listWorkspaces().iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - result.push(...keys); + for await (const page of client.listWorkspaces().iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + result.push(...keys); + } + + setAvailableWorkspaces(result); } + }; - setAvailableWorkspaces(result); - } - }; - - updateAvailableWorkspaces(); - }, [client, host]); + updateAvailableWorkspaces(); + }, + 500, + [client, host], + ); // Update available repositories when workspace or project changes - useEffect(() => { - const updateAvailableRepositories = async () => { - if (client && workspace) - if (!project) { - onChange({ availableRepos: [] }); - } else { - const availableRepos: string[] = []; + useDebounce( + () => { + const updateAvailableRepositories = async () => { + if (client && workspace) + if (!project) { + onChange({ availableRepos: [] }); + } else { + const availableRepos: string[] = []; - for await (const page of client - .listRepositoriesByWorkspace(workspace, { - q: `project.key="${project}"`, - }) - .iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - availableRepos.push(...keys); + for await (const page of client + .listRepositoriesByWorkspace(workspace, { + q: `project.key="${project}"`, + }) + .iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + availableRepos.push(...keys); + } + + onChange({ availableRepos }); } + }; - onChange({ availableRepos }); - } - }; - - updateAvailableRepositories(); - }, [client, workspace, project, onChange]); + updateAvailableRepositories(); + }, + 500, + [client, workspace, project, onChange], + ); // Update available projects when workspace changes - useEffect(() => { - const updateAvailableProjects = async () => { - if (client) - if (!workspace) { - setAvailableProjects([]); - } else { - try { + useDebounce( + () => { + const updateAvailableProjects = async () => { + if (client) + if (!workspace) { + setAvailableProjects([]); + } else { const result: string[] = []; for await (const page of client @@ -141,14 +150,14 @@ export const BitbucketRepoPicker = (props: { } setAvailableProjects(result); - } catch { - setAvailableProjects([]); } - } - }; + }; - updateAvailableProjects(); - }, [client, workspace]); + updateAvailableProjects(); + }, + 500, + [client, workspace], + ); return ( <> @@ -172,7 +181,7 @@ export const BitbucketRepoPicker = (props: { ) : ( { + onInputChange={(_, newValue) => { onChange({ workspace: newValue || '' }); }} options={availableWorkspaces} @@ -207,7 +216,7 @@ export const BitbucketRepoPicker = (props: { ) : ( { + onInputChange={(_, newValue) => { onChange({ project: newValue || '' }); }} options={availableProjects} diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx index 80851b0cbb..0dcff7e6fc 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx @@ -64,7 +64,7 @@ export const RepoUrlPickerRepoName = (props: { ) : ( { + onInputChange={(_, newValue) => { onChange(newValue || ''); }} options={availableRepos || []} From be26d90de13b2aaca2e52417247dab18ffd494d0 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 7 Jun 2024 15:52:45 +0200 Subject: [PATCH 06/33] fix: improve error handling Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index abbbe7a8e4..3bcb94d2f6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -97,7 +97,11 @@ export const BitbucketRepoPicker = (props: { } }; - updateAvailableWorkspaces(); + try { + updateAvailableWorkspaces(); + } catch { + setAvailableWorkspaces([]); + } }, 500, [client, host], @@ -126,7 +130,11 @@ export const BitbucketRepoPicker = (props: { } }; - updateAvailableRepositories(); + try { + updateAvailableRepositories(); + } catch { + onChange({ availableRepos: [] }); + } }, 500, [client, workspace, project, onChange], @@ -153,7 +161,11 @@ export const BitbucketRepoPicker = (props: { } }; - updateAvailableProjects(); + try { + updateAvailableProjects(); + } catch { + setAvailableProjects([]); + } }, 500, [client, workspace], From f0781a53940ba3942a8fe81af0d6db0e4b699c57 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 7 Jun 2024 16:46:36 +0200 Subject: [PATCH 07/33] fix: make error handling work; add tests for RepoUrlPickerRepoName Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 20 ++++--------- .../RepoUrlPickerRepoName.test.tsx | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 3bcb94d2f6..a56e1ad9ad 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -97,11 +97,7 @@ export const BitbucketRepoPicker = (props: { } }; - try { - updateAvailableWorkspaces(); - } catch { - setAvailableWorkspaces([]); - } + updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([])); }, 500, [client, host], @@ -130,11 +126,9 @@ export const BitbucketRepoPicker = (props: { } }; - try { - updateAvailableRepositories(); - } catch { - onChange({ availableRepos: [] }); - } + updateAvailableRepositories().catch(() => + onChange({ availableRepos: [] }), + ); }, 500, [client, workspace, project, onChange], @@ -161,11 +155,7 @@ export const BitbucketRepoPicker = (props: { } }; - try { - updateAvailableProjects(); - } catch { - setAvailableProjects([]); - } + updateAvailableProjects().catch(() => setAvailableProjects([])); }, 500, [client, workspace], diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx index 4c392d646c..8228f443df 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; import { render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; describe('RepoUrlPickerRepoName', () => { it('should call onChange with the first allowed repo if there is none set already', async () => { @@ -73,4 +74,31 @@ describe('RepoUrlPickerRepoName', () => { 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('foo')); + expect(onChange).toHaveBeenCalledWith('foo'); + }); }); From f78b919bf093fd3f1d462f61c587a018b88878bd Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 10 Jun 2024 13:10:44 +0200 Subject: [PATCH 08/33] test: add tests for BitbucketRepoPicker Signed-off-by: Benjamin Janssens --- .../src/BitbucketCloudClient.test.ts | 2 +- .../BitbucketRepoPicker.test.tsx | 133 +++++++++++++++++- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 54 +++---- 3 files changed, 160 insertions(+), 29 deletions(-) diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts index 92e3261410..f625f0daf8 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.test.ts @@ -116,7 +116,7 @@ describe('BitbucketCloudClient', () => { const response = { values: [ { - type: 'repository', + type: 'project', slug: 'project1', } as Models.Project, ], diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index f799d68c2d..9dae5b24a6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -16,7 +16,13 @@ import React from 'react'; import { BitbucketRepoPicker } from './BitbucketRepoPicker'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { Models } from '@backstage/plugin-bitbucket-cloud-common'; +import userEvent from '@testing-library/user-event'; + +const server = setupServer(); describe('BitbucketRepoPicker', () => { it('renders a select if there is a list of allowed owners', async () => { @@ -136,4 +142,129 @@ describe('BitbucketRepoPicker', () => { expect(await findByText('project2')).toBeInTheDocument(); }); }); + + describe('autocompletion', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + beforeEach(() => { + // BitbucketCloudClient.listWorkspaces() + 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)); + }), + ); + + // BitbucketCloudClient.listProjectsByWorkspace() + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/workspaces/workspace1/projects', + (_, res, ctx) => { + const response = { + values: [ + { + type: 'project', + key: 'project1', + } as Models.Project, + ], + }; + return res(ctx.json(response)); + }, + ), + ); + + // BitbucketCloudClient.listRepositoriesByWorkspace() + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace1', + (_, res, ctx) => { + const response = { + values: [ + { + type: 'repository', + slug: 'repo1', + } as Models.Repository, + ], + }; + return res(ctx.json(response)); + }, + ), + ); + }); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + 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('workspace1')).toBeInTheDocument()); + + // Verify that selecting an option calls onChange + await userEvent.click(getByText('workspace1')); + expect(onChange).toHaveBeenCalledWith({ workspace: 'workspace1' }); + }); + + 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('project1')).toBeInTheDocument()); + + // Verify that selecting an option calls onChange + await userEvent.click(getByText('project1')); + expect(onChange).toHaveBeenCalledWith({ project: 'project1' }); + }); + + 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: ['repo1'] }), + ); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index a56e1ad9ad..67e82abc3f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -103,6 +103,33 @@ export const BitbucketRepoPicker = (props: { [client, host], ); + // Update available projects when workspace changes + useDebounce( + () => { + const updateAvailableProjects = async () => { + if (client) + if (!workspace) { + setAvailableProjects([]); + } else { + const result: string[] = []; + + for await (const page of client + .listProjectsByWorkspace(workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + result.push(...keys); + } + + setAvailableProjects(result); + } + }; + + updateAvailableProjects().catch(() => setAvailableProjects([])); + }, + 500, + [client, workspace], + ); + // Update available repositories when workspace or project changes useDebounce( () => { @@ -134,33 +161,6 @@ export const BitbucketRepoPicker = (props: { [client, workspace, project, onChange], ); - // Update available projects when workspace changes - useDebounce( - () => { - const updateAvailableProjects = async () => { - if (client) - if (!workspace) { - setAvailableProjects([]); - } else { - const result: string[] = []; - - for await (const page of client - .listProjectsByWorkspace(workspace) - .iteratePages()) { - const keys = [...page.values!].map(p => p.key!); - result.push(...keys); - } - - setAvailableProjects(result); - } - }; - - updateAvailableProjects().catch(() => setAvailableProjects([])); - }, - 500, - [client, workspace], - ); - return ( <> {host === 'bitbucket.org' && ( From da3db7945a699f77ff64ff27ffbc5e03dcceb877 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 10 Jun 2024 13:47:00 +0200 Subject: [PATCH 09/33] chore: clean up some code Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 80 +++++++++---------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 67e82abc3f..5361b99b01 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-syntax */ /* * Copyright 2021 The Backstage Authors * @@ -82,19 +81,18 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableWorkspaces = async () => { - if (client) - if (!host) { - setAvailableWorkspaces([]); - } else { - const result: string[] = []; + if (client && host) { + const result: string[] = []; - for await (const page of client.listWorkspaces().iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - result.push(...keys); - } - - setAvailableWorkspaces(result); + for await (const page of client.listWorkspaces().iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + result.push(...keys); } + + setAvailableWorkspaces(result); + } else { + setAvailableWorkspaces([]); + } }; updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([])); @@ -107,21 +105,20 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableProjects = async () => { - if (client) - if (!workspace) { - setAvailableProjects([]); - } else { - const result: string[] = []; + if (client && workspace) { + const result: string[] = []; - for await (const page of client - .listProjectsByWorkspace(workspace) - .iteratePages()) { - const keys = [...page.values!].map(p => p.key!); - result.push(...keys); - } - - setAvailableProjects(result); + for await (const page of client + .listProjectsByWorkspace(workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + result.push(...keys); } + + setAvailableProjects(result); + } else { + setAvailableProjects([]); + } }; updateAvailableProjects().catch(() => setAvailableProjects([])); @@ -134,23 +131,22 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableRepositories = async () => { - if (client && workspace) - if (!project) { - onChange({ availableRepos: [] }); - } else { - const availableRepos: string[] = []; + if (client && workspace && project) { + const availableRepos: string[] = []; - for await (const page of client - .listRepositoriesByWorkspace(workspace, { - q: `project.key="${project}"`, - }) - .iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - availableRepos.push(...keys); - } - - onChange({ availableRepos }); + for await (const page of client + .listRepositoriesByWorkspace(workspace, { + q: `project.key="${project}"`, + }) + .iteratePages()) { + const keys = [...page.values!].map(p => p.slug!); + availableRepos.push(...keys); } + + onChange({ availableRepos }); + } else { + onChange({ availableRepos: [] }); + } }; updateAvailableRepositories().catch(() => @@ -184,7 +180,7 @@ export const BitbucketRepoPicker = (props: { { - onChange({ workspace: newValue || '' }); + onChange({ workspace: String(newValue) }); }} options={availableWorkspaces} renderInput={params => ( @@ -219,7 +215,7 @@ export const BitbucketRepoPicker = (props: { { - onChange({ project: newValue || '' }); + onChange({ project: String(newValue) }); }} options={availableProjects} renderInput={params => ( From 7bc44a99e6df85e22017e4bdb723beb1d9e30901 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 10 Jun 2024 14:25:51 +0200 Subject: [PATCH 10/33] chore: make client dependent on host Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 5361b99b01..72c39fb11c 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -67,21 +67,24 @@ export const BitbucketRepoPicker = (props: { const [availableProjects, setAvailableProjects] = useState([]); useEffect(() => { - if (accessToken) + if (host === 'bitbucket.org' && accessToken) { setClient( BitbucketCloudClient.fromConfig({ - host: 'bitbucket.org', + host, apiBaseUrl: 'https://api.bitbucket.org/2.0', accessToken, }), ); - }, [accessToken]); + } else { + setClient(undefined); + } + }, [host, accessToken]); - // Update available workspaces when host changes + // Update available workspaces when client is available useDebounce( () => { const updateAvailableWorkspaces = async () => { - if (client && host) { + if (client) { const result: string[] = []; for await (const page of client.listWorkspaces().iteratePages()) { @@ -98,10 +101,10 @@ export const BitbucketRepoPicker = (props: { updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([])); }, 500, - [client, host], + [client], ); - // Update available projects when workspace changes + // Update available projects when client is available and workspace changes useDebounce( () => { const updateAvailableProjects = async () => { @@ -127,7 +130,7 @@ export const BitbucketRepoPicker = (props: { [client, workspace], ); - // Update available repositories when workspace or project changes + // Update available repositories when client is available and workspace or project changes useDebounce( () => { const updateAvailableRepositories = async () => { From 2127b5ef04c87ea25b31dd9ee47a0723db3243b6 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 10 Jun 2024 14:44:40 +0200 Subject: [PATCH 11/33] chore: update API reports Signed-off-by: Benjamin Janssens --- packages/integration/api-report.md | 1 + plugins/bitbucket-cloud-common/api-report.md | 15 +++++++++++++++ .../RepoUrlPicker/RepoUrlPickerRepoName.tsx | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index f9a8426cf5..91abc7473f 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; + accessToken?: string; }; // @public @deprecated 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/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx index 0dcff7e6fc..cd346cf8ed 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx @@ -65,7 +65,7 @@ export const RepoUrlPickerRepoName = (props: { { - onChange(newValue || ''); + onChange(String(newValue)); }} options={availableRepos || []} renderInput={params => ( From 0757000fad2a3ab99efd5811d7a8b428f883b6cf Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 11 Jun 2024 12:09:58 +0200 Subject: [PATCH 12/33] chore: add changesets for plugin-bitbucket-cloud-common Signed-off-by: Benjamin Janssens --- .changeset/five-forks-retire.md | 5 +++++ .changeset/great-colts-enjoy.md | 5 +++++ .changeset/rare-planes-switch.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/five-forks-retire.md create mode 100644 .changeset/great-colts-enjoy.md create mode 100644 .changeset/rare-planes-switch.md diff --git a/.changeset/five-forks-retire.md b/.changeset/five-forks-retire.md new file mode 100644 index 0000000000..9b9573bfd4 --- /dev/null +++ b/.changeset/five-forks-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-bitbucket-cloud-common': patch +--- + +Added support for access tokens diff --git a/.changeset/great-colts-enjoy.md b/.changeset/great-colts-enjoy.md new file mode 100644 index 0000000000..6907f661a5 --- /dev/null +++ b/.changeset/great-colts-enjoy.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-bitbucket-cloud-common': patch +--- + +Added method `listProjectsByWorkspace` for retrieving projects by workspace diff --git a/.changeset/rare-planes-switch.md b/.changeset/rare-planes-switch.md new file mode 100644 index 0000000000..622c6d48d8 --- /dev/null +++ b/.changeset/rare-planes-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-bitbucket-cloud-common': patch +--- + +Added method `listWorkspaces` for retrieving workspaces From 5ecb6bc94258b91e2c75deea499881588dc039b1 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 11 Jun 2024 12:11:28 +0200 Subject: [PATCH 13/33] chore: add changesets for @backstage/integration Signed-off-by: Benjamin Janssens --- .changeset/nine-seals-sit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nine-seals-sit.md diff --git a/.changeset/nine-seals-sit.md b/.changeset/nine-seals-sit.md new file mode 100644 index 0000000000..5ec46e30b3 --- /dev/null +++ b/.changeset/nine-seals-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Added support for access tokens to Bitbucket Cloud From 973ff9b74b51d64a075dc4c5874a4bb7fa023669 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 11 Jun 2024 12:13:29 +0200 Subject: [PATCH 14/33] chore: add changesets for @backstage/plugin-scaffolder Signed-off-by: Benjamin Janssens --- .changeset/angry-ladybugs-retire.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/angry-ladybugs-retire.md diff --git a/.changeset/angry-ladybugs-retire.md b/.changeset/angry-ladybugs-retire.md new file mode 100644 index 0000000000..eb6e8a5932 --- /dev/null +++ b/.changeset/angry-ladybugs-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added autocompletion for Bitbucket Cloud From d79cd670cca8fe1f28a84a8d91828e8482581b8e Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 11 Jun 2024 12:25:43 +0200 Subject: [PATCH 15/33] chore: set changesets for @backstage/plugin-scaffolder and @backstage/integration to minor Signed-off-by: Benjamin Janssens --- .changeset/angry-ladybugs-retire.md | 2 +- .changeset/nine-seals-sit.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/angry-ladybugs-retire.md b/.changeset/angry-ladybugs-retire.md index eb6e8a5932..ca2b34676b 100644 --- a/.changeset/angry-ladybugs-retire.md +++ b/.changeset/angry-ladybugs-retire.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder': minor --- Added autocompletion for Bitbucket Cloud diff --git a/.changeset/nine-seals-sit.md b/.changeset/nine-seals-sit.md index 5ec46e30b3..0055a1be24 100644 --- a/.changeset/nine-seals-sit.md +++ b/.changeset/nine-seals-sit.md @@ -1,5 +1,5 @@ --- -'@backstage/integration': patch +'@backstage/integration': minor --- Added support for access tokens to Bitbucket Cloud From 21ddf605221eb3109cdf795bae1e2b1bfdaa11c1 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 11:51:57 +0200 Subject: [PATCH 16/33] feat: add endpoint for bitbucket autocomplete in scaffolder Signed-off-by: Benjamin Janssens --- plugins/scaffolder-backend/package.json | 1 + .../src/service/autocomplete.ts | 79 +++++++++++++++++++ .../scaffolder-backend/src/service/router.ts | 24 ++++++ yarn.lock | 1 + 4 files changed, 105 insertions(+) create mode 100644 plugins/scaffolder-backend/src/service/autocomplete.ts diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 4060529421..af6625c8b8 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:^", diff --git a/plugins/scaffolder-backend/src/service/autocomplete.ts b/plugins/scaffolder-backend/src/service/autocomplete.ts new file mode 100644 index 0000000000..034a201450 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/autocomplete.ts @@ -0,0 +1,79 @@ +/* + * 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 handleBitbucketCloudRequest( + token: string, + resource: string, + parameters: Record, +): Promise { + const client = BitbucketCloudClient.fromConfig({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + accessToken: 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 result; + } + case 'projects': { + if (!parameters.workspace) + throw new InputError('Missing workspace query parameter'); + + const result: string[] = []; + + for await (const page of client + .listProjectsByWorkspace(parameters.workspace) + .iteratePages()) { + const keys = [...page.values!].map(p => p.key!); + result.push(...keys); + } + + return result; + } + case 'repositories': { + if (!parameters.workspace || !parameters.project) + throw new InputError( + 'Missing workspace and/or project query parameter', + ); + + const result: string[] = []; + + for await (const page of client + .listRepositoriesByWorkspace(parameters.workspace, { + q: `project.key="${parameters.project}"`, + }) + .iteratePages()) { + const slugs = [...page.values!].map(p => p.slug!); + result.push(...slugs); + } + + return result; + } + default: + throw new InputError(`Invalid resource: ${resource}`); + } +} diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 384b9f5340..dbccd6ddb5 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 { handleBitbucketCloudRequest } from './autocomplete'; /** * @@ -771,6 +772,29 @@ export async function createRouter( base64Content: file.content.toString('base64'), })), }); + }) + .get('/v2/autocomplete/:provider/:resource', async (req, res) => { + const { token, ...query } = req.query; + const { provider, resource } = req.params; + + if (!token) throw new InputError('Missing token query parameter'); + + let result: string[]; + + switch (provider) { + case 'bitbucketCloud': { + result = await handleBitbucketCloudRequest( + token as string, + resource, + query as Record, + ); + break; + } + default: + throw new InputError(`Unsupported provider: ${provider}`); + } + + res.status(200).json(result); }); const app = express(); diff --git a/yarn.lock b/yarn.lock index 1719070c2f..7f44f1559c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7093,6 +7093,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:^" From 28c991d6e471eed1d73f679a4bb227b564b0d0ab Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 12:53:50 +0200 Subject: [PATCH 17/33] test: add tests for autocomplete endpoint Signed-off-by: Benjamin Janssens --- .../src/service/router.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 8ba26c68a4..0024a57bd9 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -46,6 +46,7 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { handleBitbucketCloudRequest } from './autocomplete'; const mockAccess = jest.fn(); @@ -62,6 +63,10 @@ jest.mock('fs-extra', () => ({ remove: jest.fn(), })); +jest.mock('./autocomplete', () => ({ + handleBitbucketCloudRequest: jest.fn(), +})); + function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( new ConfigReader({ @@ -1461,5 +1466,22 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(subscriber!.closed).toBe(true); }); }); + + describe('GET /v2/autocomplete/:provider/:resource', () => { + it('should handle requests for provider bitbucketCloud', async () => { + const bbToken = 'foo'; + const resource = 'bar'; + + await request(app) + .get(`/v2/autocomplete/bitbucketCloud/${resource}?token=${bbToken}`) + .send(); + + expect(jest.mocked(handleBitbucketCloudRequest)).toHaveBeenCalledWith( + bbToken, + resource, + {}, + ); + }); + }); }); }); From 8a03156d9165f3747eae3d9b8a19105c7b12606e Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 14:58:38 +0200 Subject: [PATCH 18/33] feat: use autocomplete endpoint in frontend Signed-off-by: Benjamin Janssens --- plugins/scaffolder-react/src/api/types.ts | 7 + .../components/Workflow/Workflow.test.tsx | 1 + plugins/scaffolder/package.json | 1 - plugins/scaffolder/src/api.ts | 24 ++ .../BitbucketRepoPicker.test.tsx | 240 +++++++++--------- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 73 ++---- .../TemplateWizardPage.test.tsx | 1 + yarn.lock | 1 - 8 files changed, 175 insertions(+), 173 deletions(-) diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index b4a3deea54..c79f98c780 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( + token: string, + provider: string, + resource: string, + params?: Record, + ): Promise; } 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/package.json b/plugins/scaffolder/package.json index a17fc14105..d1b58c8eca 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -67,7 +67,6 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", - "@backstage/plugin-bitbucket-cloud-common": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-permission-react": "workspace:^", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 6ef5fff588..ff5c89e91e 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -337,4 +337,28 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } + + async autocomplete( + token: string, + provider: string, + resource: string, + params?: Record, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const query = new URLSearchParams({ + ...params, + token, + }); + const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}?${query}`; + + const response = await this.fetchApi.fetch(url, { + method: 'GET', + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } } diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 9dae5b24a6..404fb6ddf6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -17,23 +17,33 @@ import React from 'react'; import { BitbucketRepoPicker } from './BitbucketRepoPicker'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; -import { Models } from '@backstage/plugin-bitbucket-cloud-common'; import userEvent from '@testing-library/user-event'; - -const server = setupServer(); +import { TestApiProvider } from '@backstage/test-utils'; +import { + ScaffolderApi, + scaffolderApiRef, +} from '@backstage/plugin-scaffolder-react'; describe('BitbucketRepoPicker', () => { + const scaffolderApiMock: Partial = { + autocomplete: jest + .fn() + .mockImplementation((_token, _provider, resource) => [ + `${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(); @@ -44,7 +54,13 @@ describe('BitbucketRepoPicker', () => { const state = { host: 'bitbucket.org', workspace: 'lolsWorkspace' }; const { getAllByRole } = render( - , + + + , ); expect(getAllByRole('textbox')).toHaveLength(2); @@ -57,7 +73,13 @@ describe('BitbucketRepoPicker', () => { }; const { getAllByRole } = render( - , + + + , ); expect(getAllByRole('textbox')).toHaveLength(1); @@ -67,11 +89,13 @@ describe('BitbucketRepoPicker', () => { it('calls onChange when the workspace changes', () => { const onChange = jest.fn(); const { getAllByRole } = render( - , + + + , ); const workspaceInput = getAllByRole('textbox')[0]; @@ -86,11 +110,13 @@ describe('BitbucketRepoPicker', () => { it('calls onChange when the project changes', () => { const onChange = jest.fn(); const { getAllByRole } = render( - , + + + , ); const projectInput = getAllByRole('textbox')[1]; @@ -102,11 +128,13 @@ describe('BitbucketRepoPicker', () => { it('Does not render a select if the list of allowed projects does not exist', async () => { const { getAllByRole } = render( - , + + + , ); expect(getAllByRole('textbox')).toHaveLength(2); @@ -115,12 +143,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); @@ -130,12 +160,14 @@ 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(); @@ -144,71 +176,17 @@ describe('BitbucketRepoPicker', () => { }); describe('autocompletion', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - beforeEach(() => { - // BitbucketCloudClient.listWorkspaces() - 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)); - }), - ); - - // BitbucketCloudClient.listProjectsByWorkspace() - server.use( - rest.get( - 'https://api.bitbucket.org/2.0/workspaces/workspace1/projects', - (_, res, ctx) => { - const response = { - values: [ - { - type: 'project', - key: 'project1', - } as Models.Project, - ], - }; - return res(ctx.json(response)); - }, - ), - ); - - // BitbucketCloudClient.listRepositoriesByWorkspace() - server.use( - rest.get( - 'https://api.bitbucket.org/2.0/repositories/workspace1', - (_, res, ctx) => { - const response = { - values: [ - { - type: 'repository', - slug: 'repo1', - } as Models.Repository, - ], - }; - return res(ctx.json(response)); - }, - ), - ); - }); - afterAll(() => server.close()); - afterEach(() => server.resetHandlers()); - 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 @@ -216,22 +194,28 @@ describe('BitbucketRepoPicker', () => { await userEvent.click(workspaceInput); // Verify that the available workspaces are shown - await waitFor(() => expect(getByText('workspace1')).toBeInTheDocument()); + await waitFor(() => + expect(getByText('workspaces_example')).toBeInTheDocument(), + ); // Verify that selecting an option calls onChange - await userEvent.click(getByText('workspace1')); - expect(onChange).toHaveBeenCalledWith({ workspace: 'workspace1' }); + 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 @@ -239,31 +223,37 @@ describe('BitbucketRepoPicker', () => { await userEvent.click(projectInput); // Verify that the available projects are shown - await waitFor(() => expect(getByText('project1')).toBeInTheDocument()); + await waitFor(() => + expect(getByText('projects_example')).toBeInTheDocument(), + ); // Verify that selecting an option calls onChange - await userEvent.click(getByText('project1')); - expect(onChange).toHaveBeenCalledWith({ project: 'project1' }); + 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: ['repo1'] }), + 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 72c39fb11c..995470fbcb 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -18,10 +18,11 @@ import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import { Select, SelectItem } from '@backstage/core-components'; import { RepoUrlPickerState } from './types'; -import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; 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` @@ -62,35 +63,21 @@ export const BitbucketRepoPicker = (props: { } }, [allowedOwners, host, onChange]); - const [client, setClient] = useState(); + const scaffolderApi = useApi(scaffolderApiRef); + const [availableWorkspaces, setAvailableWorkspaces] = useState([]); const [availableProjects, setAvailableProjects] = useState([]); - useEffect(() => { - if (host === 'bitbucket.org' && accessToken) { - setClient( - BitbucketCloudClient.fromConfig({ - host, - apiBaseUrl: 'https://api.bitbucket.org/2.0', - accessToken, - }), - ); - } else { - setClient(undefined); - } - }, [host, accessToken]); - // Update available workspaces when client is available useDebounce( () => { const updateAvailableWorkspaces = async () => { - if (client) { - const result: string[] = []; - - for await (const page of client.listWorkspaces().iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - result.push(...keys); - } + if (host === 'bitbucket.org' && accessToken) { + const result = await scaffolderApi.autocomplete( + accessToken, + 'bitbucketCloud', + 'workspaces', + ); setAvailableWorkspaces(result); } else { @@ -101,22 +88,20 @@ export const BitbucketRepoPicker = (props: { updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([])); }, 500, - [client], + [host, accessToken], ); // Update available projects when client is available and workspace changes useDebounce( () => { const updateAvailableProjects = async () => { - if (client && workspace) { - const result: string[] = []; - - for await (const page of client - .listProjectsByWorkspace(workspace) - .iteratePages()) { - const keys = [...page.values!].map(p => p.key!); - result.push(...keys); - } + if (host === 'bitbucket.org' && accessToken && workspace) { + const result = await scaffolderApi.autocomplete( + accessToken, + 'bitbucketCloud', + 'projects', + { workspace }, + ); setAvailableProjects(result); } else { @@ -127,24 +112,20 @@ export const BitbucketRepoPicker = (props: { updateAvailableProjects().catch(() => setAvailableProjects([])); }, 500, - [client, workspace], + [host, accessToken, workspace], ); // Update available repositories when client is available and workspace or project changes useDebounce( () => { const updateAvailableRepositories = async () => { - if (client && workspace && project) { - const availableRepos: string[] = []; - - for await (const page of client - .listRepositoriesByWorkspace(workspace, { - q: `project.key="${project}"`, - }) - .iteratePages()) { - const keys = [...page.values!].map(p => p.slug!); - availableRepos.push(...keys); - } + if (host === 'bitbucket.org' && accessToken && workspace && project) { + const availableRepos = await scaffolderApi.autocomplete( + accessToken, + 'bitbucketCloud', + 'repositories', + { workspace, project }, + ); onChange({ availableRepos }); } else { @@ -157,7 +138,7 @@ export const BitbucketRepoPicker = (props: { ); }, 500, - [client, workspace, project, onChange], + [host, accessToken, workspace, project], ); return ( 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 7f44f1559c..f81ce9dabd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7269,7 +7269,6 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" - "@backstage/plugin-bitbucket-cloud-common": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" From a1709aa4eeba6a175b128d0f84c872c952222ada Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 15:30:00 +0200 Subject: [PATCH 19/33] chore: add changesets for @backstage/plugin-scaffolder-backend and @backstage/plugin-scaffolder-react; update API reports; update tests of ActionsPage Signed-off-by: Benjamin Janssens --- .changeset/slimy-ties-relate.md | 5 +++++ .changeset/tame-geese-run.md | 5 +++++ plugins/scaffolder-react/api-report.md | 7 +++++++ plugins/scaffolder/api-report.md | 7 +++++++ .../src/components/ActionsPage/ActionsPage.test.tsx | 1 + 5 files changed, 25 insertions(+) create mode 100644 .changeset/slimy-ties-relate.md create mode 100644 .changeset/tame-geese-run.md diff --git a/.changeset/slimy-ties-relate.md b/.changeset/slimy-ties-relate.md new file mode 100644 index 0000000000..d4da9972f1 --- /dev/null +++ b/.changeset/slimy-ties-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added endpoint for autocompleting provider resources (currently only supports Bitbucket) diff --git a/.changeset/tame-geese-run.md b/.changeset/tame-geese-run.md new file mode 100644 index 0000000000..785ae6c7fc --- /dev/null +++ b/.changeset/tame-geese-run.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added method `autocomplete` to interface `ScaffolderApi` diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index 850c30ced8..cc6672a8a5 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -179,6 +179,13 @@ export type ReviewStepProps = { // @public export interface ScaffolderApi { + // (undocumented) + autocomplete( + token: string, + provider: string, + resource: string, + params?: Record, + ): Promise; cancelTask(taskId: string): Promise; // (undocumented) dryRun?(options: ScaffolderDryRunOptions): Promise; diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 5c766af890..affc69ed93 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -502,6 +502,13 @@ export class ScaffolderClient implements ScaffolderApi_2 { useLongPollingLogs?: boolean; }); // (undocumented) + autocomplete( + token: string, + provider: string, + resource: string, + params?: Record, + ): Promise; + // (undocumented) cancelTask(taskId: string): Promise; // (undocumented) dryRun( 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]); From 62a9dcf8df69aba686557de3a58a873a69c6bf5e Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 17:00:14 +0200 Subject: [PATCH 20/33] fix: use onChange instead of onInputChange to make autocomplete actually work Signed-off-by: Benjamin Janssens --- .../fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx | 5 +++++ .../fields/RepoUrlPicker/BitbucketRepoPicker.tsx | 8 ++++---- .../fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx | 3 +++ .../fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx | 5 +++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 404fb6ddf6..10c049b3a0 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -23,6 +23,7 @@ import { ScaffolderApi, scaffolderApiRef, } from '@backstage/plugin-scaffolder-react'; +import { act } from 'react-dom/test-utils'; describe('BitbucketRepoPicker', () => { const scaffolderApiMock: Partial = { @@ -100,7 +101,9 @@ describe('BitbucketRepoPicker', () => { const workspaceInput = getAllByRole('textbox')[0]; + act(() => workspaceInput.focus()); fireEvent.change(workspaceInput, { target: { value: 'test-workspace' } }); + act(() => workspaceInput.blur()); expect(onChange).toHaveBeenCalledWith({ workspace: 'test-workspace' }); }); @@ -121,7 +124,9 @@ describe('BitbucketRepoPicker', () => { const projectInput = getAllByRole('textbox')[1]; + act(() => projectInput.focus()); fireEvent.change(projectInput, { target: { value: 'test-project' } }); + act(() => projectInput.blur()); expect(onChange).toHaveBeenCalledWith({ project: 'test-project' }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 995470fbcb..51bd1f5c67 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -163,8 +163,8 @@ export const BitbucketRepoPicker = (props: { ) : ( { - onChange({ workspace: String(newValue) }); + onChange={(_, newValue) => { + onChange({ workspace: newValue || '' }); }} options={availableWorkspaces} renderInput={params => ( @@ -198,8 +198,8 @@ export const BitbucketRepoPicker = (props: { ) : ( { - onChange({ project: String(newValue) }); + onChange={(_, newValue) => { + onChange({ project: newValue || '' }); }} options={availableProjects} renderInput={params => ( diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx index 8228f443df..efc3c94e71 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx @@ -17,6 +17,7 @@ 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 () => { @@ -70,7 +71,9 @@ describe('RepoUrlPickerRepoName', () => { expect(textArea).toBeVisible(); + act(() => textArea.focus()); fireEvent.change(textArea, { target: { value: 'foo' } }); + act(() => textArea.blur()); expect(onChange).toHaveBeenCalledWith('foo'); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx index cd346cf8ed..01878cb619 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.tsx @@ -64,14 +64,15 @@ export const RepoUrlPickerRepoName = (props: { ) : ( { - onChange(String(newValue)); + onChange={(_, newValue) => { + onChange(newValue || ''); }} options={availableRepos || []} renderInput={params => ( )} freeSolo + autoSelect /> )} The name of the repository From 4d88b29995acf47b3065fa83bb89d9340f7bd806 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 13 Jun 2024 18:01:21 +0200 Subject: [PATCH 21/33] test: update tests to work with onChange Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.test.tsx | 18 ++++++++++++------ .../RepoUrlPicker/RepoUrlPicker.test.tsx | 13 ++++++++++--- .../RepoUrlPickerRepoName.test.tsx | 8 +++++--- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 10c049b3a0..eb8c1f3a2c 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -101,9 +101,13 @@ describe('BitbucketRepoPicker', () => { const workspaceInput = getAllByRole('textbox')[0]; - act(() => workspaceInput.focus()); - fireEvent.change(workspaceInput, { target: { value: 'test-workspace' } }); - act(() => workspaceInput.blur()); + act(() => { + workspaceInput.focus(); + fireEvent.change(workspaceInput, { + target: { value: 'test-workspace' }, + }); + workspaceInput.blur(); + }); expect(onChange).toHaveBeenCalledWith({ workspace: 'test-workspace' }); }); @@ -124,9 +128,11 @@ describe('BitbucketRepoPicker', () => { const projectInput = getAllByRole('textbox')[1]; - act(() => projectInput.focus()); - fireEvent.change(projectInput, { target: { value: 'test-project' } }); - act(() => projectInput.blur()); + act(() => { + projectInput.focus(); + fireEvent.change(projectInput, { target: { value: 'test-project' } }); + projectInput.blur(); + }); expect(onChange).toHaveBeenCalledWith({ project: 'test-project' }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 59c09c106c..af78bc0395 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -32,7 +32,7 @@ import { useTemplateSecrets, ScaffolderRJSFField, } from '@backstage/plugin-scaffolder-react'; -import { act, fireEvent } from '@testing-library/react'; +import { act, fireEvent, waitFor } from '@testing-library/react'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { @@ -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/RepoUrlPickerRepoName.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx index efc3c94e71..3a32b6e175 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx @@ -71,9 +71,11 @@ describe('RepoUrlPickerRepoName', () => { expect(textArea).toBeVisible(); - act(() => textArea.focus()); - fireEvent.change(textArea, { target: { value: 'foo' } }); - act(() => textArea.blur()); + act(() => { + textArea.focus(); + fireEvent.change(textArea, { target: { value: 'foo' } }); + textArea.blur(); + }); expect(onChange).toHaveBeenCalledWith('foo'); }); From 74c95163438eac8e0a7e51d2bf04ae7f4d1cea60 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 14 Jun 2024 13:22:30 +0200 Subject: [PATCH 22/33] chore: remove unused import Signed-off-by: Benjamin Janssens --- .../src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index af78bc0395..002652cfab 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -32,7 +32,7 @@ import { useTemplateSecrets, ScaffolderRJSFField, } from '@backstage/plugin-scaffolder-react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { From b82eaf64e2a522d7970d924ee275fbde2c9e9eda Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Sun, 16 Jun 2024 11:19:39 +0200 Subject: [PATCH 23/33] test: add tests for handleBitbucketCloudRequest Signed-off-by: Benjamin Janssens --- .../src/service/autocomplete.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 plugins/scaffolder-backend/src/service/autocomplete.test.ts diff --git a/plugins/scaffolder-backend/src/service/autocomplete.test.ts b/plugins/scaffolder-backend/src/service/autocomplete.test.ts new file mode 100644 index 0000000000..53816f0126 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/autocomplete.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { handleBitbucketCloudRequest } from './autocomplete'; +import { InputError } from '@backstage/errors'; + +describe('handleBitbucketCloudRequest', () => { + 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 handleBitbucketCloudRequest(accessToken, 'workspaces', {}); + + expect(fromConfig).toHaveBeenCalledWith( + expect.objectContaining({ accessToken }), + ); + }); + + it('should return workspaces', async () => { + const result = await handleBitbucketCloudRequest('foo', 'workspaces', {}); + + expect(result).toEqual(['workspace1']); + }); + + it('should return projects', async () => { + const result = await handleBitbucketCloudRequest('foo', 'projects', { + workspace: 'workspace1', + }); + + expect(result).toEqual(['project1']); + }); + + it('should return repositories', async () => { + const result = await handleBitbucketCloudRequest('foo', 'repositories', { + workspace: 'workspace1', + project: 'project1', + }); + + expect(result).toEqual(['repository1']); + }); + + it('should throw an error when passing an invalid resource', async () => { + await expect( + handleBitbucketCloudRequest('token', 'invalid', {}), + ).rejects.toThrow(InputError); + }); + + it('should throw an error when there are missing parameters', async () => { + await expect( + handleBitbucketCloudRequest('token', 'projects', {}), + ).rejects.toThrow(InputError); + }); +}); From 2aa81ad13a19ddaf3a3960a976ead3ae0a83762c Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Sun, 16 Jun 2024 11:50:05 +0200 Subject: [PATCH 24/33] test: improve tests for autocomplete endpoint Signed-off-by: Benjamin Janssens --- .../src/service/router.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 0024a57bd9..f9ff1b2a04 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -63,9 +63,7 @@ jest.mock('fs-extra', () => ({ remove: jest.fn(), })); -jest.mock('./autocomplete', () => ({ - handleBitbucketCloudRequest: jest.fn(), -})); +jest.mock('./autocomplete'); function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( @@ -1469,18 +1467,25 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ describe('GET /v2/autocomplete/:provider/:resource', () => { it('should handle requests for provider bitbucketCloud', async () => { + jest + .mocked(handleBitbucketCloudRequest) + .mockResolvedValue(['resource1']); + const bbToken = 'foo'; const resource = 'bar'; - await request(app) - .get(`/v2/autocomplete/bitbucketCloud/${resource}?token=${bbToken}`) - .send(); + const response = await request(app) + .get(`/v2/autocomplete/bitbucketCloud/${resource}`) + .query({ token: bbToken, workspace: 'workspace1' }); - expect(jest.mocked(handleBitbucketCloudRequest)).toHaveBeenCalledWith( + expect(handleBitbucketCloudRequest).toHaveBeenCalledWith( bbToken, resource, - {}, + { workspace: 'workspace1' }, ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(['resource1']); }); }); }); From 025f23f01343b66f85e439fc684546f9abf2cc2f Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Sun, 16 Jun 2024 12:02:23 +0200 Subject: [PATCH 25/33] test: clean up tests Signed-off-by: Benjamin Janssens --- .../fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx | 3 +++ .../fields/RepoUrlPicker/RepoUrlPickerRepoName.test.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index eb8c1f3a2c..18ce8d2c76 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -189,6 +189,7 @@ describe('BitbucketRepoPicker', () => { describe('autocompletion', () => { it('should populate workspaces if host is set and accessToken is provided', async () => { const onChange = jest.fn(); + const { getAllByRole, getByText } = render( { it('should populate projects if host and workspace are set and accessToken is provided', async () => { const onChange = jest.fn(); + const { getAllByRole, getByText } = render( { it('should populate repositories if host, workspace and project are set and accessToken is provided', async () => { const onChange = jest.fn(); + render( { } // Verify that selecting an option calls onChange - await userEvent.click(getByText('foo')); - expect(onChange).toHaveBeenCalledWith('foo'); + await userEvent.click(getByText(availableRepos[0])); + expect(onChange).toHaveBeenCalledWith(availableRepos[0]); }); }); From a82cbab3c4a507d74e95ba5d07b1d27fce1f2470 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 Jun 2024 10:25:42 +0200 Subject: [PATCH 26/33] chore: starting to refactor a little bit] Signed-off-by: blam --- .../src/ScaffolderPlugin.ts | 8 ++++++ .../src/service/autocomplete.ts | 2 +- plugins/scaffolder-node/src/alpha.ts | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index a8213cf585..9fc4b2537a 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -29,6 +29,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { scaffolderActionsExtensionPoint, + scaffolderAutocompleteExtensionPoint, scaffolderTaskBrokerExtensionPoint, scaffolderTemplatingExtensionPoint, } from '@backstage/plugin-scaffolder-node/alpha'; @@ -82,6 +83,13 @@ export const scaffolderPlugin = createBackendPlugin({ }, }); + const autocompleteResolers = []; + env.registerExtensionPoint(scaffolderAutocompleteExtensionPoint, { + addAutocompleteProvider(provider) { + autocompleteResolers.push(provider); + }, + }); + env.registerInit({ deps: { logger: coreServices.logger, diff --git a/plugins/scaffolder-backend/src/service/autocomplete.ts b/plugins/scaffolder-backend/src/service/autocomplete.ts index 034a201450..131305d53e 100644 --- a/plugins/scaffolder-backend/src/service/autocomplete.ts +++ b/plugins/scaffolder-backend/src/service/autocomplete.ts @@ -17,7 +17,7 @@ import { InputError } from '@backstage/errors'; import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; -export async function handleBitbucketCloudRequest( +export async function handleAutocompleteRequest( token: string, resource: string, parameters: Record, diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha.ts index b85da5b590..3644d48b7c 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha.ts @@ -79,3 +79,31 @@ export const scaffolderTemplatingExtensionPoint = createExtensionPoint({ id: 'scaffolder.templating', }); + +/** + * Extension point for adding autocomplete handler providers + * @alpha + */ +export interface ScaffolderAutocompleteExtensionPoint { + addAutocompleteProvider( + provider: ({ + type, + token, + query, + }: { + type: string; + token: string; + query: Record; + }) => Promise<{ results: { title: string }[] }>, + ): void; +} + +/** + * Extension point for adding template filters and globals. + * + * @alpha + */ +export const scaffolderAutocompleteExtensionPoint = + createExtensionPoint({ + id: 'scaffolder.autocomplete', + }); From 1fbd77fb56fc9c1031ff1bd679a9e9c821c01338 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 Jun 2024 17:09:53 +0200 Subject: [PATCH 27/33] chore: small refactor to move the handlers out into an extension point Signed-off-by: blam --- .../package.json | 1 + .../src/autocomplete}/autocomplete.test.ts | 16 ++--- .../src/autocomplete}/autocomplete.ts | 34 +++++----- .../src/module.ts | 14 ++++- .../src/service/router.test.ts | 2 +- .../scaffolder-backend/src/service/router.ts | 31 +++++----- plugins/scaffolder-node/src/alpha.ts | 32 ++++++---- plugins/scaffolder-react/src/api/types.ts | 12 ++-- plugins/scaffolder/src/api.ts | 36 +++++++---- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 62 ++++++++++++------- 10 files changed, 144 insertions(+), 96 deletions(-) rename plugins/{scaffolder-backend/src/service => scaffolder-backend-module-bitbucket-cloud/src/autocomplete}/autocomplete.test.ts (81%) rename plugins/{scaffolder-backend/src/service => scaffolder-backend-module-bitbucket-cloud/src/autocomplete}/autocomplete.ts (69%) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index f846f751cc..97a08e2741 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/src/service/autocomplete.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts similarity index 81% rename from plugins/scaffolder-backend/src/service/autocomplete.test.ts rename to plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts index 53816f0126..50e218ce85 100644 --- a/plugins/scaffolder-backend/src/service/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts @@ -15,10 +15,10 @@ */ import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { handleAutocompleteRequest } from './autocomplete'; import { InputError } from '@backstage/errors'; -describe('handleBitbucketCloudRequest', () => { +describe('handleAutocompleteRequest', () => { const client: Partial = { listWorkspaces: jest.fn().mockReturnValue({ iteratePages: jest @@ -43,7 +43,7 @@ describe('handleBitbucketCloudRequest', () => { it('should pass the token to the client', async () => { const accessToken = 'foo'; - await handleBitbucketCloudRequest(accessToken, 'workspaces', {}); + await handleAutocompleteRequest(accessToken, 'workspaces', {}); expect(fromConfig).toHaveBeenCalledWith( expect.objectContaining({ accessToken }), @@ -51,13 +51,13 @@ describe('handleBitbucketCloudRequest', () => { }); it('should return workspaces', async () => { - const result = await handleBitbucketCloudRequest('foo', 'workspaces', {}); + const result = await handleAutocompleteRequest('foo', 'workspaces', {}); expect(result).toEqual(['workspace1']); }); it('should return projects', async () => { - const result = await handleBitbucketCloudRequest('foo', 'projects', { + const result = await handleAutocompleteRequest('foo', 'projects', { workspace: 'workspace1', }); @@ -65,7 +65,7 @@ describe('handleBitbucketCloudRequest', () => { }); it('should return repositories', async () => { - const result = await handleBitbucketCloudRequest('foo', 'repositories', { + const result = await handleAutocompleteRequest('foo', 'repositories', { workspace: 'workspace1', project: 'project1', }); @@ -75,13 +75,13 @@ describe('handleBitbucketCloudRequest', () => { it('should throw an error when passing an invalid resource', async () => { await expect( - handleBitbucketCloudRequest('token', 'invalid', {}), + handleAutocompleteRequest('token', 'invalid', {}), ).rejects.toThrow(InputError); }); it('should throw an error when there are missing parameters', async () => { await expect( - handleBitbucketCloudRequest('token', 'projects', {}), + handleAutocompleteRequest('token', 'projects', {}), ).rejects.toThrow(InputError); }); }); diff --git a/plugins/scaffolder-backend/src/service/autocomplete.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts similarity index 69% rename from plugins/scaffolder-backend/src/service/autocomplete.ts rename to plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts index 131305d53e..50723fb41d 100644 --- a/plugins/scaffolder-backend/src/service/autocomplete.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts @@ -17,11 +17,15 @@ import { InputError } from '@backstage/errors'; import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; -export async function handleAutocompleteRequest( - token: string, - resource: string, - parameters: Record, -): Promise { +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', @@ -37,41 +41,41 @@ export async function handleAutocompleteRequest( result.push(...slugs); } - return result; + return { results: result.map(title => ({ title })) }; } case 'projects': { - if (!parameters.workspace) - throw new InputError('Missing workspace query parameter'); + if (!context.workspace) + throw new InputError('Missing workspace context parameter'); const result: string[] = []; for await (const page of client - .listProjectsByWorkspace(parameters.workspace) + .listProjectsByWorkspace(context.workspace) .iteratePages()) { const keys = [...page.values!].map(p => p.key!); result.push(...keys); } - return result; + return { results: result.map(title => ({ title })) }; } case 'repositories': { - if (!parameters.workspace || !parameters.project) + if (!context.workspace || !context.project) throw new InputError( - 'Missing workspace and/or project query parameter', + 'Missing workspace and/or project context parameter', ); const result: string[] = []; for await (const page of client - .listRepositoriesByWorkspace(parameters.workspace, { - q: `project.key="${parameters.project}"`, + .listRepositoriesByWorkspace(context.workspace, { + q: `project.key="${context.project}"`, }) .iteratePages()) { const slugs = [...page.values!].map(p => p.slug!); result.push(...slugs); } - return result; + 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/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index f9ff1b2a04..10975d6b52 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -46,7 +46,7 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { handleBitbucketCloudRequest } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete'; const mockAccess = jest.fn(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index dbccd6ddb5..e5f88c3482 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -94,7 +94,7 @@ import { } from '@backstage/plugin-auth-node'; import { InternalTaskSecrets } from '../scaffolder/tasks/types'; import { checkPermission } from '../util/checkPermissions'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha'; /** * @@ -167,6 +167,8 @@ export interface RouterOptions { httpAuth?: HttpAuthService; identity?: IdentityApi; discovery?: DiscoveryService; + + autocompleteHandlers?: Record; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { @@ -274,6 +276,7 @@ export async function createRouter( permissionRules, discovery = HostDiscovery.fromConfig(config), identity = buildDefaultIdentityClient(options), + autocompleteHandlers = {}, } = options; const { auth, httpAuth } = createLegacyAuthAdapters({ @@ -773,28 +776,22 @@ export async function createRouter( })), }); }) - .get('/v2/autocomplete/:provider/:resource', async (req, res) => { - const { token, ...query } = req.query; + .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'); - let result: string[]; - - switch (provider) { - case 'bitbucketCloud': { - result = await handleBitbucketCloudRequest( - token as string, - resource, - query as Record, - ); - break; - } - default: - throw new InputError(`Unsupported provider: ${provider}`); + if (!autocompleteHandlers[provider]) { + throw new InputError(`Unsupported provider: ${provider}`); } + const { results } = await autocompleteHandlers[provider]({ + resource, + token, + context, + }); - res.status(200).json(result); + res.status(200).json({ results }); }); const app = express(); diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha.ts index 3644d48b7c..ef4d5ab289 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha.ts @@ -80,22 +80,32 @@ export const scaffolderTemplatingExtensionPoint = 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( - provider: ({ - type, - token, - query, - }: { - type: string; - token: string; - query: Record; - }) => Promise<{ results: { title: string }[] }>, - ): void; + addAutocompleteProvider({ + id, + handler, + }: { + id: string; + handler: AutocompleteHandler; + }): void; } /** diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index c79f98c780..eaef54ff1b 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -230,10 +230,10 @@ export interface ScaffolderApi { dryRun?(options: ScaffolderDryRunOptions): Promise; - autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise; + autocomplete?(options: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ results: { title: string }[] }>; } diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ff5c89e91e..1809ed4c15 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -338,27 +338,37 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } - async autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise { + 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 query = new URLSearchParams({ - ...params, - token, - }); - const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}?${query}`; + + const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}`; const response = await this.fetchApi.fetch(url, { - method: 'GET', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + context: context ?? {}, + }), }); if (!response.ok) { throw await ResponseError.fromResponse(response); } - return await response.json(); + const { results } = await response.json(); + return { results }; } } diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 51bd1f5c67..10d4a6e6c3 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -72,14 +72,19 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableWorkspaces = async () => { - if (host === 'bitbucket.org' && accessToken) { - const result = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'workspaces', - ); + if ( + host === 'bitbucket.org' && + accessToken && + scaffolderApi.autocomplete + ) { + const { results } = await scaffolderApi.autocomplete({ + token: accessToken, + resource: 'workspaces', + context: {}, + provider: 'bitbucket-cloud', + }); - setAvailableWorkspaces(result); + setAvailableWorkspaces(results.map(r => r.title)); } else { setAvailableWorkspaces([]); } @@ -95,15 +100,20 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableProjects = async () => { - if (host === 'bitbucket.org' && accessToken && workspace) { - const result = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'projects', - { workspace }, - ); + if ( + host === 'bitbucket.org' && + accessToken && + workspace && + scaffolderApi.autocomplete + ) { + const { results } = await scaffolderApi.autocomplete({ + token: accessToken, + resource: 'projects', + context: { workspace }, + provider: 'bitbucket-cloud', + }); - setAvailableProjects(result); + setAvailableProjects(results.map(r => r.title)); } else { setAvailableProjects([]); } @@ -119,15 +129,21 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableRepositories = async () => { - if (host === 'bitbucket.org' && accessToken && workspace && project) { - const availableRepos = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'repositories', - { workspace, project }, - ); + 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 }); + onChange({ availableRepos: results.map(r => r.title) }); } else { onChange({ availableRepos: [] }); } From 16acc0fc10a1879e19075c5ce0f8900b6c5263b6 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 Jun 2024 17:10:11 +0200 Subject: [PATCH 28/33] chore: pass through the handlers to the autocomplete Signed-off-by: blam --- plugins/scaffolder-backend/src/ScaffolderPlugin.ts | 6 ++++-- plugins/scaffolder-backend/src/service/router.test.ts | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 9fc4b2537a..c83c6d437d 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -28,6 +28,7 @@ import { TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; import { + AutocompleteHandler, scaffolderActionsExtensionPoint, scaffolderAutocompleteExtensionPoint, scaffolderTaskBrokerExtensionPoint, @@ -83,10 +84,10 @@ export const scaffolderPlugin = createBackendPlugin({ }, }); - const autocompleteResolers = []; + const autocompleteHandlers: Record = {}; env.registerExtensionPoint(scaffolderAutocompleteExtensionPoint, { addAutocompleteProvider(provider) { - autocompleteResolers.push(provider); + autocompleteHandlers[provider.id] = provider.handler; }, }); @@ -170,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 10975d6b52..4bd3847666 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -46,7 +46,6 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; -import { handleBitbucketCloudRequest } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete'; const mockAccess = jest.fn(); From acda33a75293ff1966a3e1aa3b239e6d32864b34 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 24 Jun 2024 11:21:34 +0200 Subject: [PATCH 29/33] chore: update yarn.lock Signed-off-by: blam --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index f81ce9dabd..225004ba7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6829,6 +6829,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 From 0273ae27d34bdf393b10321525d98a837abf3084 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 24 Jun 2024 11:52:39 +0200 Subject: [PATCH 30/33] feat: fix tests and test confifmr Signed-off-by: blam --- .../src/autocomplete/autocomplete.test.ts | 48 ++++++++--- plugins/scaffolder-backend/package.json | 1 + .../src/service/router.test.ts | 80 +++++++++++++++---- .../scaffolder-backend/src/service/router.ts | 1 + yarn.lock | 1 + 5 files changed, 102 insertions(+), 29 deletions(-) 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 index 50e218ce85..7367504d93 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts @@ -43,7 +43,11 @@ describe('handleAutocompleteRequest', () => { it('should pass the token to the client', async () => { const accessToken = 'foo'; - await handleAutocompleteRequest(accessToken, 'workspaces', {}); + await handleAutocompleteRequest({ + token: accessToken, + context: {}, + resource: 'workspaces', + }); expect(fromConfig).toHaveBeenCalledWith( expect.objectContaining({ accessToken }), @@ -51,37 +55,57 @@ describe('handleAutocompleteRequest', () => { }); it('should return workspaces', async () => { - const result = await handleAutocompleteRequest('foo', 'workspaces', {}); + const result = await handleAutocompleteRequest({ + token: 'foo', + context: {}, + resource: 'workspaces', + }); - expect(result).toEqual(['workspace1']); + expect(result).toEqual({ results: [{ title: 'workspace1' }] }); }); it('should return projects', async () => { - const result = await handleAutocompleteRequest('foo', 'projects', { - workspace: 'workspace1', + const result = await handleAutocompleteRequest({ + token: 'foo', + context: { + workspace: 'workspace1', + }, + resource: 'projects', }); - expect(result).toEqual(['project1']); + expect(result).toEqual({ results: [{ title: 'project1' }] }); }); it('should return repositories', async () => { - const result = await handleAutocompleteRequest('foo', 'repositories', { - workspace: 'workspace1', - project: 'project1', + const result = await handleAutocompleteRequest({ + token: 'foo', + resource: 'repositories', + context: { + workspace: 'workspace1', + project: 'project1', + }, }); - expect(result).toEqual(['repository1']); + expect(result).toEqual({ results: [{ title: 'repository1' }] }); }); it('should throw an error when passing an invalid resource', async () => { await expect( - handleAutocompleteRequest('token', 'invalid', {}), + handleAutocompleteRequest({ + token: 'token', + resource: 'invalid', + context: {}, + }), ).rejects.toThrow(InputError); }); it('should throw an error when there are missing parameters', async () => { await expect( - handleAutocompleteRequest('token', 'projects', {}), + handleAutocompleteRequest({ + token: 'token', + resource: 'projects', + context: {}, + }), ).rejects.toThrow(InputError); }); }); diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index af6625c8b8..eb977787c2 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -114,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/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4bd3847666..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(); @@ -62,8 +64,6 @@ jest.mock('fs-extra', () => ({ remove: jest.fn(), })); -jest.mock('./autocomplete'); - function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( new ConfigReader({ @@ -1465,26 +1465,72 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); describe('GET /v2/autocomplete/:provider/:resource', () => { - it('should handle requests for provider bitbucketCloud', async () => { - jest - .mocked(handleBitbucketCloudRequest) - .mockResolvedValue(['resource1']); + let handleAutocompleteRequest: AutocompleteHandler; - const bbToken = 'foo'; - const resource = 'bar'; + 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) - .get(`/v2/autocomplete/bitbucketCloud/${resource}`) - .query({ token: bbToken, workspace: 'workspace1' }); - - expect(handleBitbucketCloudRequest).toHaveBeenCalledWith( - bbToken, - resource, - { workspace: 'workspace1' }, - ); + .post('/v2/autocomplete/test-provider/resource') + .send({ + token: mockToken, + context, + }); expect(response.status).toEqual(200); - expect(response.body).toEqual(['resource1']); + + 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 e5f88c3482..9b3f5173c8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -785,6 +785,7 @@ export async function createRouter( if (!autocompleteHandlers[provider]) { throw new InputError(`Unsupported provider: ${provider}`); } + const { results } = await autocompleteHandlers[provider]({ resource, token, diff --git a/yarn.lock b/yarn.lock index 225004ba7a..9952ad2bbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7083,6 +7083,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:^" From b5a988f0c3a96973d3b667af6b976e0c0192f1c9 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 24 Jun 2024 13:10:55 +0200 Subject: [PATCH 31/33] chore: updating api-reports Signed-off-by: blam --- plugins/scaffolder-backend/api-report.md | 3 +++ plugins/scaffolder-node/api-report-alpha.md | 30 +++++++++++++++++++++ plugins/scaffolder-react/api-report.md | 16 ++++++----- plugins/scaffolder/api-report.md | 21 ++++++++++----- 4 files changed, 58 insertions(+), 12 deletions(-) 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-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-react/api-report.md b/plugins/scaffolder-react/api-report.md index cc6672a8a5..a14490b80c 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -180,12 +180,16 @@ export type ReviewStepProps = { // @public export interface ScaffolderApi { // (undocumented) - autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise; + 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/api-report.md b/plugins/scaffolder/api-report.md index affc69ed93..f479b13ec4 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -502,12 +502,21 @@ export class ScaffolderClient implements ScaffolderApi_2 { useLongPollingLogs?: boolean; }); // (undocumented) - autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise; + autocomplete({ + token, + resource, + provider, + context, + }: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ + results: { + title: string; + }[]; + }>; // (undocumented) cancelTask(taskId: string): Promise; // (undocumented) From b5deed0c1ba6dc22581e2847a70edecee092fc52 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 24 Jun 2024 13:18:14 +0200 Subject: [PATCH 32/33] chore: updating changesets and api-reports Signed-off-by: blam --- .changeset/angry-ladybugs-retire.md | 5 ----- .changeset/five-forks-retire.md | 5 ----- .changeset/friendly-masks-type.md | 5 +++++ .changeset/great-colts-enjoy.md | 5 ----- .changeset/light-avocados-worry.md | 6 ++++++ .changeset/nine-seals-sit.md | 5 ----- .changeset/rare-planes-switch.md | 5 ----- .changeset/selfish-turtles-jog.md | 6 ++++++ .changeset/slimy-ties-relate.md | 5 ----- .changeset/tame-geese-run.md | 5 ----- .changeset/young-donuts-swim.md | 6 ++++++ packages/integration/api-report.md | 2 +- packages/integration/src/bitbucketCloud/config.ts | 2 +- plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts | 5 ++--- .../src/autocomplete/autocomplete.test.ts | 2 +- .../src/autocomplete/autocomplete.ts | 2 +- 16 files changed, 29 insertions(+), 42 deletions(-) delete mode 100644 .changeset/angry-ladybugs-retire.md delete mode 100644 .changeset/five-forks-retire.md create mode 100644 .changeset/friendly-masks-type.md delete mode 100644 .changeset/great-colts-enjoy.md create mode 100644 .changeset/light-avocados-worry.md delete mode 100644 .changeset/nine-seals-sit.md delete mode 100644 .changeset/rare-planes-switch.md create mode 100644 .changeset/selfish-turtles-jog.md delete mode 100644 .changeset/slimy-ties-relate.md delete mode 100644 .changeset/tame-geese-run.md create mode 100644 .changeset/young-donuts-swim.md diff --git a/.changeset/angry-ladybugs-retire.md b/.changeset/angry-ladybugs-retire.md deleted file mode 100644 index ca2b34676b..0000000000 --- a/.changeset/angry-ladybugs-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': minor ---- - -Added autocompletion for Bitbucket Cloud diff --git a/.changeset/five-forks-retire.md b/.changeset/five-forks-retire.md deleted file mode 100644 index 9b9573bfd4..0000000000 --- a/.changeset/five-forks-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-bitbucket-cloud-common': patch ---- - -Added support for access tokens 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/great-colts-enjoy.md b/.changeset/great-colts-enjoy.md deleted file mode 100644 index 6907f661a5..0000000000 --- a/.changeset/great-colts-enjoy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-bitbucket-cloud-common': patch ---- - -Added method `listProjectsByWorkspace` for retrieving projects by workspace 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/nine-seals-sit.md b/.changeset/nine-seals-sit.md deleted file mode 100644 index 0055a1be24..0000000000 --- a/.changeset/nine-seals-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/integration': minor ---- - -Added support for access tokens to Bitbucket Cloud diff --git a/.changeset/rare-planes-switch.md b/.changeset/rare-planes-switch.md deleted file mode 100644 index 622c6d48d8..0000000000 --- a/.changeset/rare-planes-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-bitbucket-cloud-common': patch ---- - -Added method `listWorkspaces` for retrieving workspaces 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/slimy-ties-relate.md b/.changeset/slimy-ties-relate.md deleted file mode 100644 index d4da9972f1..0000000000 --- a/.changeset/slimy-ties-relate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Added endpoint for autocompleting provider resources (currently only supports Bitbucket) diff --git a/.changeset/tame-geese-run.md b/.changeset/tame-geese-run.md deleted file mode 100644 index 785ae6c7fc..0000000000 --- a/.changeset/tame-geese-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-react': patch ---- - -Added method `autocomplete` to interface `ScaffolderApi` 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 91abc7473f..4c8e8accf2 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -186,7 +186,7 @@ export type BitbucketCloudIntegrationConfig = { apiBaseUrl: string; username?: string; appPassword?: string; - accessToken?: string; + token?: string; }; // @public @deprecated diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index 2f533480cc..32a2af9313 100644 --- a/packages/integration/src/bitbucketCloud/config.ts +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -50,7 +50,7 @@ export type BitbucketCloudIntegrationConfig = { /** * The access token to use for requests to Bitbucket Cloud (bitbucket.org). */ - accessToken?: string; + token?: string; }; /** diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index a06f7425d0..f1b7d60e41 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -139,11 +139,10 @@ export class BitbucketCloudClient { 'utf8', ); headers.Authorization = `Basic ${buffer.toString('base64')}`; + } else if (this.config.token) { + headers.Authorization = `Bearer ${this.config.token}`; } - if (this.config.accessToken) - headers.Authorization = `Bearer ${this.config.accessToken}`; - return headers; } } 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 index 7367504d93..0447ff2d28 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts @@ -50,7 +50,7 @@ describe('handleAutocompleteRequest', () => { }); expect(fromConfig).toHaveBeenCalledWith( - expect.objectContaining({ accessToken }), + expect.objectContaining({ token: accessToken }), ); }); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts index 50723fb41d..31a2103088 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts @@ -29,7 +29,7 @@ export async function handleAutocompleteRequest({ const client = BitbucketCloudClient.fromConfig({ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0', - accessToken: token, + token, }); switch (resource) { From d14795bd0587a52b87284628d8364ddf570d09f9 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jun 2024 10:31:38 +0200 Subject: [PATCH 33/33] chore: fixing tests Signed-off-by: blam --- .../fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 18ce8d2c76..2ae89233ff 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -27,11 +27,9 @@ import { act } from 'react-dom/test-utils'; describe('BitbucketRepoPicker', () => { const scaffolderApiMock: Partial = { - autocomplete: jest - .fn() - .mockImplementation((_token, _provider, resource) => [ - `${resource}_example`, - ]), + autocomplete: jest.fn().mockImplementation(opts => ({ + results: [{ title: `${opts.resource}_example` }], + })), }; it('renders a select if there is a list of allowed owners', async () => {