From 0c74353234f5c3762007c0fa72fd64e052861d26 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 25 Mar 2026 14:09:57 -0400 Subject: [PATCH] feat(bitbucket): Add frontend pipeline step for Bitbucket integration setup 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) --- .../pipelineIntegrationBitbucket.spec.tsx | 154 ++++++++++++++++++ .../pipeline/pipelineIntegrationBitbucket.tsx | 96 +++++++++++ static/app/components/pipeline/registry.tsx | 2 + 3 files changed, 252 insertions(+) create mode 100644 static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx create mode 100644 static/app/components/pipeline/pipelineIntegrationBitbucket.tsx diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx new file mode 100644 index 00000000000000..f97acd8fbbec5c --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx @@ -0,0 +1,154 @@ +import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; +import type {PipelineStepProps} from './types'; + +const BitbucketAuthorizeStep = bitbucketIntegrationPipeline.steps[0].component; + +function makeStepProps( + overrides: Partial> & {stepData: D} +): PipelineStepProps { + return { + advance: jest.fn(), + advanceError: null, + isAdvancing: false, + stepIndex: 0, + totalSteps: 1, + ...overrides, + }; +} + +let mockPopup: Window; + +function dispatchPipelineMessage({ + data, + origin = document.location.origin, + source = mockPopup, +}: { + data: Record; + origin?: string; + source?: Window | MessageEventSource | null; +}) { + act(() => { + const event = new MessageEvent('message', {data, origin}); + Object.defineProperty(event, 'source', {value: source}); + window.dispatchEvent(event); + }); +} + +beforeEach(() => { + mockPopup = { + closed: false, + close: jest.fn(), + focus: jest.fn(), + } as unknown as Window; + jest.spyOn(window, 'open').mockReturnValue(mockPopup); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('BitbucketAuthorizeStep', () => { + it('renders the authorize button', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorize Bitbucket'})).toBeInTheDocument(); + }); + + it('calls advance with JWT on callback', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'})); + + dispatchPipelineMessage({ + data: { + _pipeline_source: 'sentry-pipeline', + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test', + }, + }); + + expect(advance).toHaveBeenCalledWith({ + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test', + }); + }); + + it('shows reopen button when waiting for callback', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'})); + + expect( + screen.getByRole('button', {name: 'Reopen authorization window'}) + ).toBeInTheDocument(); + }); + + it('shows loading state when isAdvancing is true', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + }); + + it('disables authorize button when authorizeUrl is not provided', () => { + render(); + + expect(screen.getByRole('button', {name: 'Authorize Bitbucket'})).toBeDisabled(); + }); + + it('shows popup blocked notice when popup fails to open', async () => { + jest.spyOn(window, 'open').mockReturnValue(null); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Bitbucket'})); + + expect(screen.getByText(/authorization popup was blocked/)).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx new file mode 100644 index 00000000000000..87cdf53b59fb1b --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx @@ -0,0 +1,96 @@ +import {useCallback} from 'react'; + +import {Button} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {t} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import {useRedirectPopupStep} from './shared/useRedirectPopupStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +interface AuthorizeStepData { + authorizeUrl?: string; +} + +interface AuthorizeAdvanceData { + jwt: string; +} + +function BitbucketAuthorizeStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const handleCallback = useCallback( + (data: Record) => { + if (data.jwt) { + advance({jwt: data.jwt}); + } + }, + [advance] + ); + + const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData.authorizeUrl, + onCallback: handleCallback, + }); + + return ( + + + + {t( + 'Connect your Bitbucket account by authorizing the Sentry add-on for Bitbucket.' + )} + + {isWaitingForCallback && ( + + {t('A popup should have opened to authorize with Bitbucket.')} + + )} + {popupStatus === 'failed-to-open' && ( + + {t( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + )} + + {isAdvancing ? ( + + ) : isWaitingForCallback ? ( + + ) : ( + + )} + + ); +} + +export const bitbucketIntegrationPipeline = { + type: 'integration', + provider: 'bitbucket', + actionTitle: t('Installing Bitbucket Integration'), + getCompletionData: pipelineComplete, + steps: [ + { + stepId: 'authorize', + shortDescription: t('Authorizing Bitbucket'), + component: BitbucketAuthorizeStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index 4c8c5be60ded33..2284fd1382129c 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,4 +1,5 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; +import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; @@ -6,6 +7,7 @@ import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; * All registered pipeline definitions. */ export const PIPELINE_REGISTRY = [ + bitbucketIntegrationPipeline, dummyIntegrationPipeline, githubIntegrationPipeline, gitlabIntegrationPipeline,