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, + }), +);