diff --git a/static/app/views/settings/projectSeer/index.spec.tsx b/static/app/views/settings/projectSeer/index.spec.tsx
index 546b31542ba7c7..2c0615becd38b8 100644
--- a/static/app/views/settings/projectSeer/index.spec.tsx
+++ b/static/app/views/settings/projectSeer/index.spec.tsx
@@ -12,6 +12,7 @@ import {
within,
} from 'sentry-test/reactTestingLibrary';
+import * as indicators from 'sentry/actionCreators/indicator';
import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
@@ -1224,4 +1225,385 @@ describe('ProjectSeer', () => {
).not.toBeInTheDocument();
});
});
+
+ describe('Auto-open PR and Cursor Handoff toggles with triage-signals-v0', () => {
+ it('shows Auto-open PR toggle when Auto-Trigger is ON', async () => {
+ render(, {
+ organization,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ await screen.findByText(/Automation/i);
+ expect(screen.getByRole('checkbox', {name: /Auto-open PR/i})).toBeInTheDocument();
+ });
+
+ it('hides Auto-open PR toggle when Auto-Trigger is OFF', async () => {
+ render(, {
+ organization,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'off',
+ }),
+ },
+ });
+
+ await screen.findByText(/Automation/i);
+ expect(
+ screen.queryByRole('checkbox', {name: /Auto-open PR/i})
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows Cursor handoff toggle when Auto-Trigger is ON and Cursor integration exists', async () => {
+ const orgWithCursor = OrganizationFixture({
+ features: ['autofix-seer-preferences', 'integrations-cursor'],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/seer/setup-check/`,
+ method: 'GET',
+ body: {
+ setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true},
+ billing: {hasAutofixQuota: true, hasScannerQuota: true},
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/repos/`,
+ query: {status: 'active'},
+ method: 'GET',
+ body: [],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {code_mapping_repos: []},
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/integrations/coding-agents/`,
+ method: 'GET',
+ body: {
+ integrations: [{id: '123', name: 'Cursor', provider: 'cursor'}],
+ },
+ });
+
+ render(, {
+ organization: orgWithCursor,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ await screen.findByText(/Automation/i);
+ expect(
+ screen.getByRole('checkbox', {name: /Hand off to Cursor/i})
+ ).toBeInTheDocument();
+ });
+
+ it('hides Cursor handoff toggle when no Cursor integration', async () => {
+ render(, {
+ organization,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ await screen.findByText(/Automation/i);
+ expect(
+ screen.queryByRole('checkbox', {name: /Hand off to Cursor/i})
+ ).not.toBeInTheDocument();
+ });
+
+ it('updates preferences when Auto-open PR toggle is changed', async () => {
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ method: 'PUT',
+ body: {},
+ });
+
+ const seerPreferencesPostRequest = MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ method: 'POST',
+ });
+
+ render(, {
+ organization,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ const toggle = await screen.findByRole('checkbox', {name: /Auto-open PR/i});
+ await userEvent.click(toggle);
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequest).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automated_run_stopping_point: 'open_pr',
+ automation_handoff: undefined,
+ }),
+ })
+ );
+ });
+ });
+
+ it('updates preferences when Cursor handoff toggle is changed', async () => {
+ const orgWithCursor = OrganizationFixture({
+ features: ['autofix-seer-preferences', 'integrations-cursor'],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/seer/setup-check/`,
+ method: 'GET',
+ body: {
+ setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true},
+ billing: {hasAutofixQuota: true, hasScannerQuota: true},
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/repos/`,
+ query: {status: 'active'},
+ method: 'GET',
+ body: [],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {code_mapping_repos: []},
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/integrations/coding-agents/`,
+ method: 'GET',
+ body: {
+ integrations: [{id: '123', name: 'Cursor', provider: 'cursor'}],
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/`,
+ method: 'PUT',
+ body: {},
+ });
+
+ const seerPreferencesPostRequest = MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'POST',
+ });
+
+ render(, {
+ organization: orgWithCursor,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ const toggle = await screen.findByRole('checkbox', {name: /Hand off to Cursor/i});
+ await userEvent.click(toggle);
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequest).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ automated_run_stopping_point: 'root_cause',
+ automation_handoff: {
+ handoff_point: 'root_cause',
+ target: 'cursor_background_agent',
+ integration_id: 123,
+ auto_create_pr: false,
+ },
+ }),
+ })
+ );
+ });
+ });
+
+ it('shows error when Cursor handoff fails due to missing integration', async () => {
+ const orgWithCursor = OrganizationFixture({
+ features: ['autofix-seer-preferences', 'integrations-cursor'],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/seer/setup-check/`,
+ method: 'GET',
+ body: {
+ setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true},
+ billing: {hasAutofixQuota: true, hasScannerQuota: true},
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/repos/`,
+ query: {status: 'active'},
+ method: 'GET',
+ body: [],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {code_mapping_repos: []},
+ });
+
+ // Mock integrations endpoint returning empty array (no Cursor integration)
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/integrations/coding-agents/`,
+ method: 'GET',
+ body: {integrations: []},
+ });
+
+ render(, {
+ organization: orgWithCursor,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ await screen.findByText(/Automation/i);
+
+ // Toggle should not be visible when no Cursor integration exists
+ expect(
+ screen.queryByRole('checkbox', {name: /Hand off to Cursor/i})
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows error message when Auto-open PR toggle fails', async () => {
+ jest.spyOn(indicators, 'addErrorMessage');
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ method: 'PUT',
+ body: {},
+ });
+
+ const seerPreferencesPostRequest = MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
+ method: 'POST',
+ statusCode: 500,
+ body: {detail: 'Internal Server Error'},
+ });
+
+ render(, {
+ organization,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ const toggle = await screen.findByRole('checkbox', {name: /Auto-open PR/i});
+ await userEvent.click(toggle);
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequest).toHaveBeenCalled();
+ });
+
+ // Should show error message
+ expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+ 'Failed to update auto-open PR setting'
+ );
+ });
+
+ it('shows error message when Cursor handoff toggle fails', async () => {
+ jest.spyOn(indicators, 'addErrorMessage');
+
+ const orgWithCursor = OrganizationFixture({
+ features: ['autofix-seer-preferences', 'integrations-cursor'],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/seer/setup-check/`,
+ method: 'GET',
+ body: {
+ setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true},
+ billing: {hasAutofixQuota: true, hasScannerQuota: true},
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/repos/`,
+ query: {status: 'active'},
+ method: 'GET',
+ body: [],
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'GET',
+ body: {code_mapping_repos: []},
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithCursor.slug}/integrations/coding-agents/`,
+ method: 'GET',
+ body: {
+ integrations: [{id: '123', name: 'Cursor', provider: 'cursor'}],
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/`,
+ method: 'PUT',
+ body: {},
+ });
+
+ const seerPreferencesPostRequest = MockApiClient.addMockResponse({
+ url: `/projects/${orgWithCursor.slug}/${project.slug}/seer/preferences/`,
+ method: 'POST',
+ statusCode: 500,
+ body: {detail: 'Internal Server Error'},
+ });
+
+ render(, {
+ organization: orgWithCursor,
+ outletContext: {
+ project: ProjectFixture({
+ features: ['triage-signals-v0'],
+ autofixAutomationTuning: 'medium',
+ }),
+ },
+ });
+
+ const toggle = await screen.findByRole('checkbox', {name: /Hand off to Cursor/i});
+ await userEvent.click(toggle);
+
+ await waitFor(() => {
+ expect(seerPreferencesPostRequest).toHaveBeenCalled();
+ });
+
+ // Should show error message
+ expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+ 'Failed to update Cursor handoff setting'
+ );
+ });
+ });
});
diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx
index be0f3112d413cb..a57b023ef5efcf 100644
--- a/static/app/views/settings/projectSeer/index.tsx
+++ b/static/app/views/settings/projectSeer/index.tsx
@@ -2,12 +2,16 @@ import {Fragment, useCallback} from 'react';
import styled from '@emotion/styled';
import {useQueryClient} from '@tanstack/react-query';
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
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 {
+ makeProjectSeerPreferencesQueryKey,
+ useProjectSeerPreferences,
+} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences';
import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences';
import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types';
import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix';
@@ -254,6 +258,8 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
[updateProjectSeerPreferences, preference?.repositories, cursorIntegration]
);
+ // Handler for Cursor's "Auto-Create PR" toggle (from PR #103730)
+ // Controls whether Cursor agent auto-creates PRs
const handleAutoCreatePrChange = useCallback(
(value: boolean) => {
if (!preference?.automation_handoff) {
@@ -271,6 +277,7 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
[preference, updateProjectSeerPreferences]
);
+ // Handler for changing which integration is used for automation handoff
const handleIntegrationChange = useCallback(
(integrationId: number) => {
if (!preference?.automation_handoff) {
@@ -288,6 +295,107 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
[preference, updateProjectSeerPreferences]
);
+ // Handler for Auto-open PR toggle (triage-signals-v0)
+ // Controls whether Seer auto-opens PRs
+ // OFF = stop at code_changes, ON = stop at open_pr
+ const handleAutoOpenPrChange = useCallback(
+ (value: boolean) => {
+ updateProjectSeerPreferences(
+ {
+ repositories: preference?.repositories || [],
+ automated_run_stopping_point: value ? 'open_pr' : 'code_changes',
+ automation_handoff: undefined, // Clear cursor handoff when using Seer PR
+ },
+ {
+ onError: () => {
+ addErrorMessage(t('Failed to update auto-open PR setting'));
+ // Refetch to reset form state to backend value
+ queryClient.invalidateQueries({
+ queryKey: [
+ makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
+ ],
+ });
+ },
+ }
+ );
+ },
+ [
+ updateProjectSeerPreferences,
+ preference?.repositories,
+ queryClient,
+ organization.slug,
+ project.slug,
+ ]
+ );
+
+ // Handler for Cursor handoff toggle (triage-signals-v0)
+ // When ON: stops at root_cause and hands off to Cursor
+ // When OFF: defaults to code_changes (user can then enable auto-open PR if desired)
+ const handleCursorHandoffChange = useCallback(
+ (value: boolean) => {
+ if (value) {
+ if (!cursorIntegration) {
+ addErrorMessage(
+ t('Cursor integration not found. Please refresh the page and try again.')
+ );
+ return;
+ }
+ 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),
+ auto_create_pr: false,
+ },
+ },
+ {
+ onError: () => {
+ addErrorMessage(t('Failed to update Cursor handoff setting'));
+ // Refetch to reset form state to backend value
+ queryClient.invalidateQueries({
+ queryKey: [
+ makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
+ ],
+ });
+ },
+ }
+ );
+ } else {
+ // When turning OFF, default to code_changes
+ // User can then manually enable auto-open PR if desired
+ updateProjectSeerPreferences(
+ {
+ repositories: preference?.repositories || [],
+ automated_run_stopping_point: 'code_changes',
+ automation_handoff: undefined,
+ },
+ {
+ onError: () => {
+ addErrorMessage(t('Failed to update Cursor handoff setting'));
+ // Refetch to reset form state to backend value
+ queryClient.invalidateQueries({
+ queryKey: [
+ makeProjectSeerPreferencesQueryKey(organization.slug, project.slug),
+ ],
+ });
+ },
+ }
+ );
+ }
+ },
+ [
+ updateProjectSeerPreferences,
+ preference?.repositories,
+ cursorIntegration,
+ queryClient,
+ organization.slug,
+ project.slug,
+ ]
+ );
+
const automatedRunStoppingPointField = {
name: 'automated_run_stopping_point',
label: t('Where should Seer stop?'),
@@ -352,6 +460,48 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
},
} satisfies FieldObject;
+ // For triage-signals-v0: Simple toggle for Auto-open PR
+ // OFF = stop at code_changes, ON = stop at open_pr
+ const autoOpenPrToggleField = {
+ name: 'autoOpenPr',
+ label: t('Auto-open PR'),
+ help: () =>
+ t(
+ 'When enabled, Seer will automatically open a pull request after writing code changes.'
+ ),
+ type: 'boolean',
+ saveOnBlur: true,
+ onChange: handleAutoOpenPrChange,
+ getData: () => ({}), // Prevent default form submission, onChange handles it
+ visible: ({model}) => {
+ const tuningValue = model?.getValue('autofixAutomationTuning');
+ return typeof tuningValue === 'boolean' ? tuningValue : tuningValue !== 'off';
+ },
+ disabled: ({model}) => model?.getValue('cursorHandoff') === true,
+ } satisfies FieldObject;
+
+ // For triage-signals-v0: Simple toggle for Cursor handoff
+ // When ON: stops at root_cause and hands off to Cursor
+ const cursorHandoffToggleField = {
+ name: 'cursorHandoff',
+ label: t('Hand off to Cursor'),
+ help: () =>
+ t(
+ "When enabled, Seer will identify the root cause and hand off the fix to Cursor's cloud agent."
+ ),
+ type: 'boolean',
+ saveOnBlur: true,
+ onChange: handleCursorHandoffChange,
+ getData: () => ({}), // Prevent default form submission, onChange handles it
+ visible: ({model}) => {
+ const tuningValue = model?.getValue('autofixAutomationTuning');
+ const automationEnabled =
+ typeof tuningValue === 'boolean' ? tuningValue : tuningValue !== 'off';
+ return automationEnabled && hasCursorIntegration;
+ },
+ disabled: ({model}) => model?.getValue('autoOpenPr') === true,
+ } satisfies FieldObject;
+
const seerFormGroups: JsonFormObject[] = [
{
title: (
@@ -380,7 +530,10 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
isTriageSignalsFeatureOn
? autofixAutomationToggleField
: autofixAutomatingTuningField,
- automatedRunStoppingPointField,
+ // Flag ON: show new toggles; Flag OFF: show old dropdown
+ ...(isTriageSignalsFeatureOn
+ ? [autoOpenPrToggleField, cursorHandoffToggleField]
+ : [automatedRunStoppingPointField]),
],
},
];
@@ -409,9 +562,15 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
// Same DB field, different UI: toggle (boolean) vs dropdown (string)
// When triage signals flag is on, default to true (ON)
autofixAutomationTuning: automationTuning,
+ // For non-flag mode (dropdown)
automated_run_stopping_point: preference?.automation_handoff
? 'cursor_handoff'
: (preference?.automated_run_stopping_point ?? 'root_cause'),
+ // For triage-signals-v0 mode (toggles) - only include when flag is on
+ ...(isTriageSignalsFeatureOn && {
+ autoOpenPr: preference?.automated_run_stopping_point === 'open_pr',
+ cursorHandoff: Boolean(preference?.automation_handoff),
+ }),
}}
onSubmitSuccess={handleSubmitSuccess}
additionalFieldProps={{organization}}