From 76fb9d74f2e6c976d1883dc8aacd4c1799dc897d Mon Sep 17 00:00:00 2001
From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com>
Date: Tue, 11 Nov 2025 22:14:57 +0700
Subject: [PATCH 1/6] feat(seer-cursor): add cursor integration CTA component
and settings page integration
- Add reusable CursorIntegrationCta component for prompting users to configure Cursor integration
- Integrate CTA component into Seer project settings page
- Export makeProjectSeerPreferencesQueryKey for cache management
- Add query invalidation on preferences update for better cache consistency
---
.../autofix/cursorIntegrationCta.spec.tsx | 345 ++++++++++++++++++
.../events/autofix/cursorIntegrationCta.tsx | 281 ++++++++++++++
.../hooks/useProjectSeerPreferences.ts | 2 +-
.../hooks/useUpdateProjectSeerPreferences.ts | 9 +-
.../app/views/settings/projectSeer/index.tsx | 2 +
5 files changed, 637 insertions(+), 2 deletions(-)
create mode 100644 static/app/components/events/autofix/cursorIntegrationCta.spec.tsx
create mode 100644 static/app/components/events/autofix/cursorIntegrationCta.tsx
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..d69eb745119bcd
--- /dev/null
+++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx
@@ -0,0 +1,345 @@
+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';
+
+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/`
+ );
+ });
+ });
+
+ 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', async () => {
+ 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 () => {
+ render(, {
+ organization,
+ });
+
+ await screen.findByText('Cursor Agent Integration');
+ expect(
+ screen.queryByRole('button', {name: 'Set Seer to hand off to Cursor'})
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Dismissible Functionality', () => {
+ const dismissKey = 'test-dismiss-key';
+
+ it('shows dismiss button when dismissible is true', async () => {
+ render(
+ ,
+ {
+ organization,
+ }
+ );
+
+ expect(await screen.findByRole('button', {name: 'Dismiss'})).toBeInTheDocument();
+ });
+
+ it('does not show dismiss button when dismissible is false', async () => {
+ render(, {
+ organization,
+ });
+
+ await screen.findByText('Cursor Agent Integration');
+ expect(screen.queryByRole('button', {name: 'Dismiss'})).not.toBeInTheDocument();
+ });
+
+ it('hides component when dismissed', async () => {
+ render(
+ ,
+ {
+ organization,
+ }
+ );
+
+ const dismissButton = await screen.findByRole('button', {name: 'Dismiss'});
+ await userEvent.click(dismissButton);
+
+ expect(screen.queryByText('Cursor Agent Integration')).not.toBeInTheDocument();
+
+ // Verify localStorage was set
+ expect(localStorage.getItem(dismissKey)).toBe('true');
+ expect(localStorage.getItem(`${dismissKey}-stage`)).toBe('install');
+ });
+
+ it('respects existing dismissal from localStorage', () => {
+ localStorage.setItem(dismissKey, 'true');
+
+ const {container} = render(
+ ,
+ {
+ organization,
+ }
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('resets dismissal when stage changes', async () => {
+ localStorage.setItem(dismissKey, 'true');
+ localStorage.setItem(`${dismissKey}-stage`, 'install');
+
+ // Now mock the integration being installed (stage changes to 'configure')
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/integrations/coding-agents/`,
+ body: {
+ integrations: [
+ {
+ id: '123',
+ provider: 'cursor',
+ name: 'Cursor',
+ },
+ ],
+ },
+ });
+
+ render(
+ ,
+ {
+ organization,
+ }
+ );
+
+ // Component should be visible since stage changed
+ expect(await screen.findByText('Cursor Agent Integration')).toBeInTheDocument();
+ expect(localStorage.getItem(dismissKey)).toBeNull();
+ });
+ });
+});
diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx
new file mode 100644
index 00000000000000..a319d11c671f8e
--- /dev/null
+++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx
@@ -0,0 +1,281 @@
+import {useCallback, useEffect, useState} 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 {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences';
+import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix';
+import Placeholder from 'sentry/components/placeholder';
+import {IconClose} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {PluginIcon} from 'sentry/plugins/components/pluginIcon';
+import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface CursorIntegrationCtaProps {
+ project: Project;
+ dismissKey?: string;
+ dismissible?: boolean;
+ organization?: Organization;
+}
+
+export function CursorIntegrationCta({
+ project,
+ dismissible = false,
+ dismissKey,
+}: 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 cursorIntegration = codingAgentIntegrations?.integrations.find(
+ integration => integration.provider === 'cursor'
+ );
+
+ const [isDismissed, setIsDismissed] = useState(() => {
+ if (!dismissible || !dismissKey) {
+ return false;
+ }
+ return localStorage.getItem(dismissKey) === 'true';
+ });
+
+ const hasCursorIntegrationFeatureFlag =
+ organization?.features.includes('integrations-cursor');
+ const hasCursorIntegration = Boolean(cursorIntegration);
+ const isConfigured = Boolean(preference?.automation_handoff);
+
+ // Determine the current stage
+ const stage = hasCursorIntegration
+ ? isConfigured
+ ? 'configured'
+ : 'configure'
+ : 'install';
+
+ // Reset dismissal if stage changes
+ useEffect(() => {
+ if (!dismissible || !dismissKey) {
+ return;
+ }
+ const dismissedStage = localStorage.getItem(`${dismissKey}-stage`);
+ if (dismissedStage && dismissedStage !== stage) {
+ setIsDismissed(false);
+ localStorage.removeItem(dismissKey);
+ }
+ }, [stage, dismissKey, dismissible]);
+
+ const handleDismiss = useCallback(() => {
+ if (!dismissKey) {
+ return;
+ }
+ localStorage.setItem(dismissKey, 'true');
+ localStorage.setItem(`${dismissKey}-stage`, stage);
+ setIsDismissed(true);
+ }, [dismissKey, stage]);
+
+ const handleSetupClick = useCallback(() => {
+ if (!cursorIntegration) {
+ throw new Error('Cursor integration not found');
+ }
+ 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,
+ organization.slug,
+ updateProjectSeerPreferences,
+ preference?.repositories,
+ cursorIntegration,
+ queryClient,
+ ]);
+
+ if (!organization) {
+ return null;
+ }
+
+ if (isDismissed) {
+ return null;
+ }
+
+ if (!hasCursorIntegrationFeatureFlag) {
+ return null;
+ }
+
+ // Show loading state while fetching data
+ if (isLoadingPreferences || isLoadingIntegrations || isUpdatingPreferences) {
+ return (
+
+
+
+ );
+ }
+
+ // Stage 1: Integration not installed
+ if (!hasCursorIntegration) {
+ return (
+
+ {dismissible && (
+ }
+ aria-label={t('Dismiss')}
+ onClick={handleDismiss}
+ />
+ )}
+
+
+
+
+ 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 (
+
+ {dismissible && (
+ }
+ aria-label={t('Dismiss')}
+ onClick={handleDismiss}
+ />
+ )}
+
+
+
+
+ Cursor Agent Integration
+
+
+
+ {tct(
+ 'You have the Cursor integration installed. Set up Seer to hand off and 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 (
+
+ {dismissible && (
+ }
+ aria-label={t('Dismiss')}
+ onClick={handleDismiss}
+ />
+ )}
+
+
+
+
+ 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']};
+`;
+
+const DismissButton = styled(Button)`
+ position: absolute;
+ top: ${p => p.theme.space.md};
+ right: ${p => p.theme.space.md};
+ color: ${p => p.theme.subText};
+
+ &:hover {
+ color: ${p => p.theme.textColor};
+ }
+`;
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/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..5ab3dfd8a46707 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';
@@ -324,6 +325,7 @@ function ProjectSeer({
})}
/>
+
Date: Wed, 12 Nov 2025 01:15:00 +0700
Subject: [PATCH 2/6] turn on automation if it's off
---
.../autofix/cursorIntegrationCta.spec.tsx | 206 +++++++++++++++++-
.../events/autofix/cursorIntegrationCta.tsx | 55 ++++-
.../app/views/settings/projectSeer/index.tsx | 4 +-
3 files changed, 255 insertions(+), 10 deletions(-)
diff --git a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx
index d69eb745119bcd..906d07ffc9adfe 100644
--- a/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx
+++ b/static/app/components/events/autofix/cursorIntegrationCta.spec.tsx
@@ -4,6 +4,7 @@ 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();
@@ -200,6 +201,195 @@ describe('CursorIntegrationCta', () => {
`/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', () => {
@@ -234,8 +424,13 @@ describe('CursorIntegrationCta', () => {
});
});
- it('shows configured stage when handoff is set up', async () => {
- render(, {
+ it('shows configured stage when handoff is set up and automation is enabled', async () => {
+ const projectWithAutomation = ProjectFixture({
+ seerScannerAutomation: true,
+ autofixAutomationTuning: 'medium',
+ });
+
+ render(, {
organization,
});
@@ -247,7 +442,12 @@ describe('CursorIntegrationCta', () => {
});
it('does not show setup button in configured stage', async () => {
- render(, {
+ const projectWithAutomation = ProjectFixture({
+ seerScannerAutomation: true,
+ autofixAutomationTuning: 'medium',
+ });
+
+ render(, {
organization,
});
diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx
index a319d11c671f8e..e99ceff791e912 100644
--- a/static/app/components/events/autofix/cursorIntegrationCta.tsx
+++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx
@@ -18,8 +18,11 @@ import Placeholder from 'sentry/components/placeholder';
import {IconClose} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {PluginIcon} from 'sentry/plugins/components/pluginIcon';
+import ProjectsStore from 'sentry/stores/projectsStore';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
+import {fetchMutation, useMutation} from 'sentry/utils/queryClient';
+import {makeDetailedProjectQueryKey} from 'sentry/utils/useDetailedProject';
import useOrganization from 'sentry/utils/useOrganization';
interface CursorIntegrationCtaProps {
@@ -44,6 +47,33 @@ export function CursorIntegrationCta({
const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} =
useCodingAgentIntegrations();
+ const {mutateAsync: updateProjectAutomation} = useMutation<
+ Project,
+ Error,
+ {autofixAutomationTuning: string; seerScannerAutomation: boolean}
+ >({
+ mutationFn: (data: {
+ autofixAutomationTuning: string;
+ seerScannerAutomation: boolean;
+ }) => {
+ return fetchMutation({
+ method: 'PUT',
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ data,
+ });
+ },
+ onSuccess: (updatedProject: Project) => {
+ ProjectsStore.onUpdateSuccess(updatedProject);
+
+ queryClient.invalidateQueries({
+ queryKey: makeDetailedProjectQueryKey({
+ orgSlug: organization.slug,
+ projectSlug: project.slug,
+ }),
+ });
+ },
+ });
+
const cursorIntegration = codingAgentIntegrations?.integrations.find(
integration => integration.provider === 'cursor'
);
@@ -58,9 +88,10 @@ export function CursorIntegrationCta({
const hasCursorIntegrationFeatureFlag =
organization?.features.includes('integrations-cursor');
const hasCursorIntegration = Boolean(cursorIntegration);
- const isConfigured = Boolean(preference?.automation_handoff);
+ const isAutomationEnabled =
+ project.seerScannerAutomation !== false && project.autofixAutomationTuning !== 'off';
+ const isConfigured = Boolean(preference?.automation_handoff) && isAutomationEnabled;
- // Determine the current stage
const stage = hasCursorIntegration
? isConfigured
? 'configured'
@@ -88,10 +119,22 @@ export function CursorIntegrationCta({
setIsDismissed(true);
}, [dismissKey, stage]);
- const handleSetupClick = useCallback(() => {
+ 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 || [],
@@ -115,8 +158,11 @@ export function CursorIntegrationCta({
);
}, [
project.slug,
+ project.seerScannerAutomation,
+ project.autofixAutomationTuning,
organization.slug,
updateProjectSeerPreferences,
+ updateProjectAutomation,
preference?.repositories,
cursorIntegration,
queryClient,
@@ -134,7 +180,6 @@ export function CursorIntegrationCta({
return null;
}
- // Show loading state while fetching data
if (isLoadingPreferences || isLoadingIntegrations || isUpdatingPreferences) {
return (
@@ -205,7 +250,7 @@ export function CursorIntegrationCta({
{tct(
- 'You have the Cursor integration installed. Set up Seer to hand off and trigger Cursor Background Agents during automation. [seerProjectSettings:Configure in Seer project settings] or [docsLink:read the docs] to learn more.',
+ '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: (
diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx
index 5ab3dfd8a46707..8366a2564f28ca 100644
--- a/static/app/views/settings/projectSeer/index.tsx
+++ b/static/app/views/settings/projectSeer/index.tsx
@@ -229,11 +229,11 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
return (