From b35084f171e3b869f73d92e5795cc50cc9e969e8 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 23 Mar 2026 17:11:33 -0400 Subject: [PATCH] feat(gitlab): Add frontend pipeline steps for GitLab integration setup Add GitLab pipeline step components and register them in the pipeline registry. The config step uses GuidedSteps to walk users through creating a GitLab OAuth application, then collects instance URL, group path, and OAuth credentials. The OAuth step reuses the shared OAuthLoginStep. Includes snapshot tests for the config step. Ref [VDY-39](https://linear.app/getsentry/issue/VDY-39/gitlab-api-driven-integration-setup) --- .../pipelineIntegrationGitLab.spec.tsx | 366 ++++++++++++++++++ .../pipeline/pipelineIntegrationGitLab.tsx | 294 ++++++++++++++ static/app/components/pipeline/registry.tsx | 2 + 3 files changed, 662 insertions(+) create mode 100644 static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx create mode 100644 static/app/components/pipeline/pipelineIntegrationGitLab.tsx diff --git a/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx b/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx new file mode 100644 index 00000000000000..fbf423de03f95b --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx @@ -0,0 +1,366 @@ +import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; +import type {PipelineStepProps} from './types'; + +const InstallationConfigStep = gitlabIntegrationPipeline.steps[0].component; +const GitLabOAuthLoginStep = gitlabIntegrationPipeline.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('InstallationConfigStep', () => { + it('renders the guided steps and config form', () => { + render( + + ); + + expect( + screen.getByText( + 'To connect Sentry with your GitLab instance, you need to create an OAuth application in GitLab.' + ) + ).toBeInTheDocument(); + + expect(screen.getByText('Open GitLab application settings')).toBeInTheDocument(); + expect(screen.getByText('Create a new application')).toBeInTheDocument(); + expect(screen.getByText('Configure the integration')).toBeInTheDocument(); + }); + + it('renders setup values in the create step', async () => { + render( + + ); + + // Navigate to the create step + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + expect(screen.getByText('Redirect URI')).toBeInTheDocument(); + expect( + screen.getByText('https://sentry.io/extensions/gitlab/setup/') + ).toBeInTheDocument(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + expect(screen.getByText('api')).toBeInTheDocument(); + }); + + it('submits config with required fields and calls advance', async () => { + const advance = jest.fn(); + render( + + ); + + // Navigate through guided steps to the configure step + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab Application ID'}), + 'my-app-id' + ); + await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret'); + + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith({ + url: undefined, + verify_ssl: undefined, + group: '', + include_subgroups: undefined, + client_id: 'my-app-id', + client_secret: 'my-secret', + }); + }); + }); + + it('submits with group and include_subgroups when group is set', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab Application ID'}), + 'my-app-id' + ); + await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret'); + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab Group Path'}), + 'my-group/sub' + ); + + // Include Subgroups toggle should now be visible + await userEvent.click(screen.getByRole('checkbox', {name: 'Include Subgroups'})); + + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({ + group: 'my-group/sub', + include_subgroups: true, + client_id: 'my-app-id', + client_secret: 'my-secret', + }) + ); + }); + }); + + it('does not show include_subgroups toggle when group is empty', async () => { + render(); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + expect( + screen.queryByRole('checkbox', {name: 'Include Subgroups'}) + ).not.toBeInTheDocument(); + }); + + it('shows self-hosted fields when self-hosted toggle is enabled', async () => { + render(); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + // Self-hosted fields should not be visible initially + expect(screen.queryByRole('textbox', {name: 'GitLab URL'})).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'})); + + expect(screen.getByRole('textbox', {name: 'GitLab URL'})).toBeInTheDocument(); + expect(screen.getByRole('checkbox', {name: 'Verify SSL'})).toBeInTheDocument(); + }); + + it('submits self-hosted config with URL and verify_ssl', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab Application ID'}), + 'my-app-id' + ); + await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'my-secret'); + + await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab URL'}), + 'https://gitlab.example.com/' + ); + + // Verify SSL is on by default, turn it off + await userEvent.click(screen.getByRole('checkbox', {name: 'Verify SSL'})); + + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://gitlab.example.com', + verify_ssl: false, + client_id: 'my-app-id', + client_secret: 'my-secret', + }) + ); + }); + }); + + it('strips trailing slashes from self-hosted URL', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab Application ID'}), + 'id' + ); + await userEvent.type(screen.getByLabelText('GitLab Application Secret'), 'secret'); + + await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'})); + + await userEvent.type( + screen.getByRole('textbox', {name: 'GitLab URL'}), + 'https://gitlab.example.com///' + ); + + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://gitlab.example.com', + }) + ); + }); + }); + + it('shows submitting state when isAdvancing is true', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + + expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + }); +}); + +describe('GitLabOAuthLoginStep', () => { + it('renders the OAuth login step for GitLab', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorize GitLab'})).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 GitLab'})); + + dispatchPipelineMessage({ + data: { + _pipeline_source: 'sentry-pipeline', + code: 'auth-code-123', + state: 'state-xyz', + }, + }); + + expect(advance).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + }); + }); + + it('shows loading state when isAdvancing is true', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + }); + + it('disables authorize button when oauthUrl is not provided', () => { + render(); + + expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toBeDisabled(); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationGitLab.tsx b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx new file mode 100644 index 00000000000000..2a34cdbf41228e --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx @@ -0,0 +1,294 @@ +import {useCallback} from 'react'; +import {z} from 'zod'; + +import {CodeBlock} from '@sentry/scraps/code'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {GuidedSteps} from 'sentry/components/guidedSteps/guidedSteps'; +import {t, tct} 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'; + +const installationConfigSchema = z + .object({ + selfHosted: z.boolean(), + url: z.string(), + group: z.string(), + includeSubgroups: z.boolean(), + verifySsl: z.boolean(), + clientId: z.string().min(1, t('Application ID is required')), + clientSecret: z.string().min(1, t('Application Secret is required')), + }) + .refine(data => !data.selfHosted || z.httpUrl().safeParse(data.url).success, { + path: ['url'], + message: t('A valid GitLab URL is required for self-hosted instances'), + }); + +interface InstallationConfigStepData { + defaults?: { + includeSubgroups?: boolean; + verifySsl?: boolean; + }; + setupValues?: Array<{label: string; value: string}>; +} + +interface InstallationConfigAdvanceData { + client_id: string; + client_secret: string; + group: string; + include_subgroups?: boolean; + url?: string; + verify_ssl?: boolean; +} + +function InstallationConfigStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const defaults = stepData.defaults ?? {}; + const setupValues = stepData.setupValues ?? []; + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + selfHosted: false, + url: '', + group: '', + includeSubgroups: defaults.includeSubgroups ?? false, + verifySsl: defaults.verifySsl ?? true, + clientId: '', + clientSecret: '', + }, + validators: {onDynamic: installationConfigSchema}, + onSubmit: ({value}) => { + advance({ + url: value.selfHosted ? value.url.replace(/\/+$/, '') : undefined, + verify_ssl: value.selfHosted ? value.verifySsl : undefined, + group: value.group, + include_subgroups: value.group ? value.includeSubgroups : undefined, + client_id: value.clientId, + client_secret: value.clientSecret, + }); + }, + }); + + const configForm = ( + + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + state.values.group}> + {group => + group ? ( + + {field => ( + + + + )} + + ) : null + } + + + {field => ( + + + + )} + + state.values.selfHosted}> + {isSelfHosted => + isSelfHosted ? ( + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + ) : null + } + + + + {isAdvancing ? t('Submitting...') : t('Continue')} + + + + + ); + + return ( + + + {t( + 'To connect Sentry with your GitLab instance, you need to create an OAuth application in GitLab.' + )} + + + + + + {tct( + 'Navigate to [bold:User Settings \u203A Access \u203A Applications] in GitLab.', + { + bold: , + } + )} + + + {tct( + 'For self-managed instances, use [bold:Admin Area \u203A Applications] instead.', + {bold: } + )} + + + + + + + {setupValues.map(({label, value}) => ( + + + {label} + + {value} + + ))} + + + + + {configForm} + + + + ); +} + +function GitLabOAuthLoginStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps<{oauthUrl?: string}, {code: string; state: string}>) { + const handleOAuthCallback = useCallback( + (data: OAuthCallbackData) => { + advance({code: data.code, state: data.state}); + }, + [advance] + ); + + return ( + + ); +} + +export const gitlabIntegrationPipeline = { + type: 'integration', + provider: 'gitlab', + actionTitle: t('Installing GitLab Integration'), + getCompletionData: pipelineComplete, + steps: [ + { + stepId: 'installation_config', + shortDescription: t('Configuring GitLab connection'), + component: InstallationConfigStep, + }, + { + stepId: 'oauth_login', + shortDescription: t('Authorizing via GitLab OAuth flow'), + component: GitLabOAuthLoginStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index c88fad5a40162d..4c8c5be60ded33 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,5 +1,6 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; +import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; /** * All registered pipeline definitions. @@ -7,6 +8,7 @@ import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; export const PIPELINE_REGISTRY = [ dummyIntegrationPipeline, githubIntegrationPipeline, + gitlabIntegrationPipeline, ] as const; type AllPipelines = (typeof PIPELINE_REGISTRY)[number];