feat(scaffolder): add support for autocompleting GitHub owners to scaffolder

Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
Benjamin Janssens
2025-01-31 14:30:23 +01:00
parent cfe3a7d5a5
commit 4317324e9e
4 changed files with 125 additions and 12 deletions
@@ -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<string, string>;
}): 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}`);
}
};
}
@@ -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 }),
});
},
});
},
@@ -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<string[]>([]);
// 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 (
<>
<FormControl
@@ -54,19 +86,28 @@ export const GithubRepoPicker = (
selected={owner}
items={ownerItems}
/>
<FormHelperText>
{t('fields.githubRepoPicker.owner.description')}
</FormHelperText>
</>
) : (
<TextField
id="ownerInput"
label={t('fields.githubRepoPicker.owner.inputTitle')}
onChange={e => onChange({ owner: e.target.value })}
helperText={t('fields.githubRepoPicker.owner.description')}
<Autocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
options={availableOwners}
renderInput={params => (
<TextField
{...params}
label={t('fields.githubRepoPicker.owner.inputTitle')}
required
/>
)}
freeSolo
autoSelect
/>
)}
<FormHelperText>
{t('fields.githubRepoPicker.owner.description')}
</FormHelperText>
</FormControl>
</>
);
@@ -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' && (