Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions static/app/components/events/autofix/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ interface SeerAutomationHandoffConfiguration {
handoff_point: 'root_cause';
integration_id: number;
target: 'cursor_background_agent';
auto_create_pr?: boolean;
}

export interface ProjectSeerPreferences {
Expand Down
226 changes: 216 additions & 10 deletions static/app/views/settings/projectSeer/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -537,6 +528,7 @@ describe('ProjectSeer', () => {
handoff_point: 'root_cause',
target: 'cursor_background_agent',
integration_id: 123,
auto_create_pr: false,
},
}),
})
Expand Down Expand Up @@ -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(<ProjectSeer />, {
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(<ProjectSeer />, {
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(<ProjectSeer />, {
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),
}),
})
);
});
});
});
});
85 changes: 82 additions & 3 deletions static/app/views/settings/projectSeer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Form
key={`coding-agent-settings-${initialValue}`}
apiMethod="POST"
saveOnBlur
initialData={{
auto_create_pr: initialValue,
}}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Form makes API requests to empty endpoint

The CodingAgentSettings Form is missing an apiEndpoint prop but has saveOnBlur enabled. When the auto_create_pr field is blurred, the form attempts to POST to an empty endpoint. While getData: () => ({}) on line 143 prevents data from being sent, the API request still fires to an empty URL, which interferes with the intended onChange handler on line 146 that actually updates preferences. This explains why "the request stops happening in tests" as noted in the PR discussion.

Fix in Cursor Fix in Web

<JsonForm
forms={[
{
title: t('Cursor Agent Settings'),
fields: [
{
name: 'auto_create_pr',
label: t('Auto-Create Pull Requests'),
help: t(
'When enabled, Cursor Cloud Agents will automatically create pull requests after hand off.'
),
saveOnBlur: true,
type: 'boolean',
getData: () => ({}),
getValue: () => initialValue,
Comment on lines +143 to +144
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to stop the request from actually happening --- in tests at least

disabled: !canWriteProject,
onChange: handleAutoCreatePrChange,
} satisfies FieldObject,
],
},
]}
/>
</Form>
);
}

function ProjectSeerGeneralForm({project}: {project: Project}) {
const organization = useOrganization();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -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 {
Expand All @@ -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?'),
Expand Down Expand Up @@ -256,6 +327,10 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
},
];

const automationTuning = isTriageSignalsFeatureOn
? (project.autofixAutomationTuning ?? 'off') !== 'off'
: (project.autofixAutomationTuning ?? 'off');

return (
<Fragment>
<Form
Expand All @@ -271,9 +346,7 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
initialData={{
seerScannerAutomation: project.seerScannerAutomation ?? false,
// Same DB field, different UI: toggle (boolean) vs dropdown (string)
autofixAutomationTuning: isTriageSignalsFeatureOn
? (project.autofixAutomationTuning ?? 'off') !== 'off'
: (project.autofixAutomationTuning ?? 'off'),
autofixAutomationTuning: automationTuning,
automated_run_stopping_point: preference?.automation_handoff
? 'cursor_handoff'
: (preference?.automated_run_stopping_point ?? 'root_cause'),
Expand All @@ -291,6 +364,12 @@ function ProjectSeerGeneralForm({project}: {project: Project}) {
)}
/>
</Form>
<CodingAgentSettings
preference={preference}
handleAutoCreatePrChange={handleAutoCreatePrChange}
isAutomationOn={automationTuning && automationTuning !== 'off'}
canWriteProject={canWriteProject}
/>
</Fragment>
);
}
Expand Down
Loading