diff --git a/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker.tsx b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker.tsx new file mode 100644 index 0000000000..df496516af --- /dev/null +++ b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2024 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 { + scaffolderApiRef, + useTemplateSecrets, +} from '@backstage/plugin-scaffolder-react'; +import FormControl from '@material-ui/core/FormControl'; +import React, { useEffect, useMemo, useState } from 'react'; +import TextField from '@material-ui/core/TextField'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import useDebounce from 'react-use/esm/useDebounce'; +import { useApi } from '@backstage/core-plugin-api'; +import { scmAuthApiRef } from '@backstage/integration-react'; +import { BitbucketRepoBranchPickerProps } from './schema'; + +export const BitbucketRepoBranchPicker = ({ + onChange, + rawErrors, + required, + formData, + formContext: { + formData: { repoUrl }, + }, + uiSchema, +}: BitbucketRepoBranchPickerProps) => { + const { secrets, setSecrets } = useTemplateSecrets(); + + const accessToken = useMemo( + () => + uiSchema?.['ui:options']?.requestUserCredentials?.secretsKey && + secrets[uiSchema['ui:options'].requestUserCredentials.secretsKey], + [secrets, uiSchema], + ); + + const [host, setHost] = useState(); + const [workspace, setWorkspace] = useState(null); + const [repository, setRepository] = useState(null); + const [availableBranches, setAvailableBranches] = useState([]); + + const scmAuthApi = useApi(scmAuthApiRef); + const scaffolderApi = useApi(scaffolderApiRef); + + useEffect(() => { + if (repoUrl) { + const url = new URL(`https://${repoUrl}`); + setHost(url.host); + setWorkspace(url.searchParams.get('workspace')); + setRepository(url.searchParams.get('repo')); + } + }, [repoUrl]); + + 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], + ); + + useDebounce( + () => { + const updateAvailableBranches = async () => { + if ( + host === 'bitbucket.org' && + accessToken && + workspace && + repository + ) { + const result = await scaffolderApi.autocomplete( + accessToken, + 'bitbucketCloud', + 'branches', + { workspace, repository }, + ); + + setAvailableBranches(result); + } else { + setAvailableBranches([]); + } + }; + + updateAvailableBranches().catch(() => setAvailableBranches([])); + }, + 500, + [host, accessToken, workspace, repository], + ); + + return ( + 0 && !formData} + > + { + onChange(newValue || ''); + }} + options={availableBranches} + renderInput={params => ( + + )} + freeSolo + autoSelect + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/index.ts b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/index.ts new file mode 100644 index 0000000000..df9acbd8b9 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 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 { + BitbucketRepoBranchPickerFieldSchema, + type BitbucketRepoBranchPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/schema.ts b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/schema.ts new file mode 100644 index 0000000000..3e14888ed5 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/BitbucketRepoBranchPicker/schema.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2024 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const BitbucketRepoBranchPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + 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 + * `BitbucketRepoBranchPicker` field extension. + * + * @public + */ +export type BitbucketRepoBranchPickerUiOptions = + typeof BitbucketRepoBranchPickerFieldSchema.uiOptionsType; + +export type BitbucketRepoBranchPickerProps = + typeof BitbucketRepoBranchPickerFieldSchema.type; + +export const BitbucketRepoBranchPickerSchema = + BitbucketRepoBranchPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index a59f3e08e3..13a719e9f9 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -50,6 +50,8 @@ import { MultiEntityPickerSchema, validateMultiEntityPickerValidation, } from '../components/fields/MultiEntityPicker/MultiEntityPicker'; +import { BitbucketRepoBranchPicker } from '../components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker'; +import { BitbucketRepoBranchPickerSchema } from '../components/fields/BitbucketRepoBranchPicker/schema'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ { @@ -99,4 +101,9 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ schema: MultiEntityPickerSchema, validation: validateMultiEntityPickerValidation, }, + { + component: BitbucketRepoBranchPicker, + name: 'BitbucketRepoBranchPicker', + schema: BitbucketRepoBranchPickerSchema, + }, ]; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index d809ec00f9..d343311db9 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -30,6 +30,7 @@ export { MyGroupsPickerFieldExtension, RepoUrlPickerFieldExtension, MultiEntityPickerFieldExtension, + BitbucketRepoBranchPickerFieldExtension, ScaffolderPage, scaffolderPlugin, } from './plugin'; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index c053bfacd9..453e5ea63a 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -73,6 +73,8 @@ import { MyGroupsPicker, MyGroupsPickerSchema, } from './components/fields/MyGroupsPicker/MyGroupsPicker'; +import { BitbucketRepoBranchPicker } from './components/fields/BitbucketRepoBranchPicker/BitbucketRepoBranchPicker'; +import { BitbucketRepoBranchPickerSchema } from './components/fields/BitbucketRepoBranchPicker/schema'; /** * The main plugin export for the scaffolder. @@ -231,3 +233,16 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide( schema: EntityTagsPickerSchema, }), ); + +/** + * A field extension to select a branch from a repository. + * + * @public + */ +export const BitbucketRepoBranchPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: BitbucketRepoBranchPicker, + name: 'BitbucketRepoBranchPicker', + schema: BitbucketRepoBranchPickerSchema, + }), +);