diff --git a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx new file mode 100644 index 00000000000000..3114f46ccadd07 --- /dev/null +++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx @@ -0,0 +1,460 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; +import ProjectsStore from 'sentry/stores/projectsStore'; + +describe('CursorIntegrationCta', () => { + const project = ProjectFixture(); + const organization = OrganizationFixture({ + features: ['integrations-cursor'], + }); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + localStorage.clear(); + + // Default mock for seer preferences + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: null, + }, + }); + + // Default mock for coding agent integrations + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [], + }, + }); + }); + + describe('Feature Flag', () => { + it('does not render without integrations-cursor feature flag', () => { + const orgWithoutFlag = OrganizationFixture({ + features: [], + }); + + const {container} = render(, { + organization: orgWithoutFlag, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders with integrations-cursor feature flag', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading placeholder while fetching preferences', () => { + render(, { + organization, + }); + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + }); + + it('shows loading placeholder while fetching integrations', () => { + render(, { + organization, + }); + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + }); + }); + + describe('Stage 1: Integration Not Installed', () => { + it('shows install stage when cursor integration is not installed', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/Connect Cursor to automatically hand off/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Install Cursor Integration'}) + ).toBeInTheDocument(); + }); + + it('links to cursor integration settings', async () => { + render(, { + organization, + }); + + const installLink = await screen.findByRole('button', { + name: 'Install Cursor Integration', + }); + expect(installLink).toHaveAttribute('href', '/settings/integrations/cursor/'); + }); + + it('includes documentation link', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + const docsLink = screen.getByRole('link', {name: 'Read the docs'}); + expect(docsLink).toHaveAttribute( + 'href', + 'https://docs.sentry.io/integrations/cursor/' + ); + }); + }); + + describe('Stage 2: Integration Installed but Not Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + }); + + it('shows configure stage when integration installed but not configured', async () => { + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/You have the Cursor integration installed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).toBeInTheDocument(); + }); + + it('configures handoff when setup button is clicked', async () => { + const updateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }) + ); + }); + }); + + it('includes link to project seer settings', async () => { + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + const settingsLink = screen.getByRole('link', { + name: 'Configure in Seer project settings', + }); + expect(settingsLink).toHaveAttribute( + 'href', + `/settings/projects/${project.slug}/seer/` + ); + }); + + it('enables automation when setup button is clicked and automation is disabled', async () => { + const projectWithoutAutomation = ProjectFixture({ + seerScannerAutomation: false, + autofixAutomationTuning: 'off', + }); + + const updatedProject = { + ...projectWithoutAutomation, + seerScannerAutomation: true, + autofixAutomationTuning: 'low', + }; + + const projectUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/`, + method: 'PUT', + body: updatedProject, + }); + + const preferencesUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + const onUpdateSuccessSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + // Should first enable automation + await waitFor(() => { + expect(projectUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithoutAutomation.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + autofixAutomationTuning: 'low', + seerScannerAutomation: true, + }, + }) + ); + }); + + // Should update the project store + await waitFor(() => { + expect(onUpdateSuccessSpy).toHaveBeenCalledWith(updatedProject); + }); + + // Then configure handoff + await waitFor(() => { + expect(preferencesUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }) + ); + }); + + onUpdateSuccessSpy.mockRestore(); + }); + + it('does not enable automation when already enabled', async () => { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + const projectUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithAutomation.slug}/`, + method: 'PUT', + body: {}, + }); + + const preferencesUpdateMock = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`, + method: 'POST', + body: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }); + + render(, { + organization, + }); + + const setupButton = await screen.findByRole('button', { + name: 'Set Seer to hand off to Cursor', + }); + await userEvent.click(setupButton); + + // Should NOT call project update since automation is already enabled + expect(projectUpdateMock).not.toHaveBeenCalled(); + + // Should only configure handoff + await waitFor(() => { + expect(preferencesUpdateMock).toHaveBeenCalledWith( + `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + }) + ); + }); + }); + }); + + describe('Stage 2: Automation Disabled with Handoff Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }, + }); + }); + + it('shows configure stage when handoff is configured but automation is disabled', async () => { + const projectWithoutAutomation = ProjectFixture({ + seerScannerAutomation: false, + autofixAutomationTuning: 'off', + }); + + render(, { + organization, + }); + + // Should show configure stage, not configured stage + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect( + screen.getByText(/You have the Cursor integration installed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).toBeInTheDocument(); + + // Should NOT show the configured message + expect(screen.queryByText(/Cursor handoff is active/)).not.toBeInTheDocument(); + }); + }); + + describe('Stage 3: Integration Configured', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + body: { + integrations: [ + { + id: '123', + provider: 'cursor', + name: 'Cursor', + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + body: { + code_mapping_repos: [], + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + }, + }, + }, + }); + }); + + it('shows configured stage when handoff is set up and automation is enabled', async () => { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument(); + expect(screen.getByText(/Cursor handoff is active/)).toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).not.toBeInTheDocument(); + }); + + it('does not show setup button in configured stage', async () => { + const projectWithAutomation = ProjectFixture({ + seerScannerAutomation: true, + autofixAutomationTuning: 'medium', + }); + + render(, { + organization, + }); + + await screen.findByText('Cursor Agent Integration'); + expect( + screen.queryByRole('button', {name: 'Set Seer to hand off to Cursor'}) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx new file mode 100644 index 00000000000000..c373ce09369dc0 --- /dev/null +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -0,0 +1,206 @@ +import {useCallback} from 'react'; +import styled from '@emotion/styled'; +import {useQueryClient} from '@tanstack/react-query'; + +import {Flex} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {Button} from 'sentry/components/core/button/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {ExternalLink, Link} from 'sentry/components/core/link'; +import { + makeProjectSeerPreferencesQueryKey, + useProjectSeerPreferences, +} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import {useUpdateProjectAutomation} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectAutomation'; +import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; +import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; +import Placeholder from 'sentry/components/placeholder'; +import {t, tct} from 'sentry/locale'; +import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; +import type {Project} from 'sentry/types/project'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface CursorIntegrationCtaProps { + project: Project; +} + +export function CursorIntegrationCta({project}: CursorIntegrationCtaProps) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + + const {preference, isFetching: isLoadingPreferences} = + useProjectSeerPreferences(project); + const {mutate: updateProjectSeerPreferences, isPending: isUpdatingPreferences} = + useUpdateProjectSeerPreferences(project); + const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = + useCodingAgentIntegrations(); + const {mutateAsync: updateProjectAutomation} = useUpdateProjectAutomation(project); + + const cursorIntegration = codingAgentIntegrations?.integrations.find( + integration => integration.provider === 'cursor' + ); + + const hasCursorIntegrationFeatureFlag = + organization.features.includes('integrations-cursor'); + const hasCursorIntegration = Boolean(cursorIntegration); + const isAutomationEnabled = + project.seerScannerAutomation !== false && project.autofixAutomationTuning !== 'off'; + const isConfigured = Boolean(preference?.automation_handoff) && isAutomationEnabled; + + const handleSetupClick = useCallback(async () => { + if (!cursorIntegration) { + throw new Error('Cursor integration not found'); + } + + const isAutomationDisabled = + project.seerScannerAutomation === false || + project.autofixAutomationTuning === 'off'; + + if (isAutomationDisabled) { + await updateProjectAutomation({ + autofixAutomationTuning: 'low', + seerScannerAutomation: true, + }); + } + + updateProjectSeerPreferences( + { + repositories: preference?.repositories || [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: parseInt(cursorIntegration.id, 10), + }, + }, + { + onSuccess: () => { + // Invalidate queries to update the dropdown in the settings page + queryClient.invalidateQueries({ + queryKey: [ + makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + ], + }); + }, + } + ); + }, [ + project.slug, + project.seerScannerAutomation, + project.autofixAutomationTuning, + organization.slug, + updateProjectSeerPreferences, + updateProjectAutomation, + preference?.repositories, + cursorIntegration, + queryClient, + ]); + + if (!hasCursorIntegrationFeatureFlag) { + return null; + } + + if (isLoadingPreferences || isLoadingIntegrations || isUpdatingPreferences) { + return ( + + + + ); + } + + // Stage 1: Integration not installed + if (!hasCursorIntegration) { + return ( + + + + + Cursor Agent Integration + + + + {tct( + 'Connect Cursor to automatically hand off Seer root cause analysis to Cursor Background Agents for seamless code fixes. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + +
+ + {t('Install Cursor Integration')} + +
+
+
+ ); + } + + // Stage 2: Integration installed but handoff not configured + if (!isConfigured) { + return ( + + + + + Cursor Agent Integration + + + + {tct( + 'You have the Cursor integration installed. Turn on Seer automation and set up hand off to trigger Cursor Background Agents during automation. [seerProjectSettings:Configure in Seer project settings] or [docsLink:read the docs] to learn more.', + { + seerProjectSettings: ( + + ), + docsLink: ( + + ), + } + )} + +
+ +
+
+
+ ); + } + + // Stage 3: Configured or just configured + return ( + + + + + Cursor Agent Integration + + + + {tct( + 'Cursor handoff is active. During automation runs, Seer will automatically trigger Cursor Background Agents. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + + + + ); +} + +const Card = styled('div')` + position: relative; + padding: ${p => p.theme.space.xl}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + margin-top: ${p => p.theme.space['2xl']}; + margin-bottom: ${p => p.theme.space['2xl']}; +`; diff --git a/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts b/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts index 5226e2e269b805..453d6c377df8db 100644 --- a/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts +++ b/static/app/components/events/autofix/preferences/hooks/useProjectSeerPreferences.ts @@ -11,7 +11,7 @@ export interface SeerPreferencesResponse { preference?: ProjectSeerPreferences | null; } -function makeProjectSeerPreferencesQueryKey(orgSlug: string, projectSlug: string) { +export function makeProjectSeerPreferencesQueryKey(orgSlug: string, projectSlug: string) { return `/projects/${orgSlug}/${projectSlug}/seer/preferences/`; } diff --git a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx new file mode 100644 index 00000000000000..31754fb3cfd39d --- /dev/null +++ b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectAutomation.tsx @@ -0,0 +1,55 @@ +import ProjectsStore from 'sentry/stores/projectsStore'; +import type {Project} from 'sentry/types/project'; +import { + fetchMutation, + setApiQueryData, + useMutation, + useQueryClient, +} from 'sentry/utils/queryClient'; +import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface UpdateProjectAutomationData { + autofixAutomationTuning: 'off' | 'super_low' | 'low' | 'medium' | 'high' | 'always'; + seerScannerAutomation: boolean; +} + +export function useUpdateProjectAutomation(project: Project) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: UpdateProjectAutomationData) => { + return fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data: { + autofixAutomationTuning: data.autofixAutomationTuning, + seerScannerAutomation: data.seerScannerAutomation, + }, + }); + }, + onSuccess: (updatedProject: Project) => { + // Update the project store so the UI reflects the changes immediately + ProjectsStore.onUpdateSuccess(updatedProject); + + // Update the query cache optimistically + setApiQueryData( + queryClient, + makeDetailedProjectQueryKey({ + orgSlug: organization.slug, + projectSlug: project.slug, + }), + existingData => (updatedProject ? updatedProject : existingData) + ); + + // Invalidate to refetch and ensure consistency + queryClient.invalidateQueries({ + queryKey: makeDetailedProjectQueryKey({ + orgSlug: organization.slug, + projectSlug: project.slug, + }), + }); + }, + }); +} diff --git a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts index 98acecac62ed72..13df39b7f62688 100644 --- a/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts +++ b/static/app/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences.ts @@ -1,12 +1,14 @@ +import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; import type {Project} from 'sentry/types/project'; -import {useMutation} from 'sentry/utils/queryClient'; +import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; export function useUpdateProjectSeerPreferences(project: Project) { const organization = useOrganization(); const api = useApi({persistInFlight: true}); + const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: ProjectSeerPreferences) => { @@ -23,5 +25,10 @@ export function useUpdateProjectSeerPreferences(project: Project) { } ); }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [makeProjectSeerPreferencesQueryKey(organization.slug, project.slug)], + }); + }, }); } diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index d53d90d12c2fa2..f0236d9956bd88 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -6,6 +6,7 @@ import {hasEveryAccess} from 'sentry/components/acl/access'; import FeatureDisabled from 'sentry/components/acl/featureDisabled'; import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Link} from 'sentry/components/core/link'; +import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; @@ -228,11 +229,11 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { return (
+