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,