diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx index 9c434d25068789..e7f430a9c19426 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx @@ -417,9 +417,13 @@ describe('autofixOverviewSection', () => { it('shows "No projects found" when there are no projects', async () => { renderSection([], {projects: []}); - // Each form section renders this text, so use findAllByText - const messages = await screen.findAllByText('No projects found'); - expect(messages.length).toBeGreaterThanOrEqual(2); + // Each form section renders this text; AgentNameForm only shows it after + // the integrations query resolves, so use waitFor to retry until both appear + await waitFor(() => { + expect(screen.getAllByText('No projects found').length).toBeGreaterThanOrEqual( + 2 + ); + }); }); it('shows "Your existing project uses Seer Agent" when 1 project uses preferred agent', async () => { diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 4ce19c63456e61..9e17fee181cba3 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -15,21 +15,24 @@ import { bulkAutofixAutomationSettingsInfiniteOptions, type AutofixAutomationSettings, } from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; -import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {Placeholder} from 'sentry/components/placeholder'; import {IconSettings} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; -import {fetchMutation, useQuery} from 'sentry/utils/queryClient'; +import {fetchMutation} from 'sentry/utils/queryClient'; import {useInfiniteQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; import { - useAgentOptions, - useBulkMutateCreatePr, + getPreferredAgentMutationOptions, + useFetchPreferredAgent, + useFetchPreferredAgentOptions, useBulkMutateSelectedAgent, -} from 'sentry/views/settings/seer/seerAgentHooks'; +} from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; +import {useBulkMutateCreatePr} from 'sentry/views/settings/seer/seerAgentHooks'; export function useAutofixOverviewData() { const organization = useOrganization(); @@ -81,6 +84,9 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} const {projects} = useProjects(); const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {}; + const projectsIdsWithPreferredAgent = new Set( + projectsWithPreferredAgent.map(s => s.projectId) + ); const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false); const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false); @@ -108,7 +114,7 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} isBulkMutatingCreatePr={isBulkMutatingCreatePr} organization={organization} projects={projects} - projectsWithPreferredAgent={projectsWithPreferredAgent} + projectsIdsWithPreferredAgent={projectsIdsWithPreferredAgent} /> ; setIsBulkMutatingAgent: (value: boolean) => void; }) { - const {data: integrations} = useQuery( - organizationIntegrationsCodingAgents(organization) - ); - const rawAgentOptions = useAgentOptions({ - integrations: integrations?.integrations ?? [], - }).filter(option => option.value !== 'none'); - const codingAgentOptions = rawAgentOptions.map(option => ({ - value: option.value === 'seer' ? 'seer' : String(option.value.id), - label: option.label, - })); - - const codingAgentMutationOpts = mutationOptions({ - mutationFn: ({agentId}: {agentId: string}) => { - return fetchMutation({ - method: 'PUT', - url: `/organizations/${organization.slug}/`, - data: - agentId === 'seer' - ? { - defaultCodingAgent: agentId, - defaultCodingAgentIntegrationId: null, - } - : { - defaultCodingAgent: rawAgentOptions - .filter(option => option.value !== 'seer') - .find(option => option.value.id === agentId)?.value.provider, - defaultCodingAgentIntegrationId: agentId, - }, - }); - }, - onSuccess: updateOrganization, + const preferredAgent = useFetchPreferredAgent({organization}); + const codingAgentSelectOptions = useFetchPreferredAgentOptions({organization}); + const codingAgentMutationOptions = getPreferredAgentMutationOptions({organization}); + const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ + projects: projects.filter(p => !projectsIdsWithPreferredAgent.has(p.id)), }); - const preferredAgentValue = organization.defaultCodingAgentIntegrationId - ? String(organization.defaultCodingAgentIntegrationId) - : organization.defaultCodingAgent - ? organization.defaultCodingAgent - : 'seer'; - - const preferredAgentLabel = codingAgentOptions.find( - option => option.value === preferredAgentValue + const preferredAgentLabel = codingAgentSelectOptions.data?.find( + o => o.value === preferredAgent.data )?.label; - const preferredAgentIntegration = - preferredAgentValue === 'seer' - ? 'seer' - : rawAgentOptions - .filter(option => option.value !== 'seer') - .find(option => option.value.id === preferredAgentValue)?.value; - - const preferredAgentProjectIds = new Set( - projectsWithPreferredAgent.map(s => s.projectId) - ); - const projectsToUpdate = projects.filter(p => !preferredAgentProjectIds.has(p.id)); - - const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ - projects: projectsToUpdate, - }); + const initialValue = preferredAgent.data ? preferredAgent.data : ('seer' as const); return ( ()]), + })} + initialValue={initialValue} + mutationOptions={codingAgentMutationOptions} > {field => ( @@ -219,12 +181,24 @@ function AgentNameForm({ )} > - + {preferredAgent.isPending || codingAgentSelectOptions.isPending ? ( + + ) : codingAgentSelectOptions.isError ? ( + + {t('Failed to fetch coding agent options')} + + ) : ( + + a === b || + (typeof a === 'object' && typeof b === 'object' && a.id === b.id) + } + /> + )} @@ -236,13 +210,14 @@ function AgentNameForm({ !canWrite || isBulkMutatingAgent || isBulkMutatingCreatePr || - !preferredAgentIntegration || - projectsWithPreferredAgent.length === projects.length + preferredAgent.isPending || + codingAgentSelectOptions.isPending || + projectsIdsWithPreferredAgent.size === projects.length } onClick={async () => { - if (preferredAgentIntegration) { + if (preferredAgent.data) { setIsBulkMutatingAgent(true); - await bulkMutateSelectedAgent(preferredAgentIntegration, {}); + await bulkMutateSelectedAgent(preferredAgent.data); setIsBulkMutatingAgent(false); } else { addErrorMessage(t('No coding agent integration found')); @@ -252,25 +227,27 @@ function AgentNameForm({ {tn( 'Set for the existing project', 'Set for all existing projects', - projectsWithPreferredAgent.length + projectsIdsWithPreferredAgent.size )} - - {projects.length === 0 - ? t('No projects found') - : projects.length === 1 - ? projectsWithPreferredAgent.length === 1 - ? t('Your existing project uses %s', preferredAgentLabel) - : t('Your existing project does not use %s', preferredAgentLabel) - : projects.length === projectsWithPreferredAgent.length - ? t('All existing projects use %s', preferredAgentLabel) - : t( - '%s of %s existing projects use %s', - projectsWithPreferredAgent.length, - projects.length, - preferredAgentLabel - )} - + {preferredAgentLabel ? ( + + {projects.length === 0 + ? t('No projects found') + : projects.length === 1 + ? projectsIdsWithPreferredAgent.size === 1 + ? t('Your existing project uses %s', preferredAgentLabel) + : t('Your existing project does not use %s', preferredAgentLabel) + : projects.length === projectsIdsWithPreferredAgent.size + ? t('All existing projects use %s', preferredAgentLabel) + : t( + '%s of %s existing projects use %s', + projectsIdsWithPreferredAgent.size, + projects.length, + preferredAgentLabel + )} + + ) : null} )} diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts new file mode 100644 index 00000000000000..8ea7f4d9a2fcb6 --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -0,0 +1,476 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import * as indicators from 'sentry/actionCreators/indicator'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {OrganizationsStore} from 'sentry/stores/organizationsStore'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {useMutation} from 'sentry/utils/queryClient'; +import { + useBulkMutateSelectedAgent, + useFetchPreferredAgent, + useFetchPreferredAgentOptions, + getPreferredAgentMutationOptions, +} from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; + +describe('seerPreferredAgent', () => { + const organization = OrganizationFixture({slug: 'org-slug'}); + const project = ProjectFixture({slug: 'project-slug', id: '1'}); + + const integrations: CodingAgentIntegration[] = [ + {id: '42', name: 'Cursor', provider: 'cursor'}, + {id: '99', name: 'Claude Code', provider: 'claude_code'}, + ]; + + function mockIntegrationsEndpoint( + body: {integrations: CodingAgentIntegration[]} = {integrations} + ) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + method: 'GET', + body, + }); + } + + beforeEach(() => { + ProjectsStore.loadInitialData([project]); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + jest.resetAllMocks(); + ProjectsStore.reset(); + }); + + describe('useFetchPreferredAgent', () => { + it('returns "seer" when no defaultCodingAgent or defaultCodingAgentIntegrationId', () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: null, + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + expect(result.current.data).toBe('seer'); + }); + + it('uses defaultCodingAgentIntegrationId when set', async () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgentIntegrationId: 42, + defaultCodingAgent: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({id: '42', name: 'Cursor'}); + }); + + it('falls back to defaultCodingAgent when no defaultCodingAgentIntegrationId', async () => { + mockIntegrationsEndpoint(); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'claude_code', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + // 'claude_code' won't match any integration id (ids are '42', '99'), so returns 'seer' + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('seer'); + }); + + it('returns matching integration when defaultCodingAgent matches integration id', async () => { + mockIntegrationsEndpoint({ + integrations: [{id: 'cursor', name: 'Cursor', provider: 'cursor'}], + }); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({id: 'cursor', name: 'Cursor'}); + }); + + it('returns "seer" when no integration matches the value', async () => { + mockIntegrationsEndpoint({integrations: []}); + const org = OrganizationFixture({ + slug: 'org-slug', + defaultCodingAgent: 'nonexistent', + defaultCodingAgentIntegrationId: null, + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgent, { + initialProps: {organization: org}, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('seer'); + }); + }); + + describe('useFetchPreferredAgentOptions', () => { + it('includes "seer" as first option plus integration options', async () => { + mockIntegrationsEndpoint(); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const options = result.current.data!; + expect(options).toHaveLength(3); + expect(options[0]).toEqual({value: 'seer', label: expect.any(String)}); + expect(options[1]).toMatchObject({ + value: {id: '42', name: 'Cursor'}, + label: 'Cursor', + }); + expect(options[2]).toMatchObject({ + value: {id: '99', name: 'Claude Code'}, + label: 'Claude Code', + }); + }); + + it('filters out integrations without an id', async () => { + mockIntegrationsEndpoint({ + integrations: [ + {id: null, name: 'No Id', provider: 'other'}, + {id: '1', name: 'With Id', provider: 'cursor'}, + ], + }); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const options = result.current.data!; + expect(options).toHaveLength(2); // seer + one integration + expect(options[1]).toMatchObject({ + value: {id: '1', name: 'With Id'}, + label: 'With Id', + }); + }); + + it('returns only "seer" when there are no integrations', async () => { + mockIntegrationsEndpoint({integrations: []}); + + const {result} = renderHookWithProviders(useFetchPreferredAgentOptions, { + initialProps: {organization}, + organization, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data![0]).toEqual({value: 'seer', label: expect.any(String)}); + }); + }); + + describe('usePreferredAgentMutationOptions', () => { + function mockOrgPutRequest() { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + method: 'PUT', + body: OrganizationFixture({slug: organization.slug}), + }); + } + + beforeEach(() => { + OrganizationsStore.addOrReplace(organization); + }); + + it('sends PUT with seer payload when integration is "seer"', async () => { + mockIntegrationsEndpoint(); + const orgPutRequest = mockOrgPutRequest(); + + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, + }); + + act(() => { + result.current.mutateAsync({integration: 'seer'}); + }); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }, + }) + ); + }); + + it('sends PUT with integration payload when integration is a CodingAgentIntegration', async () => { + mockIntegrationsEndpoint(); + const orgPutRequest = mockOrgPutRequest(); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, + }); + + act(() => { + result.current.mutateAsync({integration}); + }); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: expect.objectContaining({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: '42', + }), + }) + ); + }); + }); + + describe('useBulkMutateSelectedAgent', () => { + const preference: SeerPreferencesResponse['preference'] = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + function setupMocks( + preferenceOverride: SeerPreferencesResponse['preference'] = preference + ) { + const seerPreferencesGetRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: preferenceOverride, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + const projectPutRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + body: project, + }); + const seerPreferencesPostRequest = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + return {seerPreferencesGetRequest, projectPutRequest, seerPreferencesPostRequest}; + } + + it('sets autofixAutomationTuning to "medium" and clears handoff when integration is "seer"', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: undefined, + }), + }) + ); + }); + + it('sets handoff payload when integration is a CodingAgentIntegration', async () => { + const {projectPutRequest, seerPreferencesPostRequest} = setupMocks(); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current(integration); + }); + + expect(projectPutRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/`, + expect.objectContaining({ + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + handoff_point: 'root_cause', + integration_id: 42, + }), + }), + }) + ); + }); + + it('sets auto_create_pr true when automated_run_stopping_point is "open_pr"', async () => { + const {seerPreferencesPostRequest} = setupMocks({ + repositories: [], + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }); + const integration: CodingAgentIntegration = { + id: '42', + name: 'Cursor', + provider: 'cursor', + }; + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + await act(async () => { + await result.current(integration); + }); + + expect(seerPreferencesPostRequest).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('updates ProjectsStore on success', async () => { + setupMocks(); + const updateSuccessSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(updateSuccessSpy).toHaveBeenCalledWith({ + id: project.id, + autofixAutomationTuning: 'medium', + }); + }); + + it('shows a generic error message when requests fail with non-429 errors', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: {preference, code_mapping_repos: []}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + statusCode: 500, + body: {detail: 'Internal Server Error'}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + statusCode: 500, + body: {detail: 'Internal Server Error'}, + }); + const addErrorMessageSpy = jest.spyOn(indicators, 'addErrorMessage'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(addErrorMessageSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to update settings') + ); + }); + + it('shows a rate-limit error message when requests fail with 429', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'GET', + body: {preference, code_mapping_repos: []}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + method: 'PUT', + statusCode: 429, + body: {detail: 'Too Many Requests'}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + method: 'POST', + statusCode: 429, + body: {detail: 'Too Many Requests'}, + }); + const addErrorMessageSpy = jest.spyOn(indicators, 'addErrorMessage'); + + const {result} = renderHookWithProviders(useBulkMutateSelectedAgent, { + organization, + initialProps: {projects: [project]}, + }); + + await act(async () => { + await result.current('seer'); + }); + + expect(addErrorMessageSpy).toHaveBeenCalledWith( + expect.stringContaining('Too many requests') + ); + }); + }); +}); diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts new file mode 100644 index 00000000000000..a0f7fb6f18132f --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -0,0 +1,186 @@ +import {useCallback} from 'react'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {updateOrganization} from 'sentry/actionCreators/organizations'; +import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import {PROVIDER_TO_HANDOFF_TARGET} from 'sentry/components/events/autofix/types'; +import type {ProjectSeerPreferences} from 'sentry/components/events/autofix/types'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import {t} from 'sentry/locale'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {SelectValue} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import {processInChunks} from 'sentry/utils/array/procesInChunks'; +import { + fetchDataQuery, + fetchMutation, + useQueryClient, + mutationOptions, + useQuery, +} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +type PreferredAgent = 'seer' | CodingAgentIntegration; + +export function useFetchPreferredAgent({organization}: {organization: Organization}) { + const value = organization.defaultCodingAgentIntegrationId + ? String(organization.defaultCodingAgentIntegrationId) + : organization.defaultCodingAgent; + + const query = useQuery({ + ...organizationIntegrationsCodingAgents(organization), + enabled: value !== null && value !== 'seer', + select: data => + data.json.integrations?.find(i => i.id === String(value!)) ?? ('seer' as const), + }); + + if (value === null || value === 'seer') { + return { + ...query, + data: 'seer' as const, + isPending: false, + isSuccess: true, + status: 'success', + }; + } + return query; +} + +export function useFetchPreferredAgentOptions({ + organization, +}: { + organization: Organization; +}) { + return useQuery({ + ...organizationIntegrationsCodingAgents(organization), + select: data => { + return [ + {value: 'seer', label: t('Seer Agent')} as SelectValue, + ...(data.json.integrations ?? []) + .filter(integration => integration.id) + .map(integration => ({ + value: integration, + label: integration.name, + })), + ] as const; + }, + }); +} + +export function getPreferredAgentMutationOptions({ + organization, +}: { + organization: Organization; +}) { + return mutationOptions({ + mutationFn: ({integration}: {integration: PreferredAgent}) => { + return fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: + integration === 'seer' + ? { + defaultCodingAgent: integration, + defaultCodingAgentIntegrationId: null, + } + : { + defaultCodingAgent: integration.provider, + defaultCodingAgentIntegrationId: integration.id, + }, + }); + }, + onSuccess: updateOrganization, + }); +} + +export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + async (integration: PreferredAgent) => { + const results = await processInChunks({ + items: projects, + chunkSize: 15, + fn: async project => { + const [preferencesData] = await queryClient.fetchQuery({ + queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + queryFn: fetchDataQuery, + staleTime: 0, + }); + const preference = preferencesData?.preference; + + const handoff: ProjectSeerPreferences['automation_handoff'] = + integration !== 'seer' && integration + ? { + handoff_point: 'root_cause', + target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!, + integration_id: Number(integration.id), + auto_create_pr: preference?.automated_run_stopping_point === 'open_pr', + } + : undefined; + + return Promise.all([ + fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data: {autofixAutomationTuning: 'medium'}, + }), + fetchMutation({ + method: 'POST', + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + data: { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: handoff, + }, + }), + ]); + }, + }); + + // Update store only for projects that succeeded + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + ProjectsStore.onUpdateSuccess({ + id: projects[i]!.id, + autofixAutomationTuning: 'medium', + }); + } + }); + + // Always invalidate to sync cache with whatever the server actually saved + queryClient.invalidateQueries({ + queryKey: autofixSettingsQueryOptions.queryKey, + }); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length) { + const has429 = failures.some( + r => r.reason instanceof RequestError && r.reason.status === 429 + ); + if (has429) { + addErrorMessage( + t('Too many requests. Please wait a moment before trying again.') + ); + } else { + addErrorMessage( + t( + 'Failed to update settings for %s project(s). Please try again.', + failures.length + ) + ); + } + } + }, + [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] + ); +} diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx index bb72d9c17e7663..5a34dbf3596a6c 100644 --- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -15,7 +15,6 @@ import {useQueryClient} from 'sentry/utils/queryClient'; import { useAgentOptions, useBulkMutateCreatePr, - useBulkMutateSelectedAgent, useMutateCreatePr, useMutateSelectedAgent, useSelectedAgentFromBulkSettings, @@ -618,315 +617,6 @@ describe('seerAgentHooks', () => { }); }); - describe('useBulkMutateSelectedAgent', () => { - const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); - const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); - const projects = [project1, project2]; - - const basePreference: ProjectSeerPreferences = { - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - - function setupMocks(preference: ProjectSeerPreferences = basePreference) { - const mocks = projects.map(p => ({ - seerPreferencesGetRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'GET', - body: {preference, code_mapping_repos: []} satisfies SeerPreferencesResponse, - }), - projectPutRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/`, - method: 'PUT', - body: p, - }), - seerPreferencesPostRequest: MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }), - })); - return { - seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest), - projectPutRequests: mocks.map(m => m.projectPutRequest), - seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest), - }; - } - - function renderBulkMutateSelectedAgent() { - return renderHookWithProviders( - (props: {projects: typeof projects}) => { - const mutate = useBulkMutateSelectedAgent(props); - return {mutate}; - }, - { - initialProps: {projects}, - organization, - } - ); - } - - beforeEach(() => { - ProjectsStore.loadInitialData(projects); - }); - - it('sends correct API requests to all projects when integration is "seer"', async () => { - const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((p, i) => { - expect(projectPutRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - repositories: [], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }), - }) - ); - }); - }); - - it('sends correct API requests to all projects when integration is a CodingAgentIntegration', async () => { - const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); - const integration: CodingAgentIntegration = { - id: '123', - name: 'Cursor', - provider: 'cursor', - }; - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((p, i) => { - expect(projectPutRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/`, - expect.objectContaining({ - method: 'PUT', - data: {autofixAutomationTuning: 'medium'}, - }) - ); - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - automation_handoff: { - handoff_point: 'root_cause', - target: 'cursor_background_agent', - integration_id: 123, - auto_create_pr: false, - }, - }), - }) - ); - }); - }); - - it('sets auto_create_pr true when preference stopping point is open_pr', async () => { - const {seerPreferencesPostRequests} = setupMocks({ - ...basePreference, - automated_run_stopping_point: 'open_pr', - }); - const integration: CodingAgentIntegration = { - id: '456', - name: 'Cursor', - provider: 'cursor', - }; - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate(integration, {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((_p, i) => { - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - data: expect.objectContaining({ - automation_handoff: expect.objectContaining({ - auto_create_pr: true, - }), - }), - }) - ); - }); - }); - - it('preserves repositories from each project preference', async () => { - const preferenceWithRepos: ProjectSeerPreferences = { - repositories: [ - {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'}, - ], - automated_run_stopping_point: 'code_changes', - automation_handoff: undefined, - }; - const {seerPreferencesPostRequests} = setupMocks(preferenceWithRepos); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); - }); - - projects.forEach((_p, i) => { - expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - data: expect.objectContaining({ - repositories: [ - { - external_id: 'repo-1', - name: 'my-repo', - owner: 'my-org', - provider: 'github', - }, - ], - }), - }) - ); - }); - }); - - it('updates ProjectsStore for all projects', async () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {}); - }); - - await waitFor(() => { - expect(storeSpy).toHaveBeenCalledTimes(2); - }); - - projects.forEach(p => { - expect(storeSpy).toHaveBeenCalledWith({ - id: p.id, - autofixAutomationTuning: 'medium', - }); - }); - }); - - it('updates ProjectsStore with "off" tuning for all projects when integration is "none"', async () => { - setupMocks(); - const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('none', {}); - }); - - await waitFor(() => { - expect(storeSpy).toHaveBeenCalledTimes(2); - }); - - projects.forEach(p => { - expect(storeSpy).toHaveBeenCalledWith({ - id: p.id, - autofixAutomationTuning: 'off', - }); - }); - }); - - it('calls onSuccess when all requests succeed', async () => { - setupMocks(); - const onSuccess = jest.fn(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - it('calls onError when any request fails', async () => { - projects.forEach(p => { - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'GET', - body: { - preference: basePreference, - code_mapping_repos: [], - } satisfies SeerPreferencesResponse, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/`, - method: 'PUT', - statusCode: 500, - body: {}, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, - method: 'POST', - body: {}, - }); - }); - const onError = jest.fn(); - const {result} = renderBulkMutateSelectedAgent(); - - act(() => { - result.current.mutate('seer', {onError}); - }); - - await waitFor(() => { - expect(onError).toHaveBeenCalledTimes(1); - }); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); - - it('does nothing when projects list is empty', async () => { - const emptyProjectsMutate = renderHookWithProviders( - () => useBulkMutateSelectedAgent({projects: []}), - {organization} - ); - const onSuccess = jest.fn(); - - act(() => { - emptyProjectsMutate.result.current('seer', {onSuccess}); - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('useBulkMutateCreatePr', () => { const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx index 7aac636bce3226..770ed07b8e3f24 100644 --- a/static/app/views/settings/seer/seerAgentHooks.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.tsx @@ -214,93 +214,6 @@ export function useMutateSelectedAgent({project}: {project: Project}) { ); } -export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { - const organization = useOrganization(); - const queryClient = useQueryClient(); - const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ - organization, - }); - - return useCallback( - async ( - integration: 'seer' | 'none' | CodingAgentIntegration, - {onSuccess, onError}: MutateOptions - ) => { - const results = await processInChunks({ - items: projects, - chunkSize: 15, - fn: async project => { - const [preferencesData] = await queryClient.fetchQuery({ - queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), - queryFn: fetchDataQuery, - staleTime: 0, - }); - const preference = preferencesData?.preference; - - const handoff: ProjectSeerPreferences['automation_handoff'] = - integration !== 'seer' && integration !== 'none' && integration - ? { - handoff_point: 'root_cause', - target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!, - integration_id: Number(integration.id), - auto_create_pr: preference?.automated_run_stopping_point === 'open_pr', - } - : undefined; - - return Promise.all([ - fetchMutation({ - method: 'PUT', - url: `/projects/${organization.slug}/${project.slug}/`, - data: {autofixAutomationTuning: integration === 'none' ? 'off' : 'medium'}, - }), - fetchMutation({ - method: 'POST', - url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, - data: { - repositories: preference?.repositories ?? [], - automated_run_stopping_point: preference?.automated_run_stopping_point, - automation_handoff: handoff, - }, - }), - ]); - }, - }); - - // Update store only for projects that succeeded - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - ProjectsStore.onUpdateSuccess({ - id: projects[i]!.id, - autofixAutomationTuning: integration === 'none' ? 'off' : 'medium', - }); - } - }); - - // Always invalidate to sync cache with whatever the server actually saved - queryClient.invalidateQueries({ - queryKey: autofixSettingsQueryOptions.queryKey, - }); - - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length === 0) { - onSuccess?.(); - } else { - const has429 = failures.some( - r => r.reason instanceof RequestError && r.reason.status === 429 - ); - if (has429) { - addErrorMessage( - t('Too many requests. Please wait a moment before trying again.') - ); - } else { - onError?.(new Error('Failed to update agent setting')); - } - } - }, - [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] - ); -} - export function useBulkMutateCreatePr({projects}: {projects: Project[]}) { const organization = useOrganization(); const queryClient = useQueryClient();