From 421f99a62da144c16f7b898c05698c6a5ff91e06 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 1 Apr 2026 13:48:42 -0700 Subject: [PATCH 1/8] ref(seer): Refactor hooks related to the preferred agent option This makes this much easier to read/review and test. The stories are small and still work --- .../seer/overview/autofixOverviewSection.tsx | 131 +++--- .../overview/utils/seerPreferredAgent.spec.ts | 399 ++++++++++++++++++ .../seer/overview/utils/seerPreferredAgent.ts | 176 ++++++++ .../settings/seer/seerAgentHooks.spec.tsx | 310 -------------- .../views/settings/seer/seerAgentHooks.tsx | 87 ---- 5 files changed, 625 insertions(+), 478 deletions(-) create mode 100644 static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts create mode 100644 static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 4ce19c63456e61..313191c2dd7c4e 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, + usePreferredAgentMutationOptions, + 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 = usePreferredAgentMutationOptions({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, - }); - return ( ()]), + })} + initialValue={preferredAgent.data ? preferredAgent.data : ('seer' as const)} + mutationOptions={codingAgentMutationOptions} > {field => ( @@ -219,12 +179,20 @@ function AgentNameForm({ )} > - + {preferredAgent.isPending || codingAgentSelectOptions.isPending ? ( + + ) : codingAgentSelectOptions.isError ? ( + + {t('Failed to fetch coding agent options')} + + ) : ( + + )} @@ -236,13 +204,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,21 +221,21 @@ 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 + ? projectsIdsWithPreferredAgent.size === 1 ? t('Your existing project uses %s', preferredAgentLabel) : t('Your existing project does not use %s', preferredAgentLabel) - : projects.length === projectsWithPreferredAgent.length + : projects.length === projectsIdsWithPreferredAgent.size ? t('All existing projects use %s', preferredAgentLabel) : t( '%s of %s existing projects use %s', - projectsWithPreferredAgent.length, + projectsIdsWithPreferredAgent.size, projects.length, preferredAgentLabel )} 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..e9e5231055ee3d --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -0,0 +1,399 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import { + useBulkMutateSelectedAgent, + useFetchPreferredAgent, + useFetchPreferredAgentOptions, + usePreferredAgentMutationOptions, +} 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}), + }); + } + + it('sends PUT with seer payload when integration is "seer"', async () => { + mockIntegrationsEndpoint(); + const orgPutRequest = mockOrgPutRequest(); + + const {result} = renderHookWithProviders(usePreferredAgentMutationOptions, { + initialProps: {organization}, + }); + + act(() => { + result.current.mutationFn!({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 {result} = renderHookWithProviders(usePreferredAgentMutationOptions, { + initialProps: {organization}, + }); + + act(() => { + result.current.mutationFn!({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', + }); + }); + }); +}); 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..5abbb77ecbde43 --- /dev/null +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -0,0 +1,176 @@ +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, isSuccess: true}; + } + return query; +} + +export function useFetchPreferredAgentOptions({ + organization, +}: { + organization: Organization; +}) { + return useQuery({ + ...organizationIntegrationsCodingAgents(organization), + select: (data): ReadonlyArray> => { + return [ + {value: 'seer' as const, label: t('Seer Agent')}, + ...(data.json.integrations ?? []) + .filter(integration => integration.id) + .map(integration => ({ + value: integration, + label: integration.name, + })), + ] as const; + }, + }); +} + +export function usePreferredAgentMutationOptions({ + organization, +}: { + organization: Organization; +}) { + return mutationOptions({ + mutationFn: ({integration}: {integration: PreferredAgent}) => { + if (!integration) { + return Promise.reject(new Error('Integration is required')); + } + 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.') + ); + } + } + }, + [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(); From 4e4828ec80445be00469d4609f60581c122a2680 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 1 Apr 2026 14:07:23 -0700 Subject: [PATCH 2/8] all the overrides of the query results --- .../settings/seer/overview/utils/seerPreferredAgent.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts index 5abbb77ecbde43..8beb53d852a2ff 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -40,7 +40,13 @@ export function useFetchPreferredAgent({organization}: {organization: Organizati }); if (value === null || value === 'seer') { - return {...query, data: 'seer' as const, isSuccess: true}; + return { + ...query, + data: 'seer' as const, + isPending: false, + isSuccess: true, + status: 'success', + }; } return query; } From 09d94ad3a045c01fcc73b02e876f830fd0bb03fc Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 13:03:54 -0700 Subject: [PATCH 3/8] review comments --- .../settings/seer/overview/autofixOverviewSection.tsx | 6 ++++-- .../settings/seer/overview/utils/seerPreferredAgent.ts | 7 ++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 313191c2dd7c4e..394e146dfa1ce5 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -161,13 +161,15 @@ function AgentNameForm({ o => o.value === preferredAgent.data )?.label; + const initialValue = preferredAgent.data ? preferredAgent.data : ('seer' as const); + return ( ()]), })} - initialValue={preferredAgent.data ? preferredAgent.data : ('seer' as const)} + initialValue={initialValue} mutationOptions={codingAgentMutationOptions} > {field => ( @@ -187,7 +189,7 @@ function AgentNameForm({ ) : ( > => { + select: data => { return [ - {value: 'seer' as const, label: t('Seer Agent')}, + {value: 'seer', label: t('Seer Agent')} as SelectValue, ...(data.json.integrations ?? []) .filter(integration => integration.id) .map(integration => ({ @@ -79,9 +79,6 @@ export function usePreferredAgentMutationOptions({ }) { return mutationOptions({ mutationFn: ({integration}: {integration: PreferredAgent}) => { - if (!integration) { - return Promise.reject(new Error('Integration is required')); - } return fetchMutation({ method: 'PUT', url: `/organizations/${organization.slug}/`, From 311b9aea03ffd6c62bfef79087a3ff163eade574 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 13:09:38 -0700 Subject: [PATCH 4/8] improve getPreferredAgentMutationOptions test --- .../seer/overview/autofixOverviewSection.tsx | 4 ++-- .../overview/utils/seerPreferredAgent.spec.ts | 21 ++++++++++++------- .../seer/overview/utils/seerPreferredAgent.ts | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index 394e146dfa1ce5..ff1fafc4bad03b 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -27,7 +27,7 @@ import {useInfiniteQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; import { - usePreferredAgentMutationOptions, + getPreferredAgentMutationOptions, useFetchPreferredAgent, useFetchPreferredAgentOptions, useBulkMutateSelectedAgent, @@ -152,7 +152,7 @@ function AgentNameForm({ }) { const preferredAgent = useFetchPreferredAgent({organization}); const codingAgentSelectOptions = useFetchPreferredAgentOptions({organization}); - const codingAgentMutationOptions = usePreferredAgentMutationOptions({organization}); + const codingAgentMutationOptions = getPreferredAgentMutationOptions({organization}); const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ projects: projects.filter(p => !projectsIdsWithPreferredAgent.has(p.id)), }); diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts index e9e5231055ee3d..814eb742116ae7 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -6,11 +6,12 @@ import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLib import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import type {CodingAgentIntegration} from 'sentry/components/events/autofix/useAutofix'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {useMutation} from 'sentry/utils/queryClient'; import { useBulkMutateSelectedAgent, useFetchPreferredAgent, useFetchPreferredAgentOptions, - usePreferredAgentMutationOptions, + getPreferredAgentMutationOptions, } from 'sentry/views/settings/seer/overview/utils/seerPreferredAgent'; describe('seerPreferredAgent', () => { @@ -198,12 +199,13 @@ describe('seerPreferredAgent', () => { mockIntegrationsEndpoint(); const orgPutRequest = mockOrgPutRequest(); - const {result} = renderHookWithProviders(usePreferredAgentMutationOptions, { - initialProps: {organization}, + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, }); act(() => { - result.current.mutationFn!({integration: 'seer'}); + result.current.mutateAsync({integration: 'seer'}); }); await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); @@ -228,12 +230,17 @@ describe('seerPreferredAgent', () => { provider: 'cursor', }; - const {result} = renderHookWithProviders(usePreferredAgentMutationOptions, { - initialProps: {organization}, + const options = getPreferredAgentMutationOptions({organization}); + const {result} = renderHookWithProviders(useMutation, { + initialProps: options, + }); + + act(() => { + result.current.mutateAsync({integration: 'seer'}); }); act(() => { - result.current.mutationFn!({integration}); + result.current.mutateAsync({integration}); }); await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts index 902873de643c2c..3a4c321b952567 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -72,7 +72,7 @@ export function useFetchPreferredAgentOptions({ }); } -export function usePreferredAgentMutationOptions({ +export function getPreferredAgentMutationOptions({ organization, }: { organization: Organization; From 16e312ef60d43def81e427e8a0fceb5f9568a889 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 13:12:10 -0700 Subject: [PATCH 5/8] handle non 429 errors when bulk updating --- .../overview/utils/seerPreferredAgent.spec.ts | 69 +++++++++++++++++++ .../seer/overview/utils/seerPreferredAgent.ts | 7 ++ 2 files changed, 76 insertions(+) diff --git a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts index 814eb742116ae7..eb1637164981a3 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -3,6 +3,7 @@ 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 {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -402,5 +403,73 @@ describe('seerPreferredAgent', () => { 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 index 3a4c321b952567..a0f7fb6f18132f 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.ts @@ -171,6 +171,13 @@ export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { 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 + ) + ); } } }, From 747b6e8e24ff95e8c4be9ba6725e8ead49eddf32 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 13:53:28 -0700 Subject: [PATCH 6/8] make sure to set org before tests run, and prevent `undefined` agent name from appearing --- .../seer/overview/autofixOverviewSection.tsx | 34 ++++++++++--------- .../overview/utils/seerPreferredAgent.spec.ts | 9 ++--- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index ff1fafc4bad03b..ce50cef6b4ca17 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -226,22 +226,24 @@ function AgentNameForm({ projectsIdsWithPreferredAgent.size )} - - {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 - )} - + {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 index eb1637164981a3..8ea7f4d9a2fcb6 100644 --- a/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts +++ b/static/app/views/settings/seer/overview/utils/seerPreferredAgent.spec.ts @@ -6,6 +6,7 @@ import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLib 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 { @@ -196,6 +197,10 @@ describe('seerPreferredAgent', () => { }); } + beforeEach(() => { + OrganizationsStore.addOrReplace(organization); + }); + it('sends PUT with seer payload when integration is "seer"', async () => { mockIntegrationsEndpoint(); const orgPutRequest = mockOrgPutRequest(); @@ -236,10 +241,6 @@ describe('seerPreferredAgent', () => { initialProps: options, }); - act(() => { - result.current.mutateAsync({integration: 'seer'}); - }); - act(() => { result.current.mutateAsync({integration}); }); From e1c42913ac60f9f44dc7671c1ed6b4698424b3ae Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 14:04:11 -0700 Subject: [PATCH 7/8] fix test --- .../seer/overview/autofixOverviewSection.spec.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 () => { From c552759185b732112d014a3fece5c77e14b9efb4 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 3 Apr 2026 09:47:20 -0700 Subject: [PATCH 8/8] implement isValueEqual on the Select --- .../views/settings/seer/overview/autofixOverviewSection.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index ce50cef6b4ca17..9e17fee181cba3 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -193,6 +193,10 @@ function AgentNameForm({ onChange={field.handleChange} disabled={!canWrite} options={codingAgentSelectOptions.data} + isValueEqual={(a, b) => + a === b || + (typeof a === 'object' && typeof b === 'object' && a.id === b.id) + } /> )}