Skip to content

Commit d8c241a

Browse files
authored
fix(x2a): bitbucket RepoUrlPicker wrapper (redhat-developer#2768)
* fix(x2a): bitbucket RepoUrlPicker wrapper This is a wrapper around RepoUrlPicker that it's a hotfix for the following issue which gives enough context: backstage/backstage#33887 Fix FLPATH-3887 Signed-off-by: Eloy Coto <eloy.coto@acalustra.com> * feat: adding test and link to jira issues Signed-off-by: Eloy Coto <eloy.coto@acalustra.com> --------- Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
1 parent 1f6770f commit d8c241a

File tree

10 files changed

+314
-5
lines changed

10 files changed

+314
-5
lines changed

workspaces/x2a/packages/app/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
X2APage,
5858
x2aPluginTranslations,
5959
RepoAuthenticationExtension,
60+
X2ARepoUrlPickerExtension,
6061
} from '@red-hat-developer-hub/backstage-plugin-x2a';
6162
import {
6263
bitbucketAuthApiRef,
@@ -160,6 +161,7 @@ const routes = (
160161
<Route path="/create" element={<ScaffolderPage />}>
161162
<ScaffolderFieldExtensions>
162163
<RepoAuthenticationExtension />
164+
<X2ARepoUrlPickerExtension />
163165
</ScaffolderFieldExtensions>
164166
</Route>
165167
</FlatRoutes>

workspaces/x2a/plugins/scaffolder-backend-module-x2a/templates/conversion-project-template.yaml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ spec:
123123
description: |
124124
The Owner should be your SCM (Source Code Management) username. The repository name should be a name that already exists in your SCM account and contains a Chef cookbook or directory to convert.
125125
type: string
126-
ui:field: RepoUrlPicker
126+
# HACK: X2ARepoUrlPicker wraps the default RepoUrlPicker to
127+
# fix a Bitbucket Cloud/Server type mismatch in Backstage
128+
# scaffolder. Replace with RepoUrlPicker once upstream is fixed.
129+
# See https://redhat.atlassian.net/browse/FLPATH-4033
130+
ui:field: X2ARepoUrlPicker
127131
ui:options:
128132
requestUserCredentials:
129133
secretsKey: SRC_USER_OAUTH_TOKEN
@@ -177,7 +181,11 @@ spec:
177181
description: |
178182
The Owner should be your SCM (Source Code Management) username. The repository name should be a name that already exists in your SCM account. It will be populated by converted Ansible sources and intermediary artifacts.
179183
type: string
180-
ui:field: RepoUrlPicker
184+
# HACK: X2ARepoUrlPicker wraps the default RepoUrlPicker to
185+
# fix a Bitbucket Cloud/Server type mismatch in Backstage
186+
# scaffolder. Replace with RepoUrlPicker once upstream is fixed.
187+
# See https://redhat.atlassian.net/browse/FLPATH-4033
188+
ui:field: X2ARepoUrlPicker
181189
ui:options:
182190
requestUserCredentials:
183191
secretsKey: TGT_USER_OAUTH_TOKEN

workspaces/x2a/plugins/x2a/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@
3636
},
3737
"dependencies": {
3838
"@backstage/catalog-model": "^1.7.6",
39+
"@backstage/core-app-api": "^1.19.2",
3940
"@backstage/core-components": "^0.18.3",
4041
"@backstage/core-plugin-api": "^1.12.0",
42+
"@backstage/integration-react": "^1.2.12",
4143
"@backstage/plugin-catalog": "^1.32.0",
4244
"@backstage/plugin-permission-react": "^0.4.38",
45+
"@backstage/plugin-scaffolder": "^1.34.3",
4346
"@backstage/plugin-scaffolder-react": "^1.19.3",
4447
"@backstage/theme": "^0.7.0",
4548
"@backstage/ui": "^0.9.1",
@@ -60,7 +63,6 @@
6063
"devDependencies": {
6164
"@backstage/cli": "^0.34.5",
6265
"@backstage/config": "^1.3.6",
63-
"@backstage/core-app-api": "^1.19.2",
6466
"@backstage/dev-utils": "^1.1.17",
6567
"@backstage/frontend-plugin-api": "^0.14.1",
6668
"@backstage/test-utils": "^1.7.13",

workspaces/x2a/plugins/x2a/report.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ readonly empty: string;
222222
// @public
223223
export const x2aPluginTranslations: TranslationResource<"plugin.x2a">;
224224

225+
// @public
226+
export const X2ARepoUrlPickerExtension: FieldExtensionComponent<string, {}>;
227+
225228
// (No @packageDocumentation comment for this package)
226229

227230
```

workspaces/x2a/plugins/x2a/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
export { x2APlugin, X2APage, RepoAuthenticationExtension } from './plugin';
16+
export {
17+
x2APlugin,
18+
X2APage,
19+
RepoAuthenticationExtension,
20+
X2ARepoUrlPickerExtension,
21+
} from './plugin';
1722
export { x2aPluginTranslations, x2aPluginTranslationRef } from './translations';
1823
export {
1924
useTranslation as useX2ATranslation,

workspaces/x2a/plugins/x2a/src/plugin.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import {
2020
import { createScaffolderFieldExtension } from '@backstage/plugin-scaffolder-react';
2121

2222
import { rootRouteRef } from './routes';
23-
import { RepoAuthentication, repoAuthenticationValidation } from './scaffolder';
23+
import {
24+
RepoAuthentication,
25+
repoAuthenticationValidation,
26+
RepoUrlPickerWithBitbucketFix,
27+
} from './scaffolder';
28+
import { repoPickerValidation } from '@backstage/plugin-scaffolder';
2429

2530
/** @public */
2631
export const x2APlugin = createPlugin({
@@ -47,3 +52,18 @@ export const RepoAuthenticationExtension = x2APlugin.provide(
4752
validation: repoAuthenticationValidation,
4853
}),
4954
);
55+
56+
/**
57+
* Scaffolder field extension that wraps the built-in RepoUrlPicker with a fix
58+
* for Bitbucket Cloud/Server type resolution. Use `ui:field: X2ARepoUrlPicker`
59+
* in templates instead of `RepoUrlPicker` when bitbucket.org is an allowed host.
60+
*
61+
* @public
62+
*/
63+
export const X2ARepoUrlPickerExtension = x2APlugin.provide(
64+
createScaffolderFieldExtension({
65+
component: RepoUrlPickerWithBitbucketFix,
66+
name: 'X2ARepoUrlPicker',
67+
validation: repoPickerValidation,
68+
}),
69+
);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { ScmIntegration } from '@backstage/integration';
18+
import { wrapIntegrationsApi } from './RepoUrlPickerWithBitbucketFix';
19+
20+
function createFakeApi(integrationsByHost: Record<string, ScmIntegration>) {
21+
return {
22+
list: jest.fn(() => Object.values(integrationsByHost)),
23+
byUrl: jest.fn(),
24+
byHost: jest.fn((host: string) => integrationsByHost[host]),
25+
resolveUrl: jest.fn(),
26+
resolveEditUrl: jest.fn(),
27+
} as any;
28+
}
29+
30+
function makeIntegration(type: string, host: string): ScmIntegration {
31+
return {
32+
type,
33+
title: `${type} - ${host}`,
34+
byUrl: jest.fn(),
35+
resolveUrl: jest.fn(),
36+
resolveEditUrl: jest.fn(),
37+
} as unknown as ScmIntegration;
38+
}
39+
40+
describe('wrapIntegrationsApi', () => {
41+
it('remaps bitbucketCloud type to bitbucket', () => {
42+
const integration = makeIntegration('bitbucketCloud', 'bitbucket.org');
43+
const api = createFakeApi({ 'bitbucket.org': integration });
44+
const wrapped = wrapIntegrationsApi(api);
45+
46+
const result = wrapped.byHost('bitbucket.org');
47+
48+
expect(result).toBeDefined();
49+
expect(result!.type).toBe('bitbucket');
50+
});
51+
52+
it('remaps bitbucketServer type to bitbucket', () => {
53+
const integration = makeIntegration(
54+
'bitbucketServer',
55+
'bitbucket.mycompany.com',
56+
);
57+
const api = createFakeApi({ 'bitbucket.mycompany.com': integration });
58+
const wrapped = wrapIntegrationsApi(api);
59+
60+
const result = wrapped.byHost('bitbucket.mycompany.com');
61+
62+
expect(result).toBeDefined();
63+
expect(result!.type).toBe('bitbucket');
64+
});
65+
66+
it('preserves non-type properties on remapped integrations', () => {
67+
const integration = makeIntegration('bitbucketCloud', 'bitbucket.org');
68+
const api = createFakeApi({ 'bitbucket.org': integration });
69+
const wrapped = wrapIntegrationsApi(api);
70+
71+
const result = wrapped.byHost('bitbucket.org')!;
72+
73+
expect(result.title).toBe('bitbucketCloud - bitbucket.org');
74+
expect(result.resolveUrl).toBe(integration.resolveUrl);
75+
expect(result.resolveEditUrl).toBe(integration.resolveEditUrl);
76+
});
77+
78+
it('does not remap github integrations', () => {
79+
const integration = makeIntegration('github', 'github.com');
80+
const api = createFakeApi({ 'github.com': integration });
81+
const wrapped = wrapIntegrationsApi(api);
82+
83+
const result = wrapped.byHost('github.com');
84+
85+
expect(result).toBeDefined();
86+
expect(result!.type).toBe('github');
87+
});
88+
89+
it('does not remap gitlab integrations', () => {
90+
const integration = makeIntegration('gitlab', 'gitlab.com');
91+
const api = createFakeApi({ 'gitlab.com': integration });
92+
const wrapped = wrapIntegrationsApi(api);
93+
94+
const result = wrapped.byHost('gitlab.com');
95+
96+
expect(result!.type).toBe('gitlab');
97+
});
98+
99+
it('returns undefined for unknown hosts', () => {
100+
const api = createFakeApi({});
101+
const wrapped = wrapIntegrationsApi(api);
102+
103+
const result = wrapped.byHost('unknown.example.com');
104+
105+
expect(result).toBeUndefined();
106+
});
107+
108+
it('delegates non-byHost properties to the original api', () => {
109+
const integration = makeIntegration('github', 'github.com');
110+
const api = createFakeApi({ 'github.com': integration });
111+
const wrapped = wrapIntegrationsApi(api);
112+
113+
const result = wrapped.list();
114+
115+
expect(api.list).toHaveBeenCalled();
116+
expect(result).toEqual([integration]);
117+
});
118+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// HACK: Backstage scaffolder >=1.36.1 (commit 527cf88) updated validation.ts
18+
// to check for "bitbucketCloud"/"bitbucketServer" types, but RepoUrlPicker.tsx
19+
// still only checks hostType === "bitbucket". Since
20+
// integrationApi.byHost("bitbucket.org") returns a BitbucketCloudIntegration
21+
// (type "bitbucketCloud"), the BitbucketRepoPicker (workspace/project fields)
22+
// never renders. This wrapper fixes the type mismatch by intercepting the
23+
// scmIntegrationsApi within the component's React context.
24+
//
25+
// Remove once upstream fixes RepoUrlPicker to handle the new type strings.
26+
// See https://redhat.atlassian.net/browse/FLPATH-4033
27+
28+
import { createElement, useMemo } from 'react';
29+
import {
30+
type ApiHolder,
31+
type ApiRef,
32+
getComponentData,
33+
useApi,
34+
useApiHolder,
35+
} from '@backstage/core-plugin-api';
36+
import { ApiProvider } from '@backstage/core-app-api';
37+
import { scmIntegrationsApiRef } from '@backstage/integration-react';
38+
import {
39+
type FieldExtensionComponentProps,
40+
type FieldExtensionOptions,
41+
} from '@backstage/plugin-scaffolder-react';
42+
import { RepoUrlPickerFieldExtension } from '@backstage/plugin-scaffolder';
43+
44+
type IntegrationsApi =
45+
typeof scmIntegrationsApiRef extends ApiRef<infer T> ? T : never;
46+
47+
// Extract the original RepoUrlPicker component from the field extension's
48+
// attached component data. This avoids importing from internal dist paths
49+
// which are blocked by the package's "exports" field.
50+
const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1';
51+
const fieldExtensionData = getComponentData<FieldExtensionOptions>(
52+
createElement(RepoUrlPickerFieldExtension),
53+
FIELD_EXTENSION_KEY,
54+
);
55+
56+
if (!fieldExtensionData?.component) {
57+
throw new Error(
58+
'Failed to extract RepoUrlPicker component from RepoUrlPickerFieldExtension. ' +
59+
'The Backstage scaffolder field extension data key may have changed.',
60+
);
61+
}
62+
63+
const OriginalRepoUrlPicker = fieldExtensionData.component;
64+
65+
const BITBUCKET_TYPES = new Set(['bitbucketCloud', 'bitbucketServer']);
66+
67+
/**
68+
* Wraps the IntegrationsApi so that byHost() remaps "bitbucketCloud" and
69+
* "bitbucketServer" to "bitbucket", making the upstream RepoUrlPicker render
70+
* the BitbucketRepoPicker component.
71+
*/
72+
/** @internal Exported for testing only. */
73+
export function wrapIntegrationsApi(api: IntegrationsApi): IntegrationsApi {
74+
return new Proxy(api, {
75+
get(target, prop, receiver) {
76+
if (prop !== 'byHost') {
77+
return Reflect.get(target, prop, receiver);
78+
}
79+
80+
return (host: string) => {
81+
const integration = target.byHost(host);
82+
if (!integration || !BITBUCKET_TYPES.has(integration.type)) {
83+
return integration;
84+
}
85+
86+
// TODO: Remove this type remapping once upstream decouples
87+
// "bitbucketCloud" / "bitbucketServer" in RepoUrlPicker.
88+
// See https://redhat.atlassian.net/browse/FLPATH-4033
89+
return new Proxy(integration, {
90+
get(innerTarget, innerProp, innerReceiver) {
91+
if (innerProp === 'type') {
92+
return 'bitbucket';
93+
}
94+
return Reflect.get(innerTarget, innerProp, innerReceiver);
95+
},
96+
});
97+
};
98+
},
99+
});
100+
}
101+
102+
/**
103+
* Creates an ApiHolder that intercepts scmIntegrationsApiRef to return a
104+
* wrapped version, delegating all other API lookups to the parent holder.
105+
*/
106+
function createWrappedApiHolder(
107+
parentHolder: ApiHolder,
108+
wrappedIntegrations: IntegrationsApi,
109+
): ApiHolder {
110+
return {
111+
get<T>(ref: ApiRef<T>): T | undefined {
112+
if (ref === scmIntegrationsApiRef) {
113+
return wrappedIntegrations as unknown as T;
114+
}
115+
return parentHolder.get(ref);
116+
},
117+
};
118+
}
119+
120+
/**
121+
* Wrapper around Backstage's RepoUrlPicker that fixes the Bitbucket Cloud/Server
122+
* type mismatch. Provides a scoped API context override so the inner
123+
* RepoUrlPicker sees "bitbucket" as the host type and renders the
124+
* BitbucketRepoPicker with workspace/project fields.
125+
*/
126+
export function RepoUrlPickerWithBitbucketFix(
127+
props: FieldExtensionComponentProps<string>,
128+
) {
129+
const parentHolder = useApiHolder();
130+
const integrationsApi = useApi(scmIntegrationsApiRef);
131+
132+
const wrappedHolder = useMemo(
133+
() =>
134+
createWrappedApiHolder(
135+
parentHolder,
136+
wrapIntegrationsApi(integrationsApi),
137+
),
138+
[parentHolder, integrationsApi],
139+
);
140+
141+
return (
142+
<ApiProvider apis={wrappedHolder}>
143+
<OriginalRepoUrlPicker
144+
{...(props as FieldExtensionComponentProps<unknown>)}
145+
/>
146+
</ApiProvider>
147+
);
148+
}

workspaces/x2a/plugins/x2a/src/scaffolder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
* See the License for the specific language governing permissions and limitations under the License.
1414
*/
1515
export * from './RepoAuthentication';
16+
export { RepoUrlPickerWithBitbucketFix } from './RepoUrlPickerWithBitbucketFix';

0 commit comments

Comments
 (0)