Merge pull request #32105 from benjidotsh/scaffolder/repo-owner-picker

feat(scaffolder): add RepoOwnerPicker
This commit is contained in:
Ben Lambert
2025-12-16 15:00:10 +01:00
committed by GitHub
24 changed files with 1221 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Added autocompletion resource `owners` for retrieving GitHub repository owners.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Added field extension `RepoOwnerPicker` for retrieving GitHub repository owners.
@@ -468,3 +468,59 @@ repoUrl:
The supported `additionalScopes` values are `gerrit`, `github`, `gitlab`, `bitbucket`, and `azure`.
If you're also using the `RepoUrlPicker` field extension, you should simply duplicate this part from there.
## RepoOwnerPicker
The input props that can be specified under `ui:options` for the `RepoOwnerPicker` field extension.
### `host`
The SCM integration host that owners should be fetched from for autocompletion.
- Fetch owners from `github.com`
```yaml
repoUrl:
title: Repository Owner
type: string
ui:field: RepoOwnerPicker
ui:options:
host: github.com
```
### `excludedOwners`
List of owners that should be excluded from autocompletion.
- Exclude owner `owner_1` from autocompletion
```yaml
repoUrl:
title: Repository Owner
type: string
ui:field: RepoOwnerPicker
ui:options:
excludedOwners:
- owner_1
```
### `requestUserCredentials`
If defined will request user credentials to auth against the given SCM platform.
```yaml
repoUrl:
title: Repository Owner
type: string
ui:field: RepoOwnerPicker
ui:options:
requestUserCredentials:
secretsKey: USER_OAUTH_TOKEN
additionalScopes:
github:
- workflow:write
```
`secretsKey` is the key used within the template secrets context to store the credential and `additionalScopes` is any additional permission scopes to request.
The supported `additionalScopes` values are `gerrit`, `github`, `gitlab`, `bitbucket`, and `azure`.
@@ -555,6 +555,35 @@ The `RepoBranchPicker` is a custom field that we provide part of the
`plugin-scaffolder`. You can provide your own custom fields by
[writing your own Custom Field Extensions](./writing-custom-field-extensions.md)
### The Repository Owner Picker
Similar to the repository picker, there is a picker for owners to support autocompletion. A full example could look like this:
```yaml
- title: Choose an owner
required:
- repoOwner
properties:
repoOwner:
title: Repository Owner
type: string
ui:field: RepoOwnerPicker
ui:options:
host: github.com
excludedOwners:
- backstage
requestUserCredentials:
secretsKey: USER_OAUTH_TOKEN
```
Passing the `requestUserCredentials` and `host` properties is required for autocompletion to work. For more information regarding the `requestUserCredentials` object, please refer to the [Using the Users `oauth` token](#using-the-users-oauth-token) section under [The Repository Picker](#the-repository-picker).
For a list of all possible `ui:options` input props for `RepoOwnerPicker`, please visit [here](./ui-options-examples.md#repoownerpicker).
The `RepoOwnerPicker` is a custom field that we provide part of the
`plugin-scaffolder`. You can provide your own custom fields by
[writing your own Custom Field Extensions](./writing-custom-field-extensions.md)
### Accessing the signed-in users details
Sometimes when authoring templates, you'll want to access the user that is running the template, and get details from the profile or the users `Entity` in the Catalog.
@@ -31,6 +31,9 @@ const mockOctokit = {
listForAuthenticatedUser: jest.fn(),
listBranches: jest.fn(),
},
orgs: {
listForAuthenticatedUser: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
@@ -95,6 +98,30 @@ describe('handleAutocompleteRequest', () => {
});
});
it('should return owners', async () => {
const handleAutocompleteRequest = createHandleAutocompleteRequest({
integrations: mockIntegrations,
});
mockOctokit.rest.orgs.listForAuthenticatedUser.mockResolvedValue({
data: [
{
login: 'backstage',
},
],
});
const result = await handleAutocompleteRequest({
resource: 'owners',
token: 'token',
context: {},
});
expect(result).toEqual({
results: [{ id: 'backstage' }],
});
});
it('should throw an error for invalid resource', async () => {
const handleAutocompleteRequest = createHandleAutocompleteRequest({
integrations: mockIntegrations,
@@ -64,6 +64,15 @@ export function createHandleAutocompleteRequest(options: {
return { results };
}
case 'owners': {
const orgs = await client.paginate(
client.rest.orgs.listForAuthenticatedUser,
);
const results = orgs.map(r => ({ id: r.login }));
return { results };
}
default:
throw new InputError(`Invalid resource: ${resource}`);
}
+17
View File
@@ -350,6 +350,21 @@ const _default: OverridableFrontendPlugin<
field: () => Promise<FormField>;
};
}>;
'scaffolder-form-field:scaffolder/repo-owner-picker': OverridableExtensionDefinition<{
kind: 'scaffolder-form-field';
name: 'repo-owner-picker';
config: {};
configInput: {};
output: ExtensionDataRef<
() => Promise<FormField>,
'scaffolder.form-field-loader',
{}
>;
inputs: {};
params: {
field: () => Promise<FormField>;
};
}>;
'scaffolder-form-field:scaffolder/repo-url-picker': OverridableExtensionDefinition<{
kind: 'scaffolder-form-field';
name: 'repo-url-picker';
@@ -499,6 +514,8 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'fields.repoUrlPicker.repository.title': 'Repositories Available';
readonly 'fields.repoUrlPicker.repository.description': 'The name of the repository';
readonly 'fields.repoUrlPicker.repository.inputTitle': 'Repository';
readonly 'fields.repoOwnerPicker.title': 'Owner';
readonly 'fields.repoOwnerPicker.description': 'The owner of the repository';
readonly 'aboutCard.launchTemplate': 'Launch Template';
readonly 'actionsPage.content.emptyState.title': 'No information to display';
readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.';
+24
View File
@@ -391,6 +391,30 @@ export const RepoBranchPickerFieldExtension: FieldExtensionComponent_2<
}
>;
// @public
export const RepoOwnerPickerFieldExtension: FieldExtensionComponent_2<
string,
{
host?: string | undefined;
requestUserCredentials?:
| {
secretsKey: string;
additionalScopes?:
| {
azure?: string[] | undefined;
github?: string[] | undefined;
gitlab?: string[] | undefined;
bitbucket?: string[] | undefined;
gerrit?: string[] | undefined;
gitea?: string[] | undefined;
}
| undefined;
}
| undefined;
excludedOwners?: string[] | undefined;
}
>;
// @public
export const repoPickerValidation: (
value: string,
@@ -127,6 +127,14 @@ export const repoBranchPickerFormField = FormFieldBlueprint.make({
},
});
export const repoOwnerPickerFormField = FormFieldBlueprint.make({
name: 'repo-owner-picker',
params: {
field: () =>
import('./fields/RepoOwnerPicker').then(m => m.RepoOwnerPicker),
},
});
export const scaffolderApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
@@ -0,0 +1,27 @@
/*
* Copyright 2025 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 { createFormField } from '@backstage/plugin-scaffolder-react/alpha';
import {
RepoOwnerPicker as Component,
RepoOwnerPickerFieldSchema,
} from '../../components/fields/RepoOwnerPicker';
export const RepoOwnerPicker = createFormField({
component: Component,
name: 'RepoOwnerPicker',
schema: RepoOwnerPickerFieldSchema,
});
+2
View File
@@ -35,6 +35,7 @@ import {
ownedEntityPickerFormField,
ownerPickerFormField,
repoBranchPickerFormField,
repoOwnerPickerFormField,
repoUrlPickerFormField,
scaffolderApi,
scaffolderNavItem,
@@ -88,5 +89,6 @@ export default createFrontendPlugin({
myGroupsPickerFormField,
ownedEntityPickerFormField,
repoBranchPickerFormField,
repoOwnerPickerFormField,
],
});
@@ -0,0 +1,72 @@
/*
* Copyright 2025 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 { fireEvent, screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
describe('DefaultRepoOwnerPicker', () => {
it('renders an input field', async () => {
const { getByRole } = await renderInTestApp(
<DefaultRepoOwnerPicker
onChange={jest.fn()}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>,
);
expect(getByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox')).toHaveValue('owner1');
});
it('input field disabled', async () => {
await renderInTestApp(
<DefaultRepoOwnerPicker
onChange={jest.fn()}
isDisabled
state={{ owner: 'owner1' }}
rawErrors={[]}
/>,
);
const input = screen.getByRole('textbox');
// Expect input to be disabled
expect(input).toBeDisabled();
expect(input).toHaveValue('owner1');
});
it('calls onChange when the input field changes', async () => {
const onChange = jest.fn();
const { getByRole } = await renderInTestApp(
<DefaultRepoOwnerPicker
onChange={onChange}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>,
);
const input = getByRole('textbox');
fireEvent.change(input, {
target: { value: 'owner2' },
});
expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' });
});
});
@@ -0,0 +1,62 @@
/*
* Copyright 2025 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 FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import TextField from '@material-ui/core/TextField';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { BaseRepoOwnerPickerProps } from './types';
import { scaffolderTranslationRef } from '../../../translation';
/**
* The underlying component that is rendered in the form for the `DefaultRepoOwnerPicker`
* field extension.
*
* @public
*
*/
export const DefaultRepoOwnerPicker = ({
onChange,
state,
rawErrors,
isDisabled,
required,
schema,
}: BaseRepoOwnerPickerProps) => {
const { owner } = state;
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<TextField
id="ownerInput"
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
disabled={isDisabled}
onChange={e => onChange({ owner: e.target.value })}
value={owner}
/>
<FormHelperText>
{schema?.description ?? t('fields.repoOwnerPicker.description')}
</FormHelperText>
</FormControl>
);
};
@@ -0,0 +1,149 @@
/*
* Copyright 2025 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 {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
import { act, fireEvent, waitFor, screen } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
describe('GitHubRepoOwnerPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest
.fn()
.mockResolvedValue({ results: [{ id: 'owner1' }, { id: 'owner2' }] }),
};
it('renders an input field', async () => {
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitHubRepoOwnerPicker
onChange={jest.fn()}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
expect(getByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox')).toHaveValue('owner1');
});
it('input field disabled', async () => {
await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitHubRepoOwnerPicker
onChange={jest.fn()}
isDisabled
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
const input = screen.getByRole('textbox');
// Expect input to be disabled
expect(input).toBeDisabled();
expect(input).toHaveValue('owner1');
});
it('calls onChange when the input field changes', async () => {
const onChange = jest.fn();
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitHubRepoOwnerPicker
onChange={onChange}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
const input = getByRole('textbox');
act(() => {
input.focus();
fireEvent.change(input, {
target: { value: 'owner2' },
});
input.blur();
});
expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' });
});
it('should populate owners', async () => {
const onChange = jest.fn();
const { getByRole, getByText } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitHubRepoOwnerPicker
onChange={onChange}
state={{
host: 'github.com',
owner: 'foo',
}}
rawErrors={[]}
accessToken="token"
/>
</TestApiProvider>,
);
// Open the Autocomplete dropdown
const input = getByRole('textbox');
await userEvent.click(input);
// Verify that the available owners are shown
await waitFor(() => expect(getByText('owner1')).toBeInTheDocument());
// Verify that selecting an option calls onChange
await userEvent.click(getByText('owner1'));
expect(onChange).toHaveBeenCalledWith({
owner: 'owner1',
});
});
it('should filter out excluded owners', async () => {
const onChange = jest.fn();
const { getByRole, getByText } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitHubRepoOwnerPicker
onChange={onChange}
state={{
host: 'github.com',
}}
rawErrors={[]}
accessToken="token"
excludedOwners={['owner1']}
/>
</TestApiProvider>,
);
// Open the Autocomplete dropdown
const input = getByRole('textbox');
await userEvent.click(input);
// Verify that the excluded owners are not shown
await waitFor(() => expect(getByText('owner2')).toBeInTheDocument());
expect(screen.queryByText('owner1')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
/*
* Copyright 2025 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 { useApi } from '@backstage/core-plugin-api';
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 Autocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { BaseRepoOwnerPickerProps } from './types';
import { scaffolderTranslationRef } from '../../../translation';
/**
* The underlying component that is rendered in the form for the `GitHubRepoOwnerPicker`
* field extension.
*
* @public
*
*/
export const GitHubRepoOwnerPicker = ({
onChange,
state,
rawErrors,
accessToken,
isDisabled,
required,
schema,
excludedOwners = [],
}: BaseRepoOwnerPickerProps<{
accessToken?: string;
excludedOwners?: string[];
}>) => {
const { host, owner } = state;
const [availableOwners, setAvailableOwners] = useState<string[]>([]);
const scaffolderApi = useApi(scaffolderApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const updateAvailableOwners = useCallback(() => {
if (!scaffolderApi.autocomplete || !accessToken || !host) {
setAvailableOwners([]);
return;
}
scaffolderApi
.autocomplete({
token: accessToken,
resource: 'owners',
context: { host },
provider: 'github',
})
.then(({ results }) => {
const owners = results
.map(r => r.id)
.filter(id => !excludedOwners.includes(id));
setAvailableOwners(owners);
})
.catch(() => {
setAvailableOwners([]);
});
}, [host, accessToken, scaffolderApi, excludedOwners]);
useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]);
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<Autocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
disabled={isDisabled}
options={availableOwners}
renderInput={params => (
<TextField
{...params}
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
disabled={isDisabled}
required={required}
/>
)}
freeSolo
autoSelect
/>
<FormHelperText>
{schema?.description ?? t('fields.repoOwnerPicker.description')}
</FormHelperText>
</FormControl>
);
};
@@ -0,0 +1,308 @@
/*
* Copyright 2025 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 { Form } from '@backstage/plugin-scaffolder-react/alpha';
import validator from '@rjsf/validator-ajv8';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
scmIntegrationsApiRef,
ScmIntegrationsApi,
scmAuthApiRef,
ScmAuthApi,
} from '@backstage/integration-react';
import {
SecretsContextProvider,
scaffolderApiRef,
useTemplateSecrets,
ScaffolderRJSFField,
} from '@backstage/plugin-scaffolder-react';
import { act, fireEvent, screen } from '@testing-library/react';
import { RepoOwnerPicker } from './RepoOwnerPicker';
describe('RepoOwnerPicker', () => {
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
byHost: () => ({ type: 'github' }),
};
let mockScmAuthApi: Partial<ScmAuthApi>;
beforeEach(() => {
mockScmAuthApi = {
getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }),
};
});
describe('happy path rendering', () => {
it('should render the repo owner picker with minimal props', async () => {
const onSubmit = jest.fn();
const { getByRole, getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{ 'ui:field': 'RepoOwnerPicker' }}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
onSubmit={onSubmit}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
const input = getByRole('textbox');
const submitButton = getByRole('button');
fireEvent.change(input, { target: { value: 'owner1' } });
fireEvent.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
formData: 'owner1',
}),
expect.anything(),
);
expect(getByText('Owner')).toBeInTheDocument();
expect(getByText('The owner of the repository')).toBeInTheDocument();
});
it('should disable the picker when ui:disabled', async () => {
const onSubmit = jest.fn();
await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{ 'ui:field': 'RepoOwnerPicker', 'ui:disabled': true }}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
onSubmit={onSubmit}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
it('should render properly with title and description', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{
type: 'string',
title: 'test title',
description: 'test description',
}}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
expect(getByText('test title')).toBeInTheDocument();
expect(getByText('test description')).toBeInTheDocument();
});
});
describe('requestUserCredentials', () => {
it('should call the scmAuthApi with the correct params', async () => {
const secretsKey = 'testKey';
const SecretsComponent = () => {
const { secrets } = useTemplateSecrets();
const secret = secrets[secretsKey];
return secret ? <div>{secret}</div> : null;
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'github.com',
requestUserCredentials: {
secretsKey,
additionalScopes: { github: ['workflow'] },
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
url: 'https://github.com',
additionalScope: {
repoWrite: true,
customScopes: {
github: ['workflow'],
},
},
});
expect(getByText('abc123')).toBeInTheDocument();
});
it('should call the scmAuthApi with the correct params if workspace is nested', async () => {
await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'github.com',
requestUserCredentials: {
secretsKey: 'testKey',
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
url: 'https://github.com',
additionalScope: {
repoWrite: true,
},
});
});
it('should not call the scmAuthApi if secret is available in the state', async () => {
const secretsKey = 'testKey';
const SecretsComponent = () => {
const { secrets } = useTemplateSecrets();
const secret = secrets[secretsKey];
return secret ? <div>{secret}</div> : null;
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider initialSecrets={{ [secretsKey]: 'abc123' }}>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
requestUserCredentials: {
secretsKey,
additionalScopes: { github: ['workflow'] },
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
// as we already have a secret in the state, getCredentials should not be called again.
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledTimes(0);
expect(getByText('abc123')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,138 @@
/*
* Copyright 2025 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 { useApi } from '@backstage/core-plugin-api';
import {
scmIntegrationsApiRef,
scmAuthApiRef,
} from '@backstage/integration-react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { useTemplateSecrets } from '@backstage/plugin-scaffolder-react';
import { RepoOwnerPickerProps } from './schema';
import { RepoOwnerPickerState } from './types';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
/**
* The underlying component that is rendered in the form for the `RepoOwnerPicker`
* field extension.
*
* @public
*/
export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => {
const { uiSchema, onChange, rawErrors, formData, schema, required } = props;
const [state, setState] = useState<RepoOwnerPickerState>({
owner: formData || '',
});
const excludedOwners = useMemo(
() => uiSchema?.['ui:options']?.excludedOwners ?? [],
[uiSchema],
);
const { host, owner } = state;
const integrationApi = useApi(scmIntegrationsApiRef);
const scmAuthApi = useApi(scmAuthApiRef);
const { secrets, setSecrets } = useTemplateSecrets();
useDebounce(
async () => {
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
if (!requestUserCredentials || !host) {
return;
}
// don't show login prompt if secret value is already in state
if (secrets[requestUserCredentials.secretsKey]) {
return;
}
// user has requested that we use the users credentials
// so lets grab them using the scmAuthApi and pass through
// any additional scopes from the ui:options
const { token } = await scmAuthApi.getCredentials({
url: `https://${host}`,
additionalScope: {
repoWrite: true,
customScopes: requestUserCredentials.additionalScopes,
},
});
// set the secret using the key provided in the ui:options for use
// in the templating the manifest with ${{ secrets[secretsKey] }}
setSecrets({ [requestUserCredentials.secretsKey]: token });
},
500,
[host, uiSchema],
);
useEffect(() => {
if (uiSchema?.['ui:options']?.host) {
const hostUiOption = uiSchema['ui:options'].host;
setState(prevState => ({ ...prevState, host: hostUiOption }));
}
}, [uiSchema]);
useEffect(() => {
onChange(owner);
}, [owner, onChange]);
const updateLocalState = useCallback(
(newState: RepoOwnerPickerState) => {
setState(prevState => ({ ...prevState, ...newState }));
},
[setState],
);
const hostType = (host && integrationApi.byHost(host)?.type) ?? null;
const renderRepoOwnerPicker = () => {
switch (hostType) {
case 'github':
return (
<GitHubRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
excludedOwners={excludedOwners}
/>
);
default:
return (
<DefaultRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
/>
);
}
};
return renderRepoOwnerPicker();
};
@@ -0,0 +1,22 @@
/*
* Copyright 2025 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.
*/
export { RepoOwnerPicker } from './RepoOwnerPicker';
export {
RepoOwnerPickerFieldSchema,
type RepoOwnerPickerUiOptions,
RepoOwnerPickerSchema,
} from './schema';
@@ -0,0 +1,86 @@
/*
* Copyright 2025 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 { makeFieldSchema } from '@backstage/plugin-scaffolder-react';
export const RepoOwnerPickerFieldSchema = makeFieldSchema({
output: z => z.string(),
uiOptions: z =>
z.object({
host: z.string().optional(),
excludedOwners: z
.array(z.string())
.optional()
.describe('List of excluded owners'),
requestUserCredentials: z
.object({
secretsKey: z
.string()
.describe(
'Key used within the template secrets context to store the credential',
),
additionalScopes: z
.object({
gitea: z
.array(z.string())
.optional()
.describe('Additional Gitea scopes to request'),
gerrit: z
.array(z.string())
.optional()
.describe('Additional Gerrit scopes to request'),
github: z
.array(z.string())
.optional()
.describe('Additional GitHub scopes to request'),
gitlab: z
.array(z.string())
.optional()
.describe('Additional GitLab scopes to request'),
bitbucket: z
.array(z.string())
.optional()
.describe('Additional BitBucket scopes to request'),
azure: z
.array(z.string())
.optional()
.describe('Additional Azure scopes to request'),
})
.optional()
.describe('Additional permission scopes to request'),
})
.optional()
.describe(
'If defined will request user credentials to auth against the given SCM platform',
),
}),
});
/**
* The input props that can be specified under `ui:options` for the
* `RepoOwnerPicker` field extension.
*/
export type RepoOwnerPickerUiOptions = NonNullable<
(typeof RepoOwnerPickerFieldSchema.TProps.uiSchema)['ui:options']
>;
export type RepoOwnerPickerProps = typeof RepoOwnerPickerFieldSchema.TProps;
// This has been duplicated from /plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts
// NOTE: There is a bug with this failing validation in the custom field explorer due
// to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if
// requestUserCredentials is not defined
export const RepoOwnerPickerSchema = RepoOwnerPickerFieldSchema.schema;
@@ -0,0 +1,31 @@
/*
* Copyright 2025 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 { RJSFSchema } from '@rjsf/utils';
export interface RepoOwnerPickerState {
host?: string;
owner?: string;
}
export type BaseRepoOwnerPickerProps<T extends {} = {}> = T & {
onChange: (state: RepoOwnerPickerState) => void;
state: RepoOwnerPickerState;
rawErrors: string[];
isDisabled?: boolean;
required?: boolean;
schema?: RJSFSchema;
};
@@ -52,6 +52,10 @@ import {
} from '../components/fields/MultiEntityPicker/MultiEntityPicker';
import { RepoBranchPicker } from '../components/fields/RepoBranchPicker/RepoBranchPicker';
import { RepoBranchPickerSchema } from '../components/fields/RepoBranchPicker/schema';
import {
RepoOwnerPicker,
RepoOwnerPickerSchema,
} from '../components/fields/RepoOwnerPicker';
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
{
@@ -106,4 +110,9 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
name: 'RepoBranchPicker',
schema: RepoBranchPickerSchema,
},
{
component: RepoOwnerPicker,
name: 'RepoOwnerPicker',
schema: RepoOwnerPickerSchema,
},
];
+1
View File
@@ -31,6 +31,7 @@ export {
RepoUrlPickerFieldExtension,
MultiEntityPickerFieldExtension,
RepoBranchPickerFieldExtension,
RepoOwnerPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
} from './plugin';
+17
View File
@@ -82,6 +82,10 @@ import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/sch
import { formDecoratorsApiRef } from './alpha/api/ref';
import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi';
import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
import {
RepoOwnerPicker,
RepoOwnerPickerSchema,
} from './components/fields/RepoOwnerPicker';
/**
* The main plugin export for the scaffolder.
@@ -267,3 +271,16 @@ export const RepoBranchPickerFieldExtension = scaffolderPlugin.provide(
schema: RepoBranchPickerSchema,
}),
);
/**
* A field extension to select an owner from a repository.
*
* @public
*/
export const RepoOwnerPickerFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
component: RepoOwnerPicker,
name: 'RepoOwnerPicker',
schema: RepoOwnerPickerSchema,
}),
);
+4
View File
@@ -137,6 +137,10 @@ export const scaffolderTranslationRef = createTranslationRef({
description: 'The name of the repository',
},
},
repoOwnerPicker: {
title: 'Owner',
description: 'The owner of the repository',
},
},
listTaskPage: {
title: 'List template tasks',