diff --git a/.changeset/gitlab-branch-autocomplete.md b/.changeset/gitlab-branch-autocomplete.md new file mode 100644 index 00000000000000..28a20f6d448691 --- /dev/null +++ b/.changeset/gitlab-branch-autocomplete.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added GitLab branch autocompletion to RepoBranchPicker using user SCM credentials. diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index b85d9d3c9be372..af629c1edf7968 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -551,6 +551,11 @@ repoUrl: The input props that can be specified under `ui:options` for the `RepoBranchPicker` field extension. +GitLab branch autocompletion requires the GitLab Scaffolder backend module and +`requestUserCredentials`. The repository must be selected in a field named +`repoUrl`. Nested GitLab namespaces are supported. Manual branch entry remains +available when autocompletion is unavailable. + ### `requestUserCredentials` If defined will request user credentials to auth against the given SCM platform. diff --git a/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.test.ts b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.test.ts index a2e2f18ab210b0..989c6e5937424d 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.test.ts @@ -27,6 +27,7 @@ jest.mock('../util', () => ({ describe('handleAutocompleteRequest', () => { const mockIntegrations = {} as ScmIntegrationRegistry; const mockClient = { + Branches: { all: jest.fn() }, Groups: { all: jest.fn(), allProjects: jest.fn(), @@ -122,6 +123,78 @@ describe('handleAutocompleteRequest', () => { }); }); + it('returns branches for a nested namespace using the supplied credentials', async () => { + const handle = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + mockClient.Branches.all.mockResolvedValue([ + { name: 'main' }, + { name: 'feature/example' }, + ]); + await expect( + handle({ + resource: 'branches', + token: 'user-token', + context: { + host: 'gitlab.example.com', + owner: 'group/subgroup', + repository: 'repo', + }, + }), + ).resolves.toEqual({ + results: [ + { title: 'main', id: 'main' }, + { title: 'feature/example', id: 'feature/example' }, + ], + }); + expect(mockGetClient).toHaveBeenCalledWith({ + host: 'gitlab.example.com', + integrations: mockIntegrations, + token: 'user-token', + }); + expect(mockClient.Branches.all).toHaveBeenCalledWith('group/subgroup/repo'); + mockClient.Branches.all.mockResolvedValue([]); + await expect( + handle({ + resource: 'branches', + token: 'user-token', + context: { owner: 'group', repository: 'empty' }, + }), + ).resolves.toEqual({ results: [] }); + mockClient.Branches.all.mockRejectedValue(new Error('Forbidden')); + await expect( + handle({ + resource: 'branches', + token: 'user-token', + context: { owner: 'group', repository: 'private' }, + }), + ).rejects.toThrow('Forbidden'); + }); + + it('rejects missing branch context and credentials without listing branches', async () => { + const handle = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + const missingContexts: Record[] = [ + {}, + { owner: 'group' }, + { repository: 'repo' }, + ]; + for (const context of missingContexts) { + await expect( + handle({ resource: 'branches', token: 'user-token', context }), + ).rejects.toThrow(InputError); + } + await expect( + handle({ + resource: 'branches', + token: '', + context: { owner: 'group', repository: 'repo' }, + }), + ).rejects.toThrow('Missing user credentials'); + expect(mockClient.Branches.all).not.toHaveBeenCalled(); + }); + it('should throw an error for invalid resource', async () => { const handleAutocompleteRequest = createHandleAutocompleteRequest({ integrations: mockIntegrations, diff --git a/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts index 4eddd844e50017..ebc1bf63f22c34 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/autocomplete/autocomplete.ts @@ -81,6 +81,27 @@ export function createHandleAutocompleteRequest(options: { return result; } + case 'branches': { + if (!context.owner || !context.repository) { + throw new InputError('Missing owner or repository context parameter'); + } + if (!token) { + throw new InputError( + 'Missing user credentials for branch autocomplete', + ); + } + + const branches = await client.Branches.all( + `${context.owner}/${context.repository}`, + ); + + return { + results: branches.map(branch => ({ + title: branch.name, + id: branch.name, + })), + }; + } case 'repositories': { if (!context.id) throw new InputError('Missing groupId and userId context parameter'); diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.test.tsx new file mode 100644 index 00000000000000..6cbcfa71b7b1c7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.test.tsx @@ -0,0 +1,282 @@ +/* + * Copyright 2026 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 { GitLabRepoBranchPicker } from './GitLabRepoBranchPicker'; +import { + act, + fireEvent, + render, + waitFor, + screen, +} from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import { RepoBranchPickerState } from './types'; + +describe('GitLabRepoBranchPicker', () => { + const scaffolderApiMock: Partial = { + autocomplete: jest.fn().mockResolvedValue({ results: [{ id: 'branch1' }] }), + }; + + it('renders an input field', () => { + const { getByRole } = render( + + + , + ); + + expect(getByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox')).toHaveValue('main'); + }); + + it('input field disabled', () => { + render( + + + , + ); + + const input = screen.getByRole('textbox'); + + // Expect input to be disabled + expect(input).toBeDisabled(); + expect(input).toHaveValue('main'); + }); + + 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: 'develop' }, + }); + input.blur(); + }); + + expect(onChange).toHaveBeenCalledWith({ branch: 'develop' }); + }); + + it('should populate branches', 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 workspaces are shown + await waitFor(() => expect(getByText('branch1')).toBeInTheDocument()); + + expect(scaffolderApiMock.autocomplete).toHaveBeenCalledWith({ + token: 'token', + resource: 'branches', + provider: 'gitlab', + context: { host: 'gitlab.example.com', owner: 'foo', repository: 'bar' }, + }); + + // Verify that selecting an option calls onChange + await userEvent.click(getByText('branch1')); + expect(onChange).toHaveBeenCalledWith({ + branch: 'branch1', + }); + }); + it('shows a read failure rather than implying that the repository has no branches', async () => { + const autocomplete = jest.fn().mockRejectedValue(new Error('Forbidden')); + render( + + + , + ); + expect( + await screen.findByText('Unable to load repository branches'), + ).toBeInTheDocument(); + }); + + describe('obsolete requests', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it.each(['repository', 'credentials'])( + 'ignores out-of-order responses after changing %s', + async change => { + type Response = Awaited>; + const requests: { + resolve: (response: Response) => void; + reject: (error: Error) => void; + }[] = []; + const autocomplete = jest.fn().mockImplementation( + () => + new Promise((resolve, reject) => { + requests.push({ resolve, reject }); + }), + ); + const api = { autocomplete }; + const initialState: RepoBranchPickerState = { + host: 'gitlab.example.com', + owner: 'group/subgroup', + repository: 'first', + branch: '', + }; + const picker = ( + state = initialState, + accessToken: string | undefined = 'first-token', + ) => ( + + + + ); + const { rerender } = render(picker()); + act(() => jest.advanceTimersByTime(500)); + expect(autocomplete).toHaveBeenCalledTimes(1); + + const nextState = + change === 'repository' + ? { ...initialState, repository: 'second' } + : initialState; + const nextToken = + change === 'credentials' ? 'second-token' : 'first-token'; + rerender(picker(nextState, nextToken)); + act(() => jest.advanceTimersByTime(500)); + expect(autocomplete).toHaveBeenLastCalledWith({ + provider: 'gitlab', + resource: 'branches', + token: nextToken, + context: { + host: nextState.host, + owner: nextState.owner, + repository: nextState.repository, + }, + }); + + await act(async () => + requests[1].resolve({ + results: [{ id: 'current', title: 'current' }], + }), + ); + fireEvent.mouseDown(screen.getByRole('textbox')); + expect( + screen.getByRole('option', { name: 'current' }), + ).toBeInTheDocument(); + await act(async () => + requests[0].resolve({ + results: [{ id: 'obsolete', title: 'obsolete' }], + }), + ); + expect( + screen.getByRole('option', { name: 'current' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', { name: 'obsolete' }), + ).not.toBeInTheDocument(); + + // Invalidate an in-flight request before the next debounce fires. + rerender(picker(initialState, 'third-token')); + act(() => jest.advanceTimersByTime(500)); + rerender(picker(nextState, 'fourth-token')); + await act(async () => + requests[2].reject(new Error('Obsolete failure')), + ); + expect( + screen.queryByText('Unable to load repository branches'), + ).not.toBeInTheDocument(); + act(() => jest.advanceTimersByTime(500)); + await act(async () => requests[3].reject(new Error('Current failure'))); + expect( + screen.getByText('Unable to load repository branches'), + ).toBeInTheDocument(); + + // Removing required context clears the error and ignores pending results. + rerender(picker(nextState, 'fifth-token')); + expect( + screen.queryByText('Unable to load repository branches'), + ).not.toBeInTheDocument(); + act(() => jest.advanceTimersByTime(500)); + rerender( + picker( + change === 'repository' + ? { ...nextState, repository: undefined } + : nextState, + change === 'credentials' ? '' : 'fifth-token', + ), + ); + await act(async () => + requests[4].resolve({ + results: [{ id: 'obsolete', title: 'obsolete' }], + }), + ); + act(() => jest.advanceTimersByTime(500)); + expect(autocomplete).toHaveBeenCalledTimes(5); + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + }, + ); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.tsx new file mode 100644 index 00000000000000..bcb6a99ddebc98 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitLabRepoBranchPicker.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 2026 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 { useEffect, useState } from 'react'; +import { BaseRepoBranchPickerProps } from './types'; +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 `GitLabRepoBranchPicker` + * field extension. + * + * @internal + * + */ +export const GitLabRepoBranchPicker = ({ + onChange, + state, + rawErrors, + accessToken, + isDisabled, + required, +}: BaseRepoBranchPickerProps<{ + accessToken?: string; +}>) => { + const theme = useScaffolderTheme(); + const { host, owner, repository, branch } = state; + + const [availableBranches, setAvailableBranches] = useState([]); + const [loadError, setLoadError] = useState(false); + + const scaffolderApi = useApi(scaffolderApiRef); + + useEffect(() => { + let isCurrent = true; + setAvailableBranches([]); + setLoadError(false); + + if ( + !scaffolderApi.autocomplete || + !owner || + !repository || + !accessToken || + !host + ) { + return undefined; + } + + const timeout = setTimeout(() => { + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'branches', + context: { host, owner, repository }, + provider: 'gitlab', + }) + .then(({ results }) => { + if (isCurrent) { + setAvailableBranches(results.map(r => r.id)); + } + }) + .catch(() => { + if (isCurrent) { + setAvailableBranches([]); + setLoadError(true); + } + }); + }, 500); + + return () => { + isCurrent = false; + clearTimeout(timeout); + }; + }, [host, owner, repository, accessToken, scaffolderApi]); + + if (theme === 'bui') { + const options = availableBranches.map(b => ({ label: b, value: b })); + + return ( + onChange({ branch: value })} + onSelectionChange={(key: Key | null) => { + if (key !== null) { + onChange({ branch: String(key) }); + } + }} + options={options} + isDisabled={isDisabled} + isRequired={required} + isInvalid={rawErrors?.length > 0 && !branch} + /> + ); + } + + return ( + 0 && !branch} + > + { + onChange({ branch: newValue || '' }); + }} + disabled={isDisabled} + options={availableBranches} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> + + {loadError + ? 'Unable to load repository branches' + : 'The branch of the repository'} + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx index 4a29c399532b9f..75003f88b0d926 100644 --- a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx @@ -31,6 +31,7 @@ import { RepoBranchPickerState } from './types'; import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker'; import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker'; import { GitHubRepoBranchPicker } from './GitHubRepoBranchPicker'; +import { GitLabRepoBranchPicker } from './GitLabRepoBranchPicker'; import { MarkdownContent } from '@backstage/core-components'; import { useScaffolderTheme } from '@backstage/plugin-scaffolder-react/alpha'; import { Flex, Text } from '@backstage/ui'; @@ -156,6 +157,17 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => { required={required} /> ); + case 'gitlab': + return ( + + ); default: return (