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(
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+ ,
+ );
+
+ const input = screen.getByRole('textbox');
+
+ expect(input).toBeDisabled();
+ });
+
+ it('should render properly with title and description', async () => {
+ const { getByText } = await renderInTestApp(
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+ ,
+ );
+
+ 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,
+ }),
+);