-
Notifications
You must be signed in to change notification settings - Fork 96
fix(x2a): bitbucket RepoUrlPicker wrapper #2768
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /* | ||
| * Copyright Red Hat, Inc. | ||
| * | ||
| * 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 type { ScmIntegration } from '@backstage/integration'; | ||
| import { wrapIntegrationsApi } from './RepoUrlPickerWithBitbucketFix'; | ||
|
|
||
| function createFakeApi(integrationsByHost: Record<string, ScmIntegration>) { | ||
| return { | ||
| list: jest.fn(() => Object.values(integrationsByHost)), | ||
| byUrl: jest.fn(), | ||
| byHost: jest.fn((host: string) => integrationsByHost[host]), | ||
| resolveUrl: jest.fn(), | ||
| resolveEditUrl: jest.fn(), | ||
| } as any; | ||
| } | ||
|
|
||
| function makeIntegration(type: string, host: string): ScmIntegration { | ||
| return { | ||
| type, | ||
| title: `${type} - ${host}`, | ||
| byUrl: jest.fn(), | ||
| resolveUrl: jest.fn(), | ||
| resolveEditUrl: jest.fn(), | ||
| } as unknown as ScmIntegration; | ||
| } | ||
|
|
||
| describe('wrapIntegrationsApi', () => { | ||
| it('remaps bitbucketCloud type to bitbucket', () => { | ||
| const integration = makeIntegration('bitbucketCloud', 'bitbucket.org'); | ||
| const api = createFakeApi({ 'bitbucket.org': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('bitbucket.org'); | ||
|
|
||
| expect(result).toBeDefined(); | ||
| expect(result!.type).toBe('bitbucket'); | ||
| }); | ||
|
|
||
| it('remaps bitbucketServer type to bitbucket', () => { | ||
| const integration = makeIntegration( | ||
| 'bitbucketServer', | ||
| 'bitbucket.mycompany.com', | ||
| ); | ||
| const api = createFakeApi({ 'bitbucket.mycompany.com': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('bitbucket.mycompany.com'); | ||
|
|
||
| expect(result).toBeDefined(); | ||
| expect(result!.type).toBe('bitbucket'); | ||
| }); | ||
|
|
||
| it('preserves non-type properties on remapped integrations', () => { | ||
| const integration = makeIntegration('bitbucketCloud', 'bitbucket.org'); | ||
| const api = createFakeApi({ 'bitbucket.org': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('bitbucket.org')!; | ||
|
|
||
| expect(result.title).toBe('bitbucketCloud - bitbucket.org'); | ||
| expect(result.resolveUrl).toBe(integration.resolveUrl); | ||
| expect(result.resolveEditUrl).toBe(integration.resolveEditUrl); | ||
| }); | ||
|
|
||
| it('does not remap github integrations', () => { | ||
| const integration = makeIntegration('github', 'github.com'); | ||
| const api = createFakeApi({ 'github.com': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('github.com'); | ||
|
|
||
| expect(result).toBeDefined(); | ||
| expect(result!.type).toBe('github'); | ||
| }); | ||
|
|
||
| it('does not remap gitlab integrations', () => { | ||
| const integration = makeIntegration('gitlab', 'gitlab.com'); | ||
| const api = createFakeApi({ 'gitlab.com': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('gitlab.com'); | ||
|
|
||
| expect(result!.type).toBe('gitlab'); | ||
| }); | ||
|
|
||
| it('returns undefined for unknown hosts', () => { | ||
| const api = createFakeApi({}); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.byHost('unknown.example.com'); | ||
|
|
||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('delegates non-byHost properties to the original api', () => { | ||
| const integration = makeIntegration('github', 'github.com'); | ||
| const api = createFakeApi({ 'github.com': integration }); | ||
| const wrapped = wrapIntegrationsApi(api); | ||
|
|
||
| const result = wrapped.list(); | ||
|
|
||
| expect(api.list).toHaveBeenCalled(); | ||
| expect(result).toEqual([integration]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| /* | ||
| * Copyright Red Hat, Inc. | ||
| * | ||
| * 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. | ||
| */ | ||
|
|
||
| // HACK: Backstage scaffolder >=1.36.1 (commit 527cf88) updated validation.ts | ||
| // to check for "bitbucketCloud"/"bitbucketServer" types, but RepoUrlPicker.tsx | ||
| // still only checks hostType === "bitbucket". Since | ||
| // integrationApi.byHost("bitbucket.org") returns a BitbucketCloudIntegration | ||
| // (type "bitbucketCloud"), the BitbucketRepoPicker (workspace/project fields) | ||
| // never renders. This wrapper fixes the type mismatch by intercepting the | ||
| // scmIntegrationsApi within the component's React context. | ||
| // | ||
| // Remove once upstream fixes RepoUrlPicker to handle the new type strings. | ||
| // See https://redhat.atlassian.net/browse/FLPATH-4033 | ||
|
|
||
| import { createElement, useMemo } from 'react'; | ||
| import { | ||
| type ApiHolder, | ||
| type ApiRef, | ||
| getComponentData, | ||
| useApi, | ||
| useApiHolder, | ||
| } from '@backstage/core-plugin-api'; | ||
| import { ApiProvider } from '@backstage/core-app-api'; | ||
| import { scmIntegrationsApiRef } from '@backstage/integration-react'; | ||
| import { | ||
| type FieldExtensionComponentProps, | ||
| type FieldExtensionOptions, | ||
| } from '@backstage/plugin-scaffolder-react'; | ||
| import { RepoUrlPickerFieldExtension } from '@backstage/plugin-scaffolder'; | ||
|
|
||
| type IntegrationsApi = | ||
| typeof scmIntegrationsApiRef extends ApiRef<infer T> ? T : never; | ||
|
|
||
| // Extract the original RepoUrlPicker component from the field extension's | ||
| // attached component data. This avoids importing from internal dist paths | ||
| // which are blocked by the package's "exports" field. | ||
| const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1'; | ||
| const fieldExtensionData = getComponentData<FieldExtensionOptions>( | ||
| createElement(RepoUrlPickerFieldExtension), | ||
| FIELD_EXTENSION_KEY, | ||
| ); | ||
|
|
||
| if (!fieldExtensionData?.component) { | ||
| throw new Error( | ||
| 'Failed to extract RepoUrlPicker component from RepoUrlPickerFieldExtension. ' + | ||
| 'The Backstage scaffolder field extension data key may have changed.', | ||
| ); | ||
| } | ||
|
|
||
| const OriginalRepoUrlPicker = fieldExtensionData.component; | ||
|
|
||
| const BITBUCKET_TYPES = new Set(['bitbucketCloud', 'bitbucketServer']); | ||
|
|
||
| /** | ||
| * Wraps the IntegrationsApi so that byHost() remaps "bitbucketCloud" and | ||
| * "bitbucketServer" to "bitbucket", making the upstream RepoUrlPicker render | ||
| * the BitbucketRepoPicker component. | ||
| */ | ||
| /** @internal Exported for testing only. */ | ||
| export function wrapIntegrationsApi(api: IntegrationsApi): IntegrationsApi { | ||
| return new Proxy(api, { | ||
| get(target, prop, receiver) { | ||
| if (prop !== 'byHost') { | ||
| return Reflect.get(target, prop, receiver); | ||
| } | ||
|
|
||
| return (host: string) => { | ||
| const integration = target.byHost(host); | ||
| if (!integration || !BITBUCKET_TYPES.has(integration.type)) { | ||
| return integration; | ||
| } | ||
|
|
||
| // TODO: Remove this type remapping once upstream decouples | ||
|
Check warning on line 86 in workspaces/x2a/plugins/x2a/src/scaffolder/RepoUrlPickerWithBitbucketFix.tsx
|
||
| // "bitbucketCloud" / "bitbucketServer" in RepoUrlPicker. | ||
| // See https://redhat.atlassian.net/browse/FLPATH-4033 | ||
| return new Proxy(integration, { | ||
| get(innerTarget, innerProp, innerReceiver) { | ||
| if (innerProp === 'type') { | ||
| return 'bitbucket'; | ||
| } | ||
|
Comment on lines
+91
to
+93
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we assume this part will change / be removed once the bug is resolved, correct? as they may decouple bitbucketCloud and bitbucketServer
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yuup |
||
| return Reflect.get(innerTarget, innerProp, innerReceiver); | ||
| }, | ||
| }); | ||
| }; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Creates an ApiHolder that intercepts scmIntegrationsApiRef to return a | ||
| * wrapped version, delegating all other API lookups to the parent holder. | ||
| */ | ||
| function createWrappedApiHolder( | ||
| parentHolder: ApiHolder, | ||
| wrappedIntegrations: IntegrationsApi, | ||
| ): ApiHolder { | ||
| return { | ||
| get<T>(ref: ApiRef<T>): T | undefined { | ||
| if (ref === scmIntegrationsApiRef) { | ||
| return wrappedIntegrations as unknown as T; | ||
| } | ||
| return parentHolder.get(ref); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Wrapper around Backstage's RepoUrlPicker that fixes the Bitbucket Cloud/Server | ||
| * type mismatch. Provides a scoped API context override so the inner | ||
| * RepoUrlPicker sees "bitbucket" as the host type and renders the | ||
| * BitbucketRepoPicker with workspace/project fields. | ||
| */ | ||
| export function RepoUrlPickerWithBitbucketFix( | ||
| props: FieldExtensionComponentProps<string>, | ||
| ) { | ||
| const parentHolder = useApiHolder(); | ||
| const integrationsApi = useApi(scmIntegrationsApiRef); | ||
|
|
||
| const wrappedHolder = useMemo( | ||
| () => | ||
| createWrappedApiHolder( | ||
| parentHolder, | ||
| wrapIntegrationsApi(integrationsApi), | ||
| ), | ||
| [parentHolder, integrationsApi], | ||
| ); | ||
|
|
||
| return ( | ||
| <ApiProvider apis={wrappedHolder}> | ||
| <OriginalRepoUrlPicker | ||
| {...(props as FieldExtensionComponentProps<unknown>)} | ||
| /> | ||
| </ApiProvider> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A brief comment that this is a workaround for BitBucker will be helpful in the future.