Merge pull request #33690 from asheen1234/gitlab-repo-owner-picker

Add GitLabRepoOwnerPicker for RepoOwnerPicker
This commit is contained in:
Andre Wanlin
2026-05-25 09:00:36 -05:00
committed by GitHub
5 changed files with 535 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Extended the `RepoOwnerPicker` implementation with a custom variant for GitLab.
@@ -0,0 +1,166 @@
/*
* 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 { GitLabRepoOwnerPicker } from './GitLabRepoOwnerPicker';
import { act, fireEvent, waitFor, screen } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
describe('GitLabRepoOwnerPicker', () => {
const scaffolderApiMock: Partial<ScaffolderApi> = {
autocomplete: jest.fn().mockResolvedValue({
results: [{ title: 'owner1' }, { title: 'owner2' }],
}),
};
it('renders an input field', async () => {
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitLabRepoOwnerPicker
onChange={jest.fn()}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
expect(getByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox')).toHaveValue('owner1');
});
it('input field disabled', async () => {
await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitLabRepoOwnerPicker
onChange={jest.fn()}
isDisabled
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
const input = screen.getByRole('textbox');
// Expect input to be disabled
expect(input).toBeDisabled();
expect(input).toHaveValue('owner1');
});
it('calls onChange when the input field changes', async () => {
const onChange = jest.fn();
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitLabRepoOwnerPicker
onChange={onChange}
state={{ owner: 'owner1' }}
rawErrors={[]}
/>
</TestApiProvider>,
);
const input = getByRole('textbox');
act(() => {
input.focus();
fireEvent.change(input, {
target: { value: 'owner2' },
});
input.blur();
});
expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' });
});
it('should populate owners', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
});
const onChange = jest.fn();
try {
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitLabRepoOwnerPicker
onChange={onChange}
state={{
host: 'gitlab.com',
owner: 'foo',
}}
rawErrors={[]}
accessToken="token"
/>
</TestApiProvider>,
);
// Open the Autocomplete dropdown
const input = getByRole('textbox');
await user.click(input);
// Flush the component debounce and any pending async updates
act(() => {
jest.advanceTimersByTime(500);
});
await act(async () => {
await Promise.resolve();
});
// Verify that the available owners are shown
expect(await screen.findByText('owner1')).toBeInTheDocument();
// Verify that selecting an option calls onChange
await user.click(screen.getByText('owner1'));
expect(onChange).toHaveBeenCalledWith({
owner: 'owner1',
});
} finally {
jest.runOnlyPendingTimers();
jest.useRealTimers();
}
});
it('should filter out excluded owners', async () => {
const onChange = jest.fn();
const { getByRole, getByText } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<GitLabRepoOwnerPicker
onChange={onChange}
state={{
host: 'gitlab.com',
}}
rawErrors={[]}
accessToken="token"
excludedOwners={['owner1']}
/>
</TestApiProvider>,
);
// Open the Autocomplete dropdown
const input = getByRole('textbox');
await userEvent.click(input);
// Verify that the excluded owners are not shown
await waitFor(() => expect(getByText('owner2')).toBeInTheDocument());
expect(screen.queryByText('owner1')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
/*
* 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 MuiTextField from '@material-ui/core/TextField';
import MuiAutocomplete 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';
import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha';
import { Autocomplete as BuiAutocomplete } from '../Autocomplete';
import type { Key } from 'react-aria-components';
/**
* The underlying component that is rendered in the form for the `GitLabRepoOwnerPicker`
* field extension.
*
* @public
*
*/
export const GitLabRepoOwnerPicker = ({
onChange,
state,
rawErrors,
accessToken,
isDisabled,
required,
schema,
excludedOwners = [],
}: BaseRepoOwnerPickerProps<{
accessToken?: string;
excludedOwners?: string[];
}>) => {
const theme = useScaffolderTheme();
const { host, owner } = state;
const [availableOwners, setAvailableOwners] = useState<string[]>([]);
const scaffolderApi = useApi(scaffolderApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const updateAvailableOwners = useCallback(() => {
if (!scaffolderApi.autocomplete || !accessToken || !host) {
setAvailableOwners([]);
return;
}
scaffolderApi
.autocomplete({
token: accessToken,
resource: 'groups',
provider: 'gitlab',
context: { host },
})
.then(({ results }) => {
const owners = results
.map(r => r.title!)
.filter(title => !excludedOwners.includes(title));
setAvailableOwners(owners);
})
.catch(() => {
setAvailableOwners([]);
});
}, [host, accessToken, scaffolderApi, excludedOwners]);
useDebounce(updateAvailableOwners, 500, [updateAvailableOwners]);
if (theme === 'bui') {
const options = availableOwners.map(o => ({ label: o, value: o }));
return (
<BuiAutocomplete
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
description={
schema?.description ?? t('fields.repoOwnerPicker.description')
}
inputValue={owner ?? ''}
onInputChange={value => onChange({ owner: value })}
onSelectionChange={(key: Key | null) => {
if (key !== null) {
onChange({ owner: String(key) });
}
}}
options={options}
isDisabled={isDisabled}
isRequired={required}
isInvalid={rawErrors?.length > 0 && !owner}
/>
);
}
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !owner}
>
<MuiAutocomplete
value={owner}
onChange={(_, newValue) => {
onChange({ owner: newValue || '' });
}}
disabled={isDisabled}
options={availableOwners}
renderInput={params => (
<MuiTextField
{...params}
label={schema?.title ?? t('fields.repoOwnerPicker.title')}
disabled={isDisabled}
required={required}
/>
)}
freeSolo
autoSelect
/>
<FormHelperText>
{schema?.description ?? t('fields.repoOwnerPicker.description')}
</FormHelperText>
</FormControl>
);
};
@@ -29,6 +29,7 @@ import {
scaffolderApiRef,
useTemplateSecrets,
ScaffolderRJSFField,
ScaffolderApi,
} from '@backstage/plugin-scaffolder-react';
import { act, fireEvent, screen } from '@testing-library/react';
import { RepoOwnerPicker } from './RepoOwnerPicker';
@@ -38,6 +39,10 @@ describe('RepoOwnerPicker', () => {
byHost: () => ({ type: 'github' }),
};
const mockIntegrationsApiGitLab: Partial<ScmIntegrationsApi> = {
byHost: () => ({ type: 'gitlab' }),
};
let mockScmAuthApi: Partial<ScmAuthApi>;
beforeEach(() => {
@@ -305,4 +310,204 @@ describe('RepoOwnerPicker', () => {
expect(getByText('abc123')).toBeInTheDocument();
});
});
describe('requestUserCredentialsGitLab', () => {
it('should call the scmAuthApi with the correct params', async () => {
const secretsKey = 'testKey';
const SecretsComponent = () => {
const { secrets } = useTemplateSecrets();
const secret = secrets[secretsKey];
return secret ? <div>{secret}</div> : null;
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApiGitLab],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'gitlab.com',
requestUserCredentials: {
secretsKey,
additionalScopes: { gitlab: ['workflow'] },
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
url: 'https://gitlab.com',
additionalScope: {
repoWrite: true,
customScopes: {
gitlab: ['workflow'],
},
},
});
expect(getByText('abc123')).toBeInTheDocument();
});
it('should call the scmAuthApi with the correct params if workspace is nested', async () => {
await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApiGitLab],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'gitlab.com',
requestUserCredentials: {
secretsKey: 'testKey',
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({
url: 'https://gitlab.com',
additionalScope: {
repoWrite: true,
},
});
});
it('should not call the scmAuthApi if secret is available in the state', async () => {
const secretsKey = 'testKey';
const SecretsComponent = () => {
const { secrets } = useTemplateSecrets();
const secret = secrets[secretsKey];
return secret ? <div>{secret}</div> : null;
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApiGitLab],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, {}],
]}
>
<SecretsContextProvider initialSecrets={{ [secretsKey]: 'abc123' }}>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'gitlab.com',
requestUserCredentials: {
secretsKey,
additionalScopes: { gitlab: ['workflow'] },
},
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// need to wait for the debounce to finish
await new Promise(resolve => setTimeout(resolve, 600));
});
// as we already have a secret in the state, getCredentials should not be called again.
expect(mockScmAuthApi.getCredentials).toHaveBeenCalledTimes(0);
expect(getByText('abc123')).toBeInTheDocument();
});
it('should route to GitLabRepoOwnerPicker and call autocomplete with gitlab provider and groups resource', async () => {
const secretsKey = 'testKey';
const mockScaffolderApi: Partial<ScaffolderApi> = {
autocomplete: jest.fn().mockResolvedValue({ results: [] }),
};
await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApiGitLab],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, mockScaffolderApi],
]}
>
<SecretsContextProvider initialSecrets={{ [secretsKey]: 'abc123' }}>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoOwnerPicker',
'ui:options': {
host: 'gitlab.com',
requestUserCredentials: { secretsKey },
},
}}
fields={{
RepoOwnerPicker: RepoOwnerPicker as ScaffolderRJSFField<string>,
}}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
await act(async () => {
// wait for both the RepoOwnerPicker and GitLabRepoOwnerPicker debounces
await new Promise(resolve => setTimeout(resolve, 600));
});
expect(mockScaffolderApi.autocomplete).toHaveBeenCalledWith({
token: 'abc123',
resource: 'groups',
provider: 'gitlab',
context: { host: 'gitlab.com' },
});
});
});
});
@@ -27,6 +27,8 @@ import { RepoOwnerPickerProps } from './schema';
import { RepoOwnerPickerState } from './types';
import { DefaultRepoOwnerPicker } from './DefaultRepoOwnerPicker';
import { GitHubRepoOwnerPicker } from './GitHubRepoOwnerPicker';
import { GitLabRepoOwnerPicker } from './GitLabRepoOwnerPicker';
/**
* The underlying component that is rendered in the form for the `RepoOwnerPicker`
* field extension.
@@ -118,6 +120,22 @@ export const RepoOwnerPicker = (props: RepoOwnerPickerProps) => {
excludedOwners={excludedOwners}
/>
);
case 'gitlab':
return (
<GitLabRepoOwnerPicker
onChange={updateLocalState}
state={state}
rawErrors={rawErrors}
accessToken={
uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey &&
secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey]
}
isDisabled={uiSchema?.['ui:disabled'] ?? false}
required={required}
schema={schema}
excludedOwners={excludedOwners}
/>
);
default:
return (
<DefaultRepoOwnerPicker