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 75cc170a30..542e58574e 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts @@ -25,7 +25,7 @@ export async function handleAutocompleteRequest({ resource: string; token: string; context: Record; -}): Promise<{ results: { title: string }[] }> { +}): Promise<{ results: { title: string; id?: string }[] }> { const client = BitbucketCloudClient.fromConfig({ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0', @@ -34,10 +34,13 @@ export async function handleAutocompleteRequest({ switch (resource) { case 'workspaces': { - const results: { title: string }[] = []; + const results: { title: string; id: string }[] = []; for await (const page of client.listWorkspaces().iteratePages()) { - const slugs = [...page.values!].map(p => ({ title: p.slug! })); + const slugs = [...page.values!].map(p => ({ + title: p.slug!, + id: p.uuid!, + })); results.push(...slugs); } @@ -47,12 +50,15 @@ export async function handleAutocompleteRequest({ if (!context.workspace) throw new InputError('Missing workspace context parameter'); - const results: { title: string }[] = []; + const results: { title: string; id: string }[] = []; for await (const page of client .listProjectsByWorkspace(context.workspace) .iteratePages()) { - const keys = [...page.values!].map(p => ({ title: p.key! })); + const keys = [...page.values!].map(p => ({ + title: p.key!, + id: p.uuid!, + })); results.push(...keys); } @@ -64,14 +70,17 @@ export async function handleAutocompleteRequest({ 'Missing workspace and/or project context parameter', ); - const results: { title: string }[] = []; + const results: { title: string; id: string }[] = []; for await (const page of client .listRepositoriesByWorkspace(context.workspace, { q: `project.key="${context.project}"`, }) .iteratePages()) { - const slugs = [...page.values!].map(p => ({ title: p.slug! })); + const slugs = [...page.values!].map(p => ({ + title: p.slug!, + id: p.uuid!, + })); results.push(...slugs); } diff --git a/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts new file mode 100644 index 0000000000..2a15c0c99e --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts @@ -0,0 +1,108 @@ +/* + * 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 { ScmIntegrationRegistry } from '@backstage/integration'; +import { getClient } from '../util'; + +export function createHandleAutocompleteRequest(options: { + integrations: ScmIntegrationRegistry; +}) { + return async function handleAutocompleteRequest({ + resource, + token, + context, + }: { + resource: string; + token: string; + context: Record; + }): Promise<{ + results: { + title: string; + id?: string; + }[]; + }> { + const { integrations } = options; + const client = getClient({ + host: context.host ?? 'gitlab.com', + integrations, + token, + }); + + switch (resource) { + case 'groups': { + let groups: any[] = []; + let page = 1; + const perPage = 100; + let response = []; + let continueFetch = true; + while (continueFetch) { + response = await client.Groups.all({ + pagination: 'offset', + page, + perPage, + }); + + groups = groups.concat(response); + if (response.length < perPage) continueFetch = false; + page++; + } + + const result: { + results: { + title: string; + id: string; + }[]; + } = { + results: groups.map(group => ({ + title: group.full_path, + id: group.id.toString(), + })), + }; + // append also user context + const user = await client.Users.showCurrentUser(); + result.results.push({ + title: user.username, + id: user.id.toString(), + }); + + return result; + } + case 'repositories': { + if (!context.id) + throw new InputError('Missing groupId and userId context parameter'); + + let response; + if ( + context.id === (await client.Users.showCurrentUser())?.id.toString() + ) { + response = await client.Users.allProjects(context.id); + } else { + response = await client.Groups.allProjects(context.id); + } + + return { + results: response.map(project => ({ + title: project.name.trim(), + id: project.id.toString(), + })), + }; + } + default: + throw new InputError(`Invalid resource: ${resource}`); + } + }; +} diff --git a/plugins/scaffolder-backend-module-gitlab/src/module.ts b/plugins/scaffolder-backend-module-gitlab/src/module.ts index 78175532ed..9f18532cdf 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/module.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/module.ts @@ -18,7 +18,10 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; -import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +import { + scaffolderActionsExtensionPoint, + scaffolderAutocompleteExtensionPoint, +} from '@backstage/plugin-scaffolder-node/alpha'; import { createGitlabGroupEnsureExistsAction, createGitlabIssueAction, @@ -31,6 +34,7 @@ import { createTriggerGitlabPipelineAction, editGitlabIssueAction, } from './actions'; +import { createHandleAutocompleteRequest } from './autocomplete/autocomplete'; /** * @public @@ -43,9 +47,10 @@ export const gitlabModule = createBackendModule({ registerInit({ deps: { scaffolder: scaffolderActionsExtensionPoint, + autocomplete: scaffolderAutocompleteExtensionPoint, config: coreServices.rootConfig, }, - async init({ scaffolder, config }) { + async init({ scaffolder, autocomplete, config }) { const integrations = ScmIntegrations.fromConfig(config); scaffolder.addActions( @@ -60,6 +65,11 @@ export const gitlabModule = createBackendModule({ createPublishGitlabMergeRequestAction({ integrations }), createTriggerGitlabPipelineAction({ integrations }), ); + + autocomplete.addAutocompleteProvider({ + id: 'gitlab', + handler: createHandleAutocompleteRequest({ integrations }), + }); }, }); }, diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha.ts index 6a0e34faf7..a8b7bde76b 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha.ts @@ -16,10 +16,10 @@ import { createExtensionPoint } from '@backstage/backend-plugin-api'; import { + TaskBroker, TemplateAction, TemplateFilter, TemplateGlobal, - TaskBroker, } from '@backstage/plugin-scaffolder-node'; export * from './tasks/alpha'; @@ -95,7 +95,7 @@ export type AutocompleteHandler = ({ resource: string; token: string; context: Record; -}) => Promise<{ results: { title: string }[] }>; +}) => Promise<{ results: { title: string; id?: string }[] }>; /** * Extension point for adding autocomplete handler providers diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index bc37eed47e..73dd6c50d2 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -245,5 +245,5 @@ export interface ScaffolderApi { provider: string; resource: string; context?: Record; - }): Promise<{ results: { title: string }[] }>; + }): Promise<{ results: { title: string; id?: string }[] }>; } diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 28cd0cf5bc..c2c6b7f0d6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -13,18 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import { Select, SelectItem } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; -import { Select, SelectItem } from '@backstage/core-components'; -import { BaseRepoUrlPickerProps } from './types'; -import Autocomplete from '@material-ui/lab/Autocomplete'; import TextField from '@material-ui/core/TextField'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import React, { useCallback, useEffect, useState } from 'react'; import useDebounce from 'react-use/esm/useDebounce'; -import { useApi } from '@backstage/core-plugin-api'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; +import { BaseRepoUrlPickerProps } from './types'; /** * The underlying component that is rendered in the form for the `BitbucketRepoPicker` diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx index f3d6e2da91..a8fb42cae3 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx @@ -14,22 +14,36 @@ * limitations under the License. */ +import { + ScaffolderApi, + scaffolderApiRef, +} from '@backstage/plugin-scaffolder-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; import React from 'react'; import { GitlabRepoPicker } from './GitlabRepoPicker'; -import { fireEvent } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; describe('GitlabRepoPicker', () => { + const scaffolderApiMock: Partial = { + autocomplete: jest.fn().mockImplementation(opts => + Promise.resolve({ + results: [{ title: `${opts.resource}_example` }], + }), + ), + }; describe('owner field', () => { it('renders a select if there is a list of allowed owners', async () => { const allowedOwners = ['owner1', 'owner2']; const { findByText } = await renderInTestApp( - , + + + , + , ); expect(await findByText('owner1')).toBeInTheDocument(); @@ -40,12 +54,15 @@ describe('GitlabRepoPicker', () => { const onChange = jest.fn(); const allowedOwners = ['owner1', 'owner2']; const { getByRole } = await renderInTestApp( - , + + + , + , ); await fireEvent.change(getByRole('combobox'), { @@ -59,12 +76,15 @@ describe('GitlabRepoPicker', () => { const onChange = jest.fn(); const allowedOwners = ['owner1']; const { getByRole } = await renderInTestApp( - , + + + , + , ); expect(getByRole('combobox')).toBeDisabled(); @@ -73,14 +93,20 @@ describe('GitlabRepoPicker', () => { it('should display free text if no allowed owners are passed', async () => { const onChange = jest.fn(); const { getAllByRole } = await renderInTestApp( - , + + + , ); const ownerField = getAllByRole('textbox')[0]; - fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } }); + ownerField.focus(); + fireEvent.change(ownerField, { + target: { value: 'my-mock-owner' }, + }); + ownerField.blur(); expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx index c0879008a1..8ac7be0e97 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -13,28 +13,97 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import { Select, SelectItem } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import TextField from '@material-ui/core/TextField'; -import { Select, SelectItem } from '@backstage/core-components'; -import { BaseRepoUrlPickerProps } from './types'; -import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import React, { useCallback, useState } from 'react'; +import useDebounce from 'react-use/esm/useDebounce'; import { scaffolderTranslationRef } from '../../../translation'; +import { BaseRepoUrlPickerProps } from './types'; export const GitlabRepoPicker = ( props: BaseRepoUrlPickerProps<{ allowedOwners?: string[]; allowedRepos?: string[]; + accessToken?: string; }>, ) => { - const { allowedOwners = [], state, onChange, rawErrors } = props; + const { allowedOwners = [], state, onChange, rawErrors, accessToken } = props; + const [availableGroups, setAvailableGroups] = useState< + { title: string; id: string }[] + >([]); const { t } = useTranslationRef(scaffolderTranslationRef); const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) : [{ label: 'Loading...', value: 'loading' }]; - const { owner } = state; + const { owner, host } = state; + + const scaffolderApi = useApi(scaffolderApiRef); + + const updateAvailableGroups = useCallback(() => { + if (!scaffolderApi.autocomplete || !accessToken || !host) { + setAvailableGroups([]); + return; + } + + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'groups', + provider: 'gitlab', + context: { host }, + }) + .then(({ results }) => { + setAvailableGroups( + results.map(r => { + return { + title: r.title, + id: r.id!, + }; + }), + ); + }) + .catch(() => { + setAvailableGroups([]); + }); + }, [scaffolderApi, accessToken, host]); + + useDebounce(updateAvailableGroups, 500, [updateAvailableGroups]); + + // Update available repositories when client is available and group changes + const updateAvailableRepositories = useCallback(() => { + if (!scaffolderApi.autocomplete || !accessToken || !host || !owner) { + onChange({ availableRepos: [] }); + return; + } + + const selectedGroup = availableGroups.find(group => group.title === owner); + + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'repositories', + context: { + id: selectedGroup?.id ?? '', + host, + }, + provider: 'gitlab', + }) + .then(({ results }) => { + onChange({ availableRepos: results.map(r => r.title) }); + }) + .catch(() => { + onChange({ availableRepos: [] }); + }); + }, [scaffolderApi, accessToken, host, owner, onChange, availableGroups]); + + useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]); return ( <> @@ -64,12 +133,21 @@ export const GitlabRepoPicker = ( ) : ( - onChange({ owner: e.target.value })} - helperText={t('fields.gitlabRepoPicker.owner.description')} + { + onChange({ owner: newValue || '' }); + }} + options={availableGroups.map(group => group.title)} + renderInput={params => ( + + )} + freeSolo + autoSelect /> )} diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 3c9ca9c2ca..c0e52e1826 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -15,26 +15,26 @@ */ import { useApi } from '@backstage/core-plugin-api'; import { - scmIntegrationsApiRef, scmAuthApiRef, + scmIntegrationsApiRef, } from '@backstage/integration-react'; -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { GithubRepoPicker } from './GithubRepoPicker'; -import { GiteaRepoPicker } from './GiteaRepoPicker'; -import { GitlabRepoPicker } from './GitlabRepoPicker'; -import { AzureRepoPicker } from './AzureRepoPicker'; -import { BitbucketRepoPicker } from './BitbucketRepoPicker'; -import { GerritRepoPicker } from './GerritRepoPicker'; -import { RepoUrlPickerHost } from './RepoUrlPickerHost'; -import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; -import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; -import { RepoUrlPickerFieldSchema } from './schema'; -import { RepoUrlPickerState } from './types'; -import useDebounce from 'react-use/esm/useDebounce'; import { useTemplateSecrets } from '@backstage/plugin-scaffolder-react'; import Box from '@material-ui/core/Box'; import Divider from '@material-ui/core/Divider'; import Typography from '@material-ui/core/Typography'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useDebounce from 'react-use/esm/useDebounce'; +import { AzureRepoPicker } from './AzureRepoPicker'; +import { BitbucketRepoPicker } from './BitbucketRepoPicker'; +import { GerritRepoPicker } from './GerritRepoPicker'; +import { GiteaRepoPicker } from './GiteaRepoPicker'; +import { GithubRepoPicker } from './GithubRepoPicker'; +import { GitlabRepoPicker } from './GitlabRepoPicker'; +import { RepoUrlPickerHost } from './RepoUrlPickerHost'; +import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; +import { RepoUrlPickerFieldSchema } from './schema'; +import { RepoUrlPickerState } from './types'; +import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; export { RepoUrlPickerSchema } from './schema'; @@ -203,6 +203,10 @@ export const RepoUrlPicker = ( rawErrors={rawErrors} state={state} onChange={updateLocalState} + accessToken={ + uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey && + secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey] + } /> )} {hostType === 'bitbucket' && (