From 4317324e9e20465c5c98e8d4ea22989068cddb51 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 31 Jan 2025 14:30:23 +0100 Subject: [PATCH] feat(scaffolder): add support for autocompleting GitHub owners to scaffolder Signed-off-by: Benjamin Janssens --- .../src/autocomplete/autocomplete.ts | 58 ++++++++++++++++++ .../src/module.ts | 14 ++++- .../fields/RepoUrlPicker/GithubRepoPicker.tsx | 61 ++++++++++++++++--- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 4 ++ 4 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts new file mode 100644 index 0000000000..a015067f52 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -0,0 +1,58 @@ +/* + * 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 { getOctokitOptions } from '../util'; +import { Octokit } from 'octokit'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +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 octokitOptions = await getOctokitOptions({ + integrations, + token, + host: context.host ?? 'github.com', + }); + const client = new Octokit(octokitOptions); + + switch (resource) { + case 'owners': { + const organizations = await client.paginate( + client.rest.orgs.listForAuthenticatedUser, + ); + + const results = organizations.map(organization => ({ + id: organization.login, + })); + + return { results }; + } + default: + throw new InputError(`Invalid resource: ${resource}`); + } + }; +} diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 0387473fdb..02dfc2d7f3 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -17,7 +17,10 @@ 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 { createGithubActionsDispatchAction, createGithubAutolinksAction, @@ -37,6 +40,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { CatalogClient } from '@backstage/catalog-client'; +import { createHandleAutocompleteRequest } from './autocomplete/autocomplete'; /** * @public @@ -52,8 +56,9 @@ export const githubModule = createBackendModule({ config: coreServices.rootConfig, discovery: coreServices.discovery, auth: coreServices.auth, + autocomplete: scaffolderAutocompleteExtensionPoint, }, - async init({ scaffolder, config, discovery, auth }) { + async init({ scaffolder, config, discovery, auth, autocomplete }) { const integrations = ScmIntegrations.fromConfig(config); const githubCredentialsProvider = DefaultGithubCredentialsProvider.fromIntegrations(integrations); @@ -109,6 +114,11 @@ export const githubModule = createBackendModule({ integrations, }), ); + + autocomplete.addAutocompleteProvider({ + id: 'github', + handler: createHandleAutocompleteRequest({ integrations }), + }); }, }); }, diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx index 6f6cbfb355..09850d85c0 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import TextField from '@material-ui/core/TextField'; @@ -21,13 +21,18 @@ import { Select, SelectItem } from '@backstage/core-components'; import { BaseRepoUrlPickerProps } from './types'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; +import { useApi } from '@backstage/core-plugin-api'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import useDebounce from 'react-use/esm/useDebounce'; +import Autocomplete from '@material-ui/lab/Autocomplete'; export const GithubRepoPicker = ( props: BaseRepoUrlPickerProps<{ allowedOwners?: string[]; + accessToken?: string; }>, ) => { - const { allowedOwners = [], rawErrors, state, onChange } = props; + const { allowedOwners = [], rawErrors, state, onChange, accessToken } = props; const { t } = useTranslationRef(scaffolderTranslationRef); const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) @@ -35,6 +40,33 @@ export const GithubRepoPicker = ( const { owner } = state; + const scaffolderApi = useApi(scaffolderApiRef); + + const [availableOwners, setAvailableOwners] = useState([]); + + // Update available owners when client is available + const updateAvailableOwners = useCallback(() => { + if (!scaffolderApi.autocomplete || !accessToken) { + setAvailableOwners([]); + return; + } + + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'owners', + provider: 'github', + }) + .then(({ results }) => { + setAvailableOwners(results.map(r => r.id)); + }) + .catch(() => { + setAvailableOwners([]); + }); + }, [scaffolderApi, accessToken]); + + useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]); + return ( <> - - {t('fields.githubRepoPicker.owner.description')} - ) : ( - onChange({ owner: e.target.value })} - helperText={t('fields.githubRepoPicker.owner.description')} + { + onChange({ owner: newValue || '' }); + }} + options={availableOwners} + renderInput={params => ( + + )} + freeSolo + autoSelect /> )} + + {t('fields.githubRepoPicker.owner.description')} + ); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 18da234012..7a985265d5 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -186,6 +186,10 @@ export const RepoUrlPicker = ( onChange={updateLocalState} rawErrors={rawErrors} state={state} + accessToken={ + uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey && + secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey] + } /> )} {hostType === 'gitea' && (