Skip to content

Commit 8c22c81

Browse files
feat(vsts): Add API pipeline frontend flow (#113095)
Refs [VDY-43: Azure DevOps (VSTS): API-driven integration setup](https://linear.app/getsentry/issue/VDY-43/azure-devops-vsts-api-driven-integration-setup)
1 parent aeea420 commit 8c22c81

File tree

3 files changed

+239
-0
lines changed

3 files changed

+239
-0
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {vstsIntegrationPipeline} from './pipelineIntegrationVsts';
4+
import type {PipelineStepProps} from './types';
5+
6+
const VstsOAuthLoginStep = vstsIntegrationPipeline.steps[0].component;
7+
const VstsAccountSelectionStep = vstsIntegrationPipeline.steps[1].component;
8+
9+
function makeStepProps<D, A>(
10+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
11+
): PipelineStepProps<D, A> {
12+
return {
13+
advance: jest.fn(),
14+
advanceError: null,
15+
isAdvancing: false,
16+
stepIndex: 0,
17+
totalSteps: 2,
18+
...overrides,
19+
};
20+
}
21+
22+
let mockPopup: Window;
23+
24+
function dispatchPipelineMessage({
25+
data,
26+
origin = document.location.origin,
27+
source = mockPopup,
28+
}: {
29+
data: Record<string, string>;
30+
origin?: string;
31+
source?: Window | MessageEventSource | null;
32+
}) {
33+
act(() => {
34+
const event = new MessageEvent('message', {data, origin});
35+
Object.defineProperty(event, 'source', {value: source});
36+
window.dispatchEvent(event);
37+
});
38+
}
39+
40+
beforeEach(() => {
41+
mockPopup = {
42+
closed: false,
43+
close: jest.fn(),
44+
focus: jest.fn(),
45+
} as unknown as Window;
46+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
47+
});
48+
49+
afterEach(() => {
50+
jest.restoreAllMocks();
51+
});
52+
53+
describe('VstsOAuthLoginStep', () => {
54+
it('renders the OAuth login step for Azure DevOps', () => {
55+
render(
56+
<VstsOAuthLoginStep
57+
{...makeStepProps({
58+
stepData: {
59+
oauthUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
60+
},
61+
})}
62+
/>
63+
);
64+
65+
expect(
66+
screen.getByRole('button', {name: 'Authorize Azure DevOps'})
67+
).toBeInTheDocument();
68+
});
69+
70+
it('calls advance with code and state on OAuth callback', async () => {
71+
const advance = jest.fn();
72+
render(
73+
<VstsOAuthLoginStep
74+
{...makeStepProps({
75+
stepData: {
76+
oauthUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
77+
},
78+
advance,
79+
})}
80+
/>
81+
);
82+
83+
await userEvent.click(screen.getByRole('button', {name: 'Authorize Azure DevOps'}));
84+
85+
dispatchPipelineMessage({
86+
data: {
87+
_pipeline_source: 'sentry-pipeline',
88+
code: 'auth-code-123',
89+
state: 'state-xyz',
90+
},
91+
});
92+
93+
expect(advance).toHaveBeenCalledWith({
94+
code: 'auth-code-123',
95+
state: 'state-xyz',
96+
});
97+
});
98+
});
99+
100+
describe('VstsAccountSelectionStep', () => {
101+
it('shows a no accounts message when no Azure DevOps organizations are available', () => {
102+
render(<VstsAccountSelectionStep {...makeStepProps({stepData: {accounts: []}})} />);
103+
104+
expect(
105+
screen.getByText(
106+
'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.'
107+
)
108+
).toBeInTheDocument();
109+
});
110+
111+
it('calls advance when selecting an Azure DevOps organization', async () => {
112+
const advance = jest.fn();
113+
render(
114+
<VstsAccountSelectionStep
115+
{...makeStepProps({
116+
stepData: {
117+
accounts: [{accountId: 'acct-1', accountName: 'MyVSTSAccount'}],
118+
},
119+
advance,
120+
})}
121+
/>
122+
);
123+
124+
await userEvent.click(
125+
screen.getByRole('button', {name: 'Select Azure DevOps organization'})
126+
);
127+
await userEvent.click(await screen.findByText('MyVSTSAccount'));
128+
129+
expect(advance).toHaveBeenCalledWith({account: 'acct-1'});
130+
});
131+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {useCallback} from 'react';
2+
3+
import {Alert} from '@sentry/scraps/alert';
4+
import {Stack} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {DropdownMenu} from 'sentry/components/dropdownMenu';
8+
import {t} from 'sentry/locale';
9+
import type {IntegrationWithConfig} from 'sentry/types/integrations';
10+
11+
import type {OAuthCallbackData} from './shared/oauthLoginStep';
12+
import {OAuthLoginStep} from './shared/oauthLoginStep';
13+
import type {PipelineDefinition, PipelineStepProps} from './types';
14+
import {pipelineComplete} from './types';
15+
16+
interface VstsAccount {
17+
accountId: string;
18+
accountName: string;
19+
}
20+
21+
interface VstsAccountSelectionStepData {
22+
accounts?: VstsAccount[];
23+
}
24+
25+
interface VstsAccountSelectionAdvanceData {
26+
account: string;
27+
}
28+
29+
function VstsOAuthLoginStep({
30+
stepData,
31+
advance,
32+
isAdvancing,
33+
}: PipelineStepProps<{oauthUrl?: string}, {code: string; state: string}>) {
34+
const handleOAuthCallback = useCallback(
35+
(data: OAuthCallbackData) => {
36+
advance({code: data.code, state: data.state});
37+
},
38+
[advance]
39+
);
40+
41+
return (
42+
<OAuthLoginStep
43+
oauthUrl={stepData.oauthUrl}
44+
isLoading={isAdvancing}
45+
serviceName="Azure DevOps"
46+
onOAuthCallback={handleOAuthCallback}
47+
/>
48+
);
49+
}
50+
51+
function VstsAccountSelectionStep({
52+
stepData,
53+
advance,
54+
isAdvancing,
55+
}: PipelineStepProps<VstsAccountSelectionStepData, VstsAccountSelectionAdvanceData>) {
56+
const accounts = stepData.accounts ?? [];
57+
58+
if (accounts.length === 0) {
59+
return (
60+
<Alert variant="info">
61+
{t(
62+
'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.'
63+
)}
64+
</Alert>
65+
);
66+
}
67+
68+
return (
69+
<Stack gap="lg" align="start">
70+
<Text>
71+
{t('Select the Azure DevOps organization you want to connect to Sentry.')}
72+
</Text>
73+
<DropdownMenu
74+
triggerLabel={t('Select Azure DevOps organization')}
75+
items={accounts.map(account => ({
76+
key: account.accountId,
77+
label: account.accountName,
78+
}))}
79+
isDisabled={isAdvancing}
80+
onAction={key => {
81+
advance({account: key as string});
82+
}}
83+
/>
84+
</Stack>
85+
);
86+
}
87+
88+
export const vstsIntegrationPipeline = {
89+
type: 'integration',
90+
provider: 'vsts',
91+
actionTitle: t('Installing Azure DevOps Integration'),
92+
getCompletionData: pipelineComplete<IntegrationWithConfig>,
93+
completionView: null,
94+
steps: [
95+
{
96+
stepId: 'oauth_login',
97+
shortDescription: t('Authorizing via Azure DevOps OAuth'),
98+
component: VstsOAuthLoginStep,
99+
},
100+
{
101+
stepId: 'account_selection',
102+
shortDescription: t('Selecting Azure DevOps organization'),
103+
component: VstsAccountSelectionStep,
104+
},
105+
],
106+
} as const satisfies PipelineDefinition;

static/app/components/pipeline/registry.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket';
44
import {githubIntegrationPipeline} from './pipelineIntegrationGitHub';
55
import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab';
66
import {slackIntegrationPipeline} from './pipelineIntegrationSlack';
7+
import {vstsIntegrationPipeline} from './pipelineIntegrationVsts';
78

89
/**
910
* All registered pipeline definitions.
@@ -15,6 +16,7 @@ export const PIPELINE_REGISTRY = [
1516
githubIntegrationPipeline,
1617
gitlabIntegrationPipeline,
1718
slackIntegrationPipeline,
19+
vstsIntegrationPipeline,
1820
] as const;
1921

2022
type AllPipelines = (typeof PIPELINE_REGISTRY)[number];

0 commit comments

Comments
 (0)