Skip to content

Commit eb6fe09

Browse files
feat(bitbucket): Add frontend pipeline step for Bitbucket integration setup (#112418)
Register the Bitbucket integration in the pipeline registry with a single authorize step. Uses `useRedirectPopupStep` to open the Bitbucket addon authorization page in a popup, then captures the JWT from the callback and posts it to advance the pipeline. Ref [VDY-41](https://linear.app/getsentry/issue/VDY-41/bitbucket-api-driven-integration-setup)
1 parent 2a7ebf3 commit eb6fe09

File tree

3 files changed

+252
-0
lines changed

3 files changed

+252
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket';
4+
import type {PipelineStepProps} from './types';
5+
6+
const BitbucketAuthorizeStep = bitbucketIntegrationPipeline.steps[0].component;
7+
8+
function makeStepProps<D, A>(
9+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
10+
): PipelineStepProps<D, A> {
11+
return {
12+
advance: jest.fn(),
13+
advanceError: null,
14+
isAdvancing: false,
15+
stepIndex: 0,
16+
totalSteps: 1,
17+
...overrides,
18+
};
19+
}
20+
21+
let mockPopup: Window;
22+
23+
function dispatchPipelineMessage({
24+
data,
25+
origin = document.location.origin,
26+
source = mockPopup,
27+
}: {
28+
data: Record<string, string>;
29+
origin?: string;
30+
source?: Window | MessageEventSource | null;
31+
}) {
32+
act(() => {
33+
const event = new MessageEvent('message', {data, origin});
34+
Object.defineProperty(event, 'source', {value: source});
35+
window.dispatchEvent(event);
36+
});
37+
}
38+
39+
beforeEach(() => {
40+
mockPopup = {
41+
closed: false,
42+
close: jest.fn(),
43+
focus: jest.fn(),
44+
} as unknown as Window;
45+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
46+
});
47+
48+
afterEach(() => {
49+
jest.restoreAllMocks();
50+
});
51+
52+
describe('BitbucketAuthorizeStep', () => {
53+
it('renders the authorize button', () => {
54+
render(
55+
<BitbucketAuthorizeStep
56+
{...makeStepProps({
57+
stepData: {
58+
authorizeUrl:
59+
'https://bitbucket.org/site/addons/authorize?descriptor_uri=test',
60+
},
61+
})}
62+
/>
63+
);
64+
65+
expect(screen.getByRole('button', {name: 'Authorize Bitbucket'})).toBeInTheDocument();
66+
});
67+
68+
it('calls advance with JWT on callback', async () => {
69+
const advance = jest.fn();
70+
render(
71+
<BitbucketAuthorizeStep
72+
{...makeStepProps({
73+
stepData: {
74+
authorizeUrl:
75+
'https://bitbucket.org/site/addons/authorize?descriptor_uri=test',
76+
},
77+
advance,
78+
})}
79+
/>
80+
);
81+
82+
await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'}));
83+
84+
dispatchPipelineMessage({
85+
data: {
86+
_pipeline_source: 'sentry-pipeline',
87+
jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test',
88+
},
89+
});
90+
91+
expect(advance).toHaveBeenCalledWith({
92+
jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test',
93+
});
94+
});
95+
96+
it('shows reopen button when waiting for callback', async () => {
97+
render(
98+
<BitbucketAuthorizeStep
99+
{...makeStepProps({
100+
stepData: {
101+
authorizeUrl:
102+
'https://bitbucket.org/site/addons/authorize?descriptor_uri=test',
103+
},
104+
})}
105+
/>
106+
);
107+
108+
await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'}));
109+
110+
expect(
111+
screen.getByRole('button', {name: 'Reopen authorization window'})
112+
).toBeInTheDocument();
113+
});
114+
115+
it('shows loading state when isAdvancing is true', () => {
116+
render(
117+
<BitbucketAuthorizeStep
118+
{...makeStepProps({
119+
stepData: {
120+
authorizeUrl:
121+
'https://bitbucket.org/site/addons/authorize?descriptor_uri=test',
122+
},
123+
isAdvancing: true,
124+
})}
125+
/>
126+
);
127+
128+
expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled();
129+
});
130+
131+
it('disables authorize button when authorizeUrl is not provided', () => {
132+
render(<BitbucketAuthorizeStep {...makeStepProps({stepData: {}})} />);
133+
134+
expect(screen.getByRole('button', {name: 'Authorize Bitbucket'})).toBeDisabled();
135+
});
136+
137+
it('shows popup blocked notice when popup fails to open', async () => {
138+
jest.spyOn(window, 'open').mockReturnValue(null);
139+
render(
140+
<BitbucketAuthorizeStep
141+
{...makeStepProps({
142+
stepData: {
143+
authorizeUrl:
144+
'https://bitbucket.org/site/addons/authorize?descriptor_uri=test',
145+
},
146+
})}
147+
/>
148+
);
149+
150+
await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'}));
151+
152+
expect(screen.getByText(/authorization popup was blocked/)).toBeInTheDocument();
153+
});
154+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {useCallback} from 'react';
2+
3+
import {Button} from '@sentry/scraps/button';
4+
import {Stack} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {t} from 'sentry/locale';
8+
import type {IntegrationWithConfig} from 'sentry/types/integrations';
9+
10+
import {useRedirectPopupStep} from './shared/useRedirectPopupStep';
11+
import type {PipelineDefinition, PipelineStepProps} from './types';
12+
import {pipelineComplete} from './types';
13+
14+
interface AuthorizeStepData {
15+
authorizeUrl?: string;
16+
}
17+
18+
interface AuthorizeAdvanceData {
19+
jwt: string;
20+
}
21+
22+
function BitbucketAuthorizeStep({
23+
stepData,
24+
advance,
25+
isAdvancing,
26+
}: PipelineStepProps<AuthorizeStepData, AuthorizeAdvanceData>) {
27+
const handleCallback = useCallback(
28+
(data: Record<string, string>) => {
29+
if (data.jwt) {
30+
advance({jwt: data.jwt});
31+
}
32+
},
33+
[advance]
34+
);
35+
36+
const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({
37+
redirectUrl: stepData.authorizeUrl,
38+
onCallback: handleCallback,
39+
});
40+
41+
return (
42+
<Stack gap="lg" align="start">
43+
<Stack gap="sm">
44+
<Text>
45+
{t(
46+
'Connect your Bitbucket account by authorizing the Sentry add-on for Bitbucket.'
47+
)}
48+
</Text>
49+
{isWaitingForCallback && (
50+
<Text variant="muted" size="sm">
51+
{t('A popup should have opened to authorize with Bitbucket.')}
52+
</Text>
53+
)}
54+
{popupStatus === 'failed-to-open' && (
55+
<Text variant="danger" size="sm">
56+
{t(
57+
'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.'
58+
)}
59+
</Text>
60+
)}
61+
</Stack>
62+
{isAdvancing ? (
63+
<Button size="sm" disabled>
64+
{t('Authorizing...')}
65+
</Button>
66+
) : isWaitingForCallback ? (
67+
<Button size="sm" onClick={openPopup}>
68+
{t('Reopen authorization window')}
69+
</Button>
70+
) : (
71+
<Button
72+
size="sm"
73+
priority="primary"
74+
onClick={openPopup}
75+
disabled={!stepData.authorizeUrl}
76+
>
77+
{t('Authorize Bitbucket')}
78+
</Button>
79+
)}
80+
</Stack>
81+
);
82+
}
83+
84+
export const bitbucketIntegrationPipeline = {
85+
type: 'integration',
86+
provider: 'bitbucket',
87+
actionTitle: t('Installing Bitbucket Integration'),
88+
getCompletionData: pipelineComplete<IntegrationWithConfig>,
89+
steps: [
90+
{
91+
stepId: 'authorize',
92+
shortDescription: t('Authorizing Bitbucket'),
93+
component: BitbucketAuthorizeStep,
94+
},
95+
],
96+
} as const satisfies PipelineDefinition;

static/app/components/pipeline/registry.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {dummyIntegrationPipeline} from './pipelineDummyProvider';
2+
import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket';
23
import {githubIntegrationPipeline} from './pipelineIntegrationGitHub';
34
import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab';
45

56
/**
67
* All registered pipeline definitions.
78
*/
89
export const PIPELINE_REGISTRY = [
10+
bitbucketIntegrationPipeline,
911
dummyIntegrationPipeline,
1012
githubIntegrationPipeline,
1113
gitlabIntegrationPipeline,

0 commit comments

Comments
 (0)