diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 5629e642dd33b0..734bf51848b95f 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -315,6 +315,7 @@ interface SeerAutomationHandoffConfiguration { handoff_point: 'root_cause'; integration_id: number; target: 'cursor_background_agent'; + auto_create_pr?: boolean; } export interface ProjectSeerPreferences { diff --git a/static/app/views/settings/projectSeer/index.spec.tsx b/static/app/views/settings/projectSeer/index.spec.tsx index 24eaeb2090b6cc..cd2efb3a047b73 100644 --- a/static/app/views/settings/projectSeer/index.spec.tsx +++ b/static/app/views/settings/projectSeer/index.spec.tsx @@ -304,21 +304,12 @@ describe('ProjectSeer', () => { // Open the menu and select a new value await userEvent.click(select); - const options = await screen.findAllByText('Minimally Actionable and Above'); + const options = await screen.findAllByText('Highly Actionable and Above'); expect(options[0]).toBeDefined(); if (options[0]) { await userEvent.click(options[0]); } - // Reopen the menu to select another value - await userEvent.click(select); - - const options2 = await screen.findAllByText('Highly Actionable and Above'); - expect(options2[0]).toBeDefined(); - if (options2[0]) { - await userEvent.click(options2[0]); - } - // Form has saveOnBlur=true, so wait for the PUT request await waitFor(() => { expect(projectPutRequest).toHaveBeenCalledTimes(1); @@ -537,6 +528,7 @@ describe('ProjectSeer', () => { handoff_point: 'root_cause', target: 'cursor_background_agent', integration_id: 123, + auto_create_pr: false, }, }), }) @@ -699,4 +691,218 @@ describe('ProjectSeer', () => { }); }); }); + + describe('Auto Create PR Setting', () => { + it('does not render when stopping point is not cursor_handoff', async () => { + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + render(, { + organization, + outletContext: {project: initialProject}, + }); + + // Wait for the page to load + await screen.findByText(/Automation/i); + + // The toggle should NOT be visible when stopping point is not cursor_handoff + expect( + screen.queryByRole('checkbox', { + name: /Auto-Create Pull Requests/i, + }) + ).not.toBeInTheDocument(); + }); + + it('renders and loads initial value when cursor_handoff is selected', async () => { + MockApiClient.clearMockResponses(); + + const orgWithCursorFeature = OrganizationFixture({ + features: ['autofix-seer-preferences', 'integrations-cursor'], + }); + + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, + method: 'GET', + body: { + setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true}, + billing: {hasAutofixQuota: true, hasScannerQuota: true}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/repos/`, + query: {status: 'active'}, + method: 'GET', + body: [], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/integrations/coding-agents/`, + method: 'GET', + body: { + integrations: [ + { + id: '123', + name: 'Cursor', + provider: 'cursor', + }, + ], + }, + }); + + // Mock preferences with automation_handoff including auto_create_pr + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: { + organization_id: orgWithCursorFeature.id, + project_id: project.id, + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: true, + }, + }, + code_mapping_repos: [], + }, + }); + + render(, { + organization: orgWithCursorFeature, + outletContext: {project: initialProject}, + }); + + // Wait for the toggle to load + const toggle = await screen.findByRole('checkbox', { + name: /Auto-Create Pull Requests/i, + }); + + // Verify it's checked + await waitFor(() => { + expect(toggle).toBeChecked(); + }); + }); + + it('calls update mutation when toggled', async () => { + MockApiClient.clearMockResponses(); + + const orgWithCursorFeature = OrganizationFixture({ + features: ['autofix-seer-preferences', 'integrations-cursor'], + }); + + const initialProject: Project = { + ...project, + autofixAutomationTuning: 'medium', + seerScannerAutomation: true, + }; + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/seer/setup-check/`, + method: 'GET', + body: { + setupAcknowledgement: {orgHasAcknowledged: true, userHasAcknowledged: true}, + billing: {hasAutofixQuota: true, hasScannerQuota: true}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/repos/`, + query: {status: 'active'}, + method: 'GET', + body: [], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${orgWithCursorFeature.slug}/integrations/coding-agents/`, + method: 'GET', + body: { + integrations: [ + { + id: '123', + name: 'Cursor', + provider: 'cursor', + }, + ], + }, + }); + + // Mock preferences with automation_handoff + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: { + repositories: [], + automated_run_stopping_point: 'root_cause', + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }, + code_mapping_repos: [], + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/`, + method: 'PUT', + body: {}, + }); + + // Mock for the Form's empty apiEndpoint POST + MockApiClient.addMockResponse({ + url: '', + method: 'POST', + body: {}, + }); + + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${orgWithCursorFeature.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + }); + + render(, { + organization: orgWithCursorFeature, + outletContext: {project: initialProject}, + }); + + // Find and click the toggle + const toggle = await screen.findByRole('checkbox', { + name: /Auto-Create Pull Requests/i, + }); + expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + + // Wait for the POST request to be called + await waitFor(() => { + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: true, + }), + repositories: expect.any(Array), + }), + }) + ); + }); + }); + }); }); diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index 97731e9361f983..40221ea3488fbb 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -9,6 +9,7 @@ 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 type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; import {useOrganizationSeerSetup} from 'sentry/components/events/autofix/useOrganizationSeerSetup'; import Form from 'sentry/components/forms/form'; @@ -100,6 +101,58 @@ const autofixAutomationToggleField = { }), } satisfies FieldObject; +function CodingAgentSettings({ + preference, + handleAutoCreatePrChange, + canWriteProject, + isAutomationOn, +}: { + canWriteProject: boolean; + handleAutoCreatePrChange: (value: boolean) => void; + preference: ProjectSeerPreferences | null | undefined; + isAutomationOn?: boolean; +}) { + if (!preference?.automation_handoff || !isAutomationOn) { + return null; + } + + const initialValue = preference?.automation_handoff?.auto_create_pr ?? false; + + return ( +
+ ({}), + getValue: () => initialValue, + disabled: !canWriteProject, + onChange: handleAutoCreatePrChange, + } satisfies FieldObject, + ], + }, + ]} + /> + + ); +} + function ProjectSeerGeneralForm({project}: {project: Project}) { const organization = useOrganization(); const queryClient = useQueryClient(); @@ -146,6 +199,7 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { handoff_point: 'root_cause', target: 'cursor_background_agent', integration_id: parseInt(cursorIntegration.id, 10), + auto_create_pr: false, }, }); } else { @@ -159,6 +213,23 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { [updateProjectSeerPreferences, preference?.repositories, cursorIntegration] ); + const handleAutoCreatePrChange = useCallback( + (value: boolean) => { + if (!preference?.automation_handoff) { + return; + } + updateProjectSeerPreferences({ + repositories: preference?.repositories || [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: { + ...preference.automation_handoff, + auto_create_pr: value, + }, + }); + }, + [preference, updateProjectSeerPreferences] + ); + const automatedRunStoppingPointField = { name: 'automated_run_stopping_point', label: t('Where should Seer stop?'), @@ -256,6 +327,10 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { }, ]; + const automationTuning = isTriageSignalsFeatureOn + ? (project.autofixAutomationTuning ?? 'off') !== 'off' + : (project.autofixAutomationTuning ?? 'off'); + return (
+
); }