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}}