diff --git a/.changeset/pretty-apricots-poke.md b/.changeset/pretty-apricots-poke.md new file mode 100644 index 0000000000..97e3454fd4 --- /dev/null +++ b/.changeset/pretty-apricots-poke.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Added support for autocompletion of GitHub branches in scaffolder 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 8556fc9428..9b9aaff9c2 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.test.ts @@ -29,6 +29,7 @@ const mockOctokit = { rest: { repos: { listForAuthenticatedUser: jest.fn(), + listBranches: jest.fn(), }, }, }; @@ -67,6 +68,33 @@ describe('handleAutocompleteRequest', () => { }); }); + it('should return branches', async () => { + const handleAutocompleteRequest = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + + mockOctokit.rest.repos.listBranches.mockResolvedValue({ + data: [ + { + name: 'main', + }, + ], + }); + + const result = await handleAutocompleteRequest({ + resource: 'branches', + token: 'token', + context: { + owner: 'backstage', + repository: 'backstage', + }, + }); + + expect(result).toEqual({ + results: [{ id: 'main' }], + }); + }); + it('should throw an error for invalid resource', async () => { const handleAutocompleteRequest = createHandleAutocompleteRequest({ integrations: mockIntegrations, @@ -80,4 +108,18 @@ describe('handleAutocompleteRequest', () => { }), ).rejects.toThrow(InputError); }); + + it('should throw an error when there are missing parameters', async () => { + const handleAutocompleteRequest = createHandleAutocompleteRequest({ + integrations: mockIntegrations, + }); + + await expect( + handleAutocompleteRequest({ + token: 'token', + resource: 'branches', + context: {}, + }), + ).rejects.toThrow(InputError); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts index 08041640d9..35d6b4f930 100644 --- a/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts +++ b/plugins/scaffolder-backend-module-github/src/autocomplete/autocomplete.ts @@ -49,6 +49,21 @@ export function createHandleAutocompleteRequest(options: { return { results }; } + case 'branches': { + if (!context.owner || !context.repository) + throw new InputError( + 'Missing owner and/or repository context parameter', + ); + + const branches = await client.paginate(client.rest.repos.listBranches, { + owner: context.owner, + repo: context.repository, + }); + + const results = branches.map(r => ({ id: r.name })); + + return { results }; + } default: throw new InputError(`Invalid resource: ${resource}`); } diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.test.tsx new file mode 100644 index 0000000000..3e89b13aa7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 React from 'react'; +import { + ScaffolderApi, + scaffolderApiRef, +} from '@backstage/plugin-scaffolder-react'; +import { GitHubRepoBranchPicker } from './GitHubRepoBranchPicker'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; + +describe('GitHubRepoBranchPicker', () => { + 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('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 Autcomplete dropdown + const input = getByRole('textbox'); + await userEvent.click(input); + + // Verify that the available workspaces are shown + await waitFor(() => expect(getByText('branch1')).toBeInTheDocument()); + + // Verify that selecting an option calls onChange + await userEvent.click(getByText('branch1')); + expect(onChange).toHaveBeenCalledWith({ + branch: 'branch1', + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.tsx b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.tsx new file mode 100644 index 0000000000..e4995ceeaa --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/GitHubRepoBranchPicker.tsx @@ -0,0 +1,99 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import useDebounce from 'react-use/esm/useDebounce'; +import { BaseRepoBranchPickerProps } from './types'; + +/** + * The underlying component that is rendered in the form for the `GitHubRepoBranchPicker` + * field extension. + * + * @public + * + */ +export const GitHubRepoBranchPicker = ({ + onChange, + state, + rawErrors, + accessToken, + required, +}: BaseRepoBranchPickerProps<{ + accessToken?: string; +}>) => { + const { host, owner, repository, branch } = state; + + const [availableBranches, setAvailableBranches] = useState([]); + + const scaffolderApi = useApi(scaffolderApiRef); + + const updateAvailableBranches = useCallback(() => { + if ( + !scaffolderApi.autocomplete || + !owner || + !repository || + !accessToken || + !host + ) { + setAvailableBranches([]); + return; + } + + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'branches', + context: { host, owner, repository }, + provider: 'github', + }) + .then(({ results }) => { + setAvailableBranches(results.map(r => r.id)); + }) + .catch(() => { + setAvailableBranches([]); + }); + }, [host, owner, repository, accessToken, scaffolderApi]); + + useDebounce(updateAvailableBranches, 500, [updateAvailableBranches]); + + return ( + 0 && !branch} + > + { + onChange({ branch: newValue || '' }); + }} + options={availableBranches} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> + 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 4ca22c8f80..98b1d73b8d 100644 --- a/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoBranchPicker/RepoBranchPicker.tsx @@ -30,6 +30,7 @@ import { RepoBranchPickerProps } from './schema'; import { RepoBranchPickerState } from './types'; import { BitbucketRepoBranchPicker } from './BitbucketRepoBranchPicker'; import { DefaultRepoBranchPicker } from './DefaultRepoBranchPicker'; +import { GitHubRepoBranchPicker } from './GitHubRepoBranchPicker'; /** * The underlying component that is rendered in the form for the `RepoBranchPicker` @@ -102,6 +103,7 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => { host: url.host, workspace: url.searchParams.get('workspace') || '', repository: url.searchParams.get('repo') || '', + owner: url.searchParams.get('owner') || '', })); } }, [repoUrl]); @@ -134,6 +136,19 @@ export const RepoBranchPicker = (props: RepoBranchPickerProps) => { required={required} /> ); + case 'github': + return ( + + ); default: return ( = T & {