From 6c5a34ebf8009dbcb2d721b5f04f785205a0c693 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Wed, 10 Dec 2025 17:10:12 +0100 Subject: [PATCH 01/12] feat: add RepoOwnerPicker Signed-off-by: Benjamin Janssens --- .../src/autocomplete/autocomplete.test.ts | 27 ++ .../src/autocomplete/autocomplete.ts | 9 + plugins/scaffolder/src/alpha/extensions.tsx | 8 + .../src/alpha/fields/RepoOwnerPicker.ts | 27 ++ plugins/scaffolder/src/alpha/plugin.tsx | 2 + .../DefaultRepoOwnerPicker.test.tsx | 71 ++++ .../DefaultRepoOwnerPicker.tsx | 58 +++ .../GitHubRepoOwnerPicker.test.tsx | 155 +++++++++ .../RepoOwnerPicker/GitHubRepoOwnerPicker.tsx | 109 ++++++ .../RepoOwnerPicker/RepoOwnerPicker.test.tsx | 329 ++++++++++++++++++ .../RepoOwnerPicker/RepoOwnerPicker.tsx | 157 +++++++++ .../fields/RepoOwnerPicker/index.ts | 22 ++ .../fields/RepoOwnerPicker/schema.ts | 86 +++++ .../fields/RepoOwnerPicker/types.ts | 31 ++ plugins/scaffolder/src/extensions/default.ts | 9 + plugins/scaffolder/src/index.ts | 1 + plugins/scaffolder/src/plugin.tsx | 17 + 17 files changed, 1118 insertions(+) create mode 100644 plugins/scaffolder/src/alpha/fields/RepoOwnerPicker.ts create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/index.ts create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/schema.ts create mode 100644 plugins/scaffolder/src/components/fields/RepoOwnerPicker/types.ts diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts index 9b9aaff9c2..8076a4de01 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts @@ -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, diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts index 35d6b4f930..fb3a2c6976 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -64,6 +64,15 @@ export function createHandleAutocompleteRequest(options: { return { results }; } + case 'owners': { + const branches = await client.paginate( + client.rest.orgs.listForAuthenticatedUser, + ); + + const results = branches.map(r => ({ id: r.login })); + + return { results }; + } default: throw new InputError(`Invalid resource: ${resource}`); } diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 429e6d493a..5215fc0968 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -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({ diff --git a/plugins/scaffolder/src/alpha/fields/RepoOwnerPicker.ts b/plugins/scaffolder/src/alpha/fields/RepoOwnerPicker.ts new file mode 100644 index 0000000000..059d71fb83 --- /dev/null +++ b/plugins/scaffolder/src/alpha/fields/RepoOwnerPicker.ts @@ -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, +}); diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 5bb744e2ea..502aab0bbc 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -35,6 +35,7 @@ import { ownedEntityPickerFormField, ownerPickerFormField, repoBranchPickerFormField, + repoOwnerPickerFormField, repoUrlPickerFormField, scaffolderApi, scaffolderNavItem, @@ -88,5 +89,6 @@ export default createFrontendPlugin({ myGroupsPickerFormField, ownedEntityPickerFormField, repoBranchPickerFormField, + repoOwnerPickerFormField, ], }); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx new file mode 100644 index 0000000000..6bb84569b2 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx @@ -0,0 +1,71 @@ +/* + * 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, render, screen } from '@testing-library/react'; + +import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker'; + +describe('DefaultRepoOwnerPicker', () => { + it('renders an input field', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox')).toHaveValue('owner1'); + }); + + it('input field disabled', () => { + render( + , + ); + + 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', () => { + const onChange = jest.fn(); + + const { getByRole } = render( + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { + target: { value: 'owner2' }, + }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx new file mode 100644 index 0000000000..e97574499c --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx @@ -0,0 +1,58 @@ +/* + * 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 { BaseRepoOwnerPickerProps } from './types'; + +/** + * 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; + + return ( + 0 && !owner} + > + onChange({ owner: e.target.value })} + value={owner} + /> + + {schema?.description ?? 'The owner of the repository'} + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx new file mode 100644 index 0000000000..ec64e0d46e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx @@ -0,0 +1,155 @@ +/* + * 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, + render, + waitFor, + screen, +} from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; + +describe('GitHubRepoOwnerPicker', () => { + const scaffolderApiMock: Partial = { + autocomplete: jest + .fn() + .mockResolvedValue({ results: [{ id: 'owner1' }, { id: 'owner2' }] }), + }; + + it('renders an input field', () => { + const { getByRole } = render( + + + , + ); + + expect(getByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox')).toHaveValue('owner1'); + }); + + it('input field disabled', () => { + render( + + + , + ); + + 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', () => { + const onChange = jest.fn(); + + const { getByRole } = render( + + + , + ); + + 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 } = render( + + + , + ); + + // 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 } = render( + + + , + ); + + // 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(); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx new file mode 100644 index 0000000000..38955bbba1 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx @@ -0,0 +1,109 @@ +/* + * 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 { BaseRepoOwnerPickerProps } from './types'; + +/** + * 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([]); + + const scaffolderApi = useApi(scaffolderApiRef); + + 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 ( + 0 && !owner} + > + { + onChange({ owner: newValue || '' }); + }} + disabled={isDisabled} + options={availableOwners} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> + + {schema?.description ?? 'The owner of the repository'} + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx new file mode 100644 index 0000000000..5b5ef1a869 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx @@ -0,0 +1,329 @@ +/* + * 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 = { + byHost: () => ({ type: 'github' }), + }; + + let mockScmAuthApi: Partial; + + 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 } = await renderInTestApp( + + +
, + }} + onSubmit={onSubmit} + formContext={{ + formData: {}, + }} + /> + + , + ); + + 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(), + ); + }); + + it('should disable the picker when ui:disabled', async () => { + const onSubmit = jest.fn(); + + await renderInTestApp( + + + , + }} + onSubmit={onSubmit} + formContext={{ + formData: { repoUrl: 'github.com' }, + }} + /> + + , + ); + + const input = screen.getByRole('textbox'); + + expect(input).toBeDisabled(); + }); + + it('should render properly with title and description', async () => { + const { getByText } = await renderInTestApp( + + + , + }} + formContext={{ + formData: { + repoUrl: 'github.com', + }, + }} + /> + + , + ); + + 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 ?
{secret}
: null; + }; + + const { getByText } = await renderInTestApp( + + + , + }} + formContext={{ + formData: { + repoUrl: 'github.com', + }, + }} + /> + + + , + ); + + 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( + + + , + }} + formContext={{ + formData: { + repoUrl: 'gitlab.example.com', + }, + }} + /> + + , + ); + + await act(async () => { + // need to wait for the debounce to finish + await new Promise(resolve => setTimeout(resolve, 600)); + }); + + expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({ + url: 'https://gitlab.example.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 ?
{secret}
: null; + }; + + const { getByText } = await renderInTestApp( + + + , + }} + formContext={{ + formData: { + repoUrl: 'github.com', + }, + }} + /> + + + , + ); + + 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(); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx new file mode 100644 index 0000000000..f778ca6d43 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx @@ -0,0 +1,157 @@ +/* + * 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, + formContext, + required, + } = props; + const { + formData: { repoUrl }, + } = formContext; + + const [state, setState] = useState({ + 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 (repoUrl) { + const url = new URL(`https://${repoUrl}`); + + setState(prevState => ({ + ...prevState, + host: url.host, + })); + } else if (uiSchema?.['ui:options']?.host) { + const hardcodedHost = uiSchema['ui:options'].host; + setState(prevState => ({ ...prevState, host: hardcodedHost })); + } + }, [repoUrl, 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 ( + + ); + default: + return ( + + ); + } + }; + + return renderRepoOwnerPicker(); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/index.ts b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/index.ts new file mode 100644 index 0000000000..d93e5e22e5 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/schema.ts b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/schema.ts new file mode 100644 index 0000000000..7931ca1eec --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/schema.ts @@ -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; diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/types.ts b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/types.ts new file mode 100644 index 0000000000..eceed58df5 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/types.ts @@ -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 & { + onChange: (state: RepoOwnerPickerState) => void; + state: RepoOwnerPickerState; + rawErrors: string[]; + isDisabled?: boolean; + required?: boolean; + schema?: RJSFSchema; +}; diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index e77d968c80..019e118687 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -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, + }, ]; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index c5709f1080..cc89c656eb 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -31,6 +31,7 @@ export { RepoUrlPickerFieldExtension, MultiEntityPickerFieldExtension, RepoBranchPickerFieldExtension, + RepoOwnerPickerFieldExtension, ScaffolderPage, scaffolderPlugin, } from './plugin'; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index cda4ee461c..dabcb683c7 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -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, + }), +); From 2a8a10a87fcef17738cac7b054a05371038c5410 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 11 Dec 2025 14:48:40 +0100 Subject: [PATCH 02/12] feat: use translations for RepoOwnerPicker title and description fallback Signed-off-by: Benjamin Janssens --- .../fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx | 8 ++++++-- .../fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx | 8 ++++++-- .../fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx | 5 ++++- plugins/scaffolder/src/translation.ts | 4 ++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx index e97574499c..48d7da8ba8 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.tsx @@ -17,8 +17,10 @@ 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` @@ -37,6 +39,8 @@ export const DefaultRepoOwnerPicker = ({ }: BaseRepoOwnerPickerProps) => { const { owner } = state; + const { t } = useTranslationRef(scaffolderTranslationRef); + return ( onChange({ owner: e.target.value })} value={owner} /> - {schema?.description ?? 'The owner of the repository'} + {schema?.description ?? t('fields.repoOwnerPicker.description')} ); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx index 38955bbba1..9258ef312d 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.tsx @@ -22,7 +22,10 @@ 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` @@ -49,6 +52,7 @@ export const GitHubRepoOwnerPicker = ({ const [availableOwners, setAvailableOwners] = useState([]); const scaffolderApi = useApi(scaffolderApiRef); + const { t } = useTranslationRef(scaffolderTranslationRef); const updateAvailableOwners = useCallback(() => { if (!scaffolderApi.autocomplete || !accessToken || !host) { @@ -93,7 +97,7 @@ export const GitHubRepoOwnerPicker = ({ renderInput={params => ( @@ -102,7 +106,7 @@ export const GitHubRepoOwnerPicker = ({ autoSelect /> - {schema?.description ?? 'The owner of the repository'} + {schema?.description ?? t('fields.repoOwnerPicker.description')} ); diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx index 5b5ef1a869..3c02e82f6c 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx @@ -50,7 +50,7 @@ describe('RepoOwnerPicker', () => { it('should render the repo owner picker with minimal props', async () => { const onSubmit = jest.fn(); - const { getByRole } = await renderInTestApp( + const { getByRole, getByText } = await renderInTestApp( { }), expect.anything(), ); + + expect(getByText('Owner')).toBeInTheDocument(); + expect(getByText('The owner of the repository')).toBeInTheDocument(); }); it('should disable the picker when ui:disabled', async () => { diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 78ca15e2ff..efe61d5209 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -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', From 2cb930fe8ec07843a89410af95c18c75c9625480 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 11 Dec 2025 14:53:50 +0100 Subject: [PATCH 03/12] chore: update api reports Signed-off-by: Benjamin Janssens --- plugins/scaffolder/report-alpha.api.md | 17 +++++++++++++++++ plugins/scaffolder/report.api.md | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 7c0247cae9..7ffe367327 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -350,6 +350,21 @@ const _default: OverridableFrontendPlugin< field: () => Promise; }; }>; + 'scaffolder-form-field:scaffolder/repo-owner-picker': OverridableExtensionDefinition<{ + kind: 'scaffolder-form-field'; + name: 'repo-owner-picker'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + () => Promise, + 'scaffolder.form-field-loader', + {} + >; + inputs: {}; + params: { + field: () => Promise; + }; + }>; '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.'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 2e9c6be1ef..e624b157fa 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -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, From ef1596f49ae74833043633d68316eaec11c9fa1a Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 11 Dec 2025 16:59:36 +0100 Subject: [PATCH 04/12] docs: add documentation Signed-off-by: Benjamin Janssens --- .../software-templates/ui-options-examples.md | 58 +++++++++++++++++++ .../software-templates/writing-templates.md | 28 +++++++++ 2 files changed, 86 insertions(+) diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index 1a024569a6..f609133187 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -468,3 +468,61 @@ 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 +``` + +This value will be overriden if a different host is specified in a `RepoUrlPicker` field extension. + +### `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`. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 32f17f945f..681f5ae83c 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -555,6 +555,34 @@ 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 + requestUserCredentials: + secretsKey: USER_OAUTH_TOKEN +``` + +Passing the `requestUserCredentials` and `host` properties is required for autocompletion to work. Only if the template contains a `RepoUrlPicker` field extension, `host` can be omitted as it will use the host specified in the field extension. +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. From dab3d3f6427c08980c9670ec62923340f9effcfd Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 11 Dec 2025 17:06:23 +0100 Subject: [PATCH 05/12] chore: add changesets Signed-off-by: Benjamin Janssens --- .changeset/odd-mirrors-attack.md | 5 +++++ .changeset/six-baboons-sneeze.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/odd-mirrors-attack.md create mode 100644 .changeset/six-baboons-sneeze.md diff --git a/.changeset/odd-mirrors-attack.md b/.changeset/odd-mirrors-attack.md new file mode 100644 index 0000000000..6e784430bc --- /dev/null +++ b/.changeset/odd-mirrors-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': minor +--- + +Added autocompletion resource `owners` for retrieving GitHub repository owners. diff --git a/.changeset/six-baboons-sneeze.md b/.changeset/six-baboons-sneeze.md new file mode 100644 index 0000000000..d1eea466bf --- /dev/null +++ b/.changeset/six-baboons-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Added field extension `RepoOwnerPicker` for retrieving GitHub repository owners. From 7acc84623e83b47487b7fdca505a93c7c03c35f1 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Fri, 12 Dec 2025 11:21:39 +0100 Subject: [PATCH 06/12] test: use renderInTestApp instead of render Signed-off-by: Benjamin Janssens --- .../DefaultRepoOwnerPicker.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx index 6bb84569b2..2e335a44a3 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/DefaultRepoOwnerPicker.test.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker'; describe('DefaultRepoOwnerPicker', () => { - it('renders an input field', () => { - const { getByRole } = render( + it('renders an input field', async () => { + const { getByRole } = await renderInTestApp( { expect(getByRole('textbox')).toHaveValue('owner1'); }); - it('input field disabled', () => { - render( + it('input field disabled', async () => { + await renderInTestApp( { expect(input).toHaveValue('owner1'); }); - it('calls onChange when the input field changes', () => { + it('calls onChange when the input field changes', async () => { const onChange = jest.fn(); - const { getByRole } = render( + const { getByRole } = await renderInTestApp( Date: Fri, 12 Dec 2025 11:46:04 +0100 Subject: [PATCH 07/12] test: use renderInTestApp instead of render in tests for GitHubRepoOwnerPicker as well Signed-off-by: Benjamin Janssens --- .../GitHubRepoOwnerPicker.test.tsx | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx index ec64e0d46e..bfc6c76768 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/GitHubRepoOwnerPicker.test.tsx @@ -19,14 +19,8 @@ import { scaffolderApiRef, } from '@backstage/plugin-scaffolder-react'; import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker'; -import { - act, - fireEvent, - render, - waitFor, - screen, -} from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +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', () => { @@ -36,8 +30,8 @@ describe('GitHubRepoOwnerPicker', () => { .mockResolvedValue({ results: [{ id: 'owner1' }, { id: 'owner2' }] }), }; - it('renders an input field', () => { - const { getByRole } = render( + it('renders an input field', async () => { + const { getByRole } = await renderInTestApp( { expect(getByRole('textbox')).toHaveValue('owner1'); }); - it('input field disabled', () => { - render( + it('input field disabled', async () => { + await renderInTestApp( { expect(input).toHaveValue('owner1'); }); - it('calls onChange when the input field changes', () => { + it('calls onChange when the input field changes', async () => { const onChange = jest.fn(); - const { getByRole } = render( + const { getByRole } = await renderInTestApp( { it('should populate owners', async () => { const onChange = jest.fn(); - const { getByRole, getByText } = render( + const { getByRole, getByText } = await renderInTestApp( { it('should filter out excluded owners', async () => { const onChange = jest.fn(); - const { getByRole, getByText } = render( + const { getByRole, getByText } = await renderInTestApp( Date: Mon, 15 Dec 2025 15:45:20 +0100 Subject: [PATCH 08/12] Update plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts Co-authored-by: Ben Lambert Signed-off-by: Benjamin Janssens --- .../src/autocomplete/autocomplete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts index fb3a2c6976..9bc38d2fb3 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -65,7 +65,7 @@ export function createHandleAutocompleteRequest(options: { return { results }; } case 'owners': { - const branches = await client.paginate( + const orgs = await client.paginate( client.rest.orgs.listForAuthenticatedUser, ); From 5ce3294f8d2223cc1564ae177c737dffd1775c6b Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Dec 2025 15:55:53 +0100 Subject: [PATCH 09/12] chore: fix typo; use correct variable Signed-off-by: Benjamin Janssens --- docs/features/software-templates/ui-options-examples.md | 2 +- .../src/autocomplete/autocomplete.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index f609133187..bf8b2ef415 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -488,7 +488,7 @@ repoUrl: host: github.com ``` -This value will be overriden if a different host is specified in a `RepoUrlPicker` field extension. +This value will be overridden if a different host is specified in a `RepoUrlPicker` field extension. ### `excludedOwners` diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts index 9bc38d2fb3..b3efde9333 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -69,7 +69,7 @@ export function createHandleAutocompleteRequest(options: { client.rest.orgs.listForAuthenticatedUser, ); - const results = branches.map(r => ({ id: r.login })); + const results = orgs.map(r => ({ id: r.login })); return { results }; } From 49579170e9aac49f8805cb6529ce267424c62cb1 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 16 Dec 2025 09:49:35 +0100 Subject: [PATCH 10/12] refactor: remove coupling with form context Signed-off-by: Benjamin Janssens --- .../software-templates/ui-options-examples.md | 2 -- .../software-templates/writing-templates.md | 5 ++-- .../RepoOwnerPicker/RepoOwnerPicker.tsx | 29 ++++--------------- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index bf8b2ef415..845da8e60b 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -488,8 +488,6 @@ repoUrl: host: github.com ``` -This value will be overridden if a different host is specified in a `RepoUrlPicker` field extension. - ### `excludedOwners` List of owners that should be excluded from autocompletion. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 681f5ae83c..5d9fcba603 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -570,12 +570,13 @@ Similar to the repository picker, there is a picker for owners to support autoco 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. Only if the template contains a `RepoUrlPicker` field extension, `host` can be omitted as it will use the host specified in the field extension. -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). +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). diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx index f778ca6d43..bcb1a0001c 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.tsx @@ -35,19 +35,7 @@ import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker'; * @public */ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => { - const { - uiSchema, - onChange, - rawErrors, - formData, - schema, - formContext, - required, - } = props; - const { - formData: { repoUrl }, - } = formContext; - + const { uiSchema, onChange, rawErrors, formData, schema, required } = props; const [state, setState] = useState({ owner: formData || '', }); @@ -95,18 +83,11 @@ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => { ); useEffect(() => { - if (repoUrl) { - const url = new URL(`https://${repoUrl}`); - - setState(prevState => ({ - ...prevState, - host: url.host, - })); - } else if (uiSchema?.['ui:options']?.host) { - const hardcodedHost = uiSchema['ui:options'].host; - setState(prevState => ({ ...prevState, host: hardcodedHost })); + if (uiSchema?.['ui:options']?.host) { + const hostUiOption = uiSchema['ui:options'].host; + setState(prevState => ({ ...prevState, host: hostUiOption })); } - }, [repoUrl, uiSchema]); + }, [uiSchema]); useEffect(() => { onChange(owner); From cdca77209761309a4734ce57e802bedae9bfa552 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 16 Dec 2025 10:53:44 +0100 Subject: [PATCH 11/12] test: update tests Signed-off-by: Benjamin Janssens --- .../RepoOwnerPicker/RepoOwnerPicker.test.tsx | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx index 3c02e82f6c..c51d74416b 100644 --- a/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoOwnerPicker/RepoOwnerPicker.test.tsx @@ -67,9 +67,6 @@ describe('RepoOwnerPicker', () => { RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} onSubmit={onSubmit} - formContext={{ - formData: {}, - }} /> , @@ -113,9 +110,6 @@ describe('RepoOwnerPicker', () => { RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} onSubmit={onSubmit} - formContext={{ - formData: { repoUrl: 'github.com' }, - }} /> , @@ -149,11 +143,6 @@ describe('RepoOwnerPicker', () => { fields={{ RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} - formContext={{ - formData: { - repoUrl: 'github.com', - }, - }} /> , @@ -189,6 +178,7 @@ describe('RepoOwnerPicker', () => { uiSchema={{ 'ui:field': 'RepoOwnerPicker', 'ui:options': { + host: 'github.com', requestUserCredentials: { secretsKey, additionalScopes: { github: ['workflow'] }, @@ -198,11 +188,6 @@ describe('RepoOwnerPicker', () => { fields={{ RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} - formContext={{ - formData: { - repoUrl: 'github.com', - }, - }} /> @@ -243,6 +228,7 @@ describe('RepoOwnerPicker', () => { uiSchema={{ 'ui:field': 'RepoOwnerPicker', 'ui:options': { + host: 'github.com', requestUserCredentials: { secretsKey: 'testKey', }, @@ -251,11 +237,6 @@ describe('RepoOwnerPicker', () => { fields={{ RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} - formContext={{ - formData: { - repoUrl: 'gitlab.example.com', - }, - }} /> , @@ -267,7 +248,7 @@ describe('RepoOwnerPicker', () => { }); expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({ - url: 'https://gitlab.example.com', + url: 'https://github.com', additionalScope: { repoWrite: true, }, @@ -307,11 +288,6 @@ describe('RepoOwnerPicker', () => { fields={{ RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField, }} - formContext={{ - formData: { - repoUrl: 'github.com', - }, - }} /> From 7a439f2e2c93c6bc73625c0b035c04b6a8e9c84a Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 16 Dec 2025 14:59:21 +0100 Subject: [PATCH 12/12] Change version to patch for GitHub plugin Updated version from minor to patch for GitHub plugin. Signed-off-by: Ben Lambert --- .changeset/odd-mirrors-attack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/odd-mirrors-attack.md b/.changeset/odd-mirrors-attack.md index 6e784430bc..d8c81e5fb1 100644 --- a/.changeset/odd-mirrors-attack.md +++ b/.changeset/odd-mirrors-attack.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-backend-module-github': minor +'@backstage/plugin-scaffolder-backend-module-github': patch --- Added autocompletion resource `owners` for retrieving GitHub repository owners.