diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx new file mode 100644 index 00000000000000..2d42a87eedcb9b --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx @@ -0,0 +1,168 @@ +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; +import {createMakeStepProps, dispatchPipelineMessage, setupMockPopup} from './testUtils'; + +const InstallationConfigStep = bitbucketServerIntegrationPipeline.steps[0].component; +const OAuthCallbackStep = bitbucketServerIntegrationPipeline.steps[1].component; + +const makeStepProps = createMakeStepProps({totalSteps: 2}); + +let mockPopup: Window; + +beforeEach(() => { + mockPopup = setupMockPopup(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +async function fillRequiredConfigFields() { + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}), + 'sentry-bot' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}), + '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----' + ); +} + +describe('Bitbucket Server InstallationConfigStep', () => { + it('renders the config form fields', () => { + render(); + + expect(screen.getByRole('textbox', {name: 'Bitbucket URL'})).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument(); + }); + + it('calls advance with form data on submit', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith({ + url: 'https://bitbucket.example.com', + consumerKey: 'sentry-bot', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----', + verifySsl: true, + }); + }); + }); + + it('strips trailing slashes from the URL', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.clear(screen.getByRole('textbox', {name: 'Bitbucket URL'})); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com///' + ); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({url: 'https://bitbucket.example.com'}) + ); + }); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); +}); + +describe('Bitbucket Server OAuthCallbackStep', () => { + const oauthUrl = + 'https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=req-token'; + + it('renders the authorize button', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeInTheDocument(); + }); + + it('opens the popup and advances with oauthToken on callback', async () => { + const advance = jest.fn(); + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect(window.open).toHaveBeenCalledWith( + oauthUrl, + 'pipeline_popup', + expect.any(String) + ); + + dispatchPipelineMessage({ + source: mockPopup, + data: { + _pipeline_source: 'sentry-pipeline', + oauth_token: 'callback-token', + }, + }); + + expect(advance).toHaveBeenCalledWith({oauthToken: 'callback-token'}); + }); + + it('disables the authorize button when oauthUrl is missing', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeDisabled(); + }); + + it('shows popup-blocked notice when window.open returns null', async () => { + jest.spyOn(window, 'open').mockReturnValue(null); + + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect( + screen.getByText( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + ) + ).toBeInTheDocument(); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toHaveAttribute('aria-busy', 'true'); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx new file mode 100644 index 00000000000000..d06435206457a5 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx @@ -0,0 +1,235 @@ +import {useCallback, useEffect} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {t, tct} 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'; + +const installationConfigSchema = z.object({ + url: z + .string() + .min(1, t('Bitbucket URL is required')) + .url(t('Enter a valid URL')) + .transform(v => v.replace(/\/+$/, '')), + consumerKey: z + .string() + .min(1, t('Consumer Key is required')) + .max(200, t('Consumer Key is limited to 200 characters')), + privateKey: z.string().min(1, t('Private Key is required')), + verifySsl: z.boolean(), +}); + +interface InstallationConfigAdvanceData { + consumerKey: string; + privateKey: string; + url: string; + verifySsl: boolean; +} + +function InstallationConfigStep({ + advance, + advanceError, + isAdvancing, + isInitializing, +}: PipelineStepProps, InstallationConfigAdvanceData>) { + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + url: '', + consumerKey: '', + privateKey: '', + verifySsl: true, + }, + validators: {onDynamic: installationConfigSchema}, + onSubmit: ({value}) => { + advance(installationConfigSchema.parse(value)); + }, + }); + + useEffect(() => { + if (advanceError) { + setFieldErrors(form, advanceError); + } + }, [advanceError, form]); + + return ( + + + + {tct( + 'Create an Application Link on your Bitbucket Server instance for Sentry, then enter the consumer credentials below. Refer to the [link:documentation] for setup instructions.', + { + link: ( + + ), + } + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + + {t('Continue')} + + + + + ); +} + +interface OAuthStepData { + oauthUrl?: string; +} + +function OAuthCallbackStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const handleCallback = useCallback( + (data: Record) => { + if (data.oauth_token) { + advance({oauthToken: data.oauth_token}); + } + }, + [advance] + ); + + const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData?.oauthUrl, + onCallback: handleCallback, + }); + + return ( + + + + {t( + 'Authorize Sentry on your Bitbucket Server instance to complete the integration setup.' + )} + + {isWaitingForCallback && ( + + {t('A popup should have opened to authorize with Bitbucket Server.')} + + )} + {popupStatus === 'failed-to-open' && ( + + {t( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + )} + + {isWaitingForCallback && !isAdvancing ? ( + + ) : ( + + )} + + ); +} + +export const bitbucketServerIntegrationPipeline = { + type: 'integration', + provider: 'bitbucket_server', + actionTitle: t('Installing Bitbucket Server Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'installation_config', + shortDescription: t('Configuring Bitbucket Server connection'), + component: InstallationConfigStep, + }, + { + stepId: 'oauth_callback', + shortDescription: t('Authorizing via OAuth'), + component: OAuthCallbackStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index d4e49141317ec2..71f9281ddb6254 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,6 +1,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {awsLambdaIntegrationPipeline} from './pipelineIntegrationAwsLambda'; import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; import {claudeCodeIntegrationPipeline} from './pipelineIntegrationClaudeCode'; import {cursorIntegrationPipeline} from './pipelineIntegrationCursor'; import {discordIntegrationPipeline} from './pipelineIntegrationDiscord'; @@ -23,6 +24,7 @@ import {vstsIntegrationPipeline} from './pipelineIntegrationVsts'; export const PIPELINE_REGISTRY = [ awsLambdaIntegrationPipeline, bitbucketIntegrationPipeline, + bitbucketServerIntegrationPipeline, claudeCodeIntegrationPipeline, cursorIntegrationPipeline, discordIntegrationPipeline,