diff --git a/static/app/components/pipeline/pipelineIntegrationVsts.spec.tsx b/static/app/components/pipeline/pipelineIntegrationVsts.spec.tsx new file mode 100644 index 00000000000000..a3e7f99ea6ec6b --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationVsts.spec.tsx @@ -0,0 +1,131 @@ +import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {vstsIntegrationPipeline} from './pipelineIntegrationVsts'; +import type {PipelineStepProps} from './types'; + +const VstsOAuthLoginStep = vstsIntegrationPipeline.steps[0].component; +const VstsAccountSelectionStep = vstsIntegrationPipeline.steps[1].component; + +function makeStepProps( + overrides: Partial> & {stepData: D} +): PipelineStepProps { + return { + advance: jest.fn(), + advanceError: null, + isAdvancing: false, + stepIndex: 0, + totalSteps: 2, + ...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('VstsOAuthLoginStep', () => { + it('renders the OAuth login step for Azure DevOps', () => { + render( + + ); + + expect( + screen.getByRole('button', {name: 'Authorize Azure DevOps'}) + ).toBeInTheDocument(); + }); + + it('calls advance with code and state on OAuth callback', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Azure DevOps'})); + + dispatchPipelineMessage({ + data: { + _pipeline_source: 'sentry-pipeline', + code: 'auth-code-123', + state: 'state-xyz', + }, + }); + + expect(advance).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + }); + }); +}); + +describe('VstsAccountSelectionStep', () => { + it('shows a no accounts message when no Azure DevOps organizations are available', () => { + render(); + + expect( + screen.getByText( + 'No Azure DevOps organizations were found for this account. Make sure you are an owner or admin on the Azure DevOps organization you want to connect.' + ) + ).toBeInTheDocument(); + }); + + it('calls advance when selecting an Azure DevOps organization', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Select Azure DevOps organization'}) + ); + await userEvent.click(await screen.findByText('MyVSTSAccount')); + + expect(advance).toHaveBeenCalledWith({account: 'acct-1'}); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationVsts.tsx b/static/app/components/pipeline/pipelineIntegrationVsts.tsx new file mode 100644 index 00000000000000..aacd892e7e17fa --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationVsts.tsx @@ -0,0 +1,106 @@ +import {useCallback} from 'react'; + +import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {t} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import type {OAuthCallbackData} from './shared/oauthLoginStep'; +import {OAuthLoginStep} from './shared/oauthLoginStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +interface VstsAccount { + accountId: string; + accountName: string; +} + +interface VstsAccountSelectionStepData { + accounts?: VstsAccount[]; +} + +interface VstsAccountSelectionAdvanceData { + account: string; +} + +function VstsOAuthLoginStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps<{oauthUrl?: string}, {code: string; state: string}>) { + const handleOAuthCallback = useCallback( + (data: OAuthCallbackData) => { + advance({code: data.code, state: data.state}); + }, + [advance] + ); + + return ( + + ); +} + +function VstsAccountSelectionStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const accounts = stepData.accounts ?? []; + + if (accounts.length === 0) { + return ( + + {t( + 'No Azure DevOps organizations were found for this account. Make sure you are an owner or admin on the Azure DevOps organization you want to connect.' + )} + + ); + } + + return ( + + + {t('Select the Azure DevOps organization you want to connect to Sentry.')} + + ({ + key: account.accountId, + label: account.accountName, + }))} + isDisabled={isAdvancing} + onAction={key => { + advance({account: key as string}); + }} + /> + + ); +} + +export const vstsIntegrationPipeline = { + type: 'integration', + provider: 'vsts', + actionTitle: t('Installing Azure DevOps Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'oauth_login', + shortDescription: t('Authorizing via Azure DevOps OAuth'), + component: VstsOAuthLoginStep, + }, + { + stepId: 'account_selection', + shortDescription: t('Selecting Azure DevOps organization'), + component: VstsAccountSelectionStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index 5c049b0a09a7d6..fc0f4233f78ac9 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -4,6 +4,7 @@ import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; import {slackIntegrationPipeline} from './pipelineIntegrationSlack'; +import {vstsIntegrationPipeline} from './pipelineIntegrationVsts'; /** * All registered pipeline definitions. @@ -15,6 +16,7 @@ export const PIPELINE_REGISTRY = [ githubIntegrationPipeline, gitlabIntegrationPipeline, slackIntegrationPipeline, + vstsIntegrationPipeline, ] as const; type AllPipelines = (typeof PIPELINE_REGISTRY)[number];