diff --git a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts index 3c77e52eaa993b..6b45d8736cc24b 100644 --- a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts +++ b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts @@ -2,7 +2,6 @@ import {useEffect, useMemo} from 'react'; import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories'; import {organizationConfigIntegrationsQueryOptions} from 'sentry/components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions'; -import {organizationIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsIntegrationsQueryOptions'; import type { IntegrationProvider, IntegrationRepository, @@ -12,6 +11,7 @@ import type { import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useInfiniteQuery, useQueries, useQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions'; type ScmIntegrationTreeData = { connectedIdentifiers: Set; diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index a3768d08a0921d..1123f243ba52c5 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -65,8 +65,8 @@ export interface Organization extends OrganizationSummary { dataScrubberDefaults: boolean; debugFilesRole: string; defaultCodeReviewTriggers: CodeReviewTrigger[]; - defaultCodingAgent: string | null | undefined; - defaultCodingAgentIntegrationId: number | null | undefined; + defaultCodingAgent: string | null; + defaultCodingAgentIntegrationId: string | number | null; defaultRole: string; enhancedPrivacy: boolean; eventsMemberAdmin: boolean; diff --git a/static/app/utils/array/procesInChunks.spec.ts b/static/app/utils/array/procesInChunks.spec.ts new file mode 100644 index 00000000000000..80a4618ae083b0 --- /dev/null +++ b/static/app/utils/array/procesInChunks.spec.ts @@ -0,0 +1,108 @@ +import {processInChunks} from 'sentry/utils/array/procesInChunks'; + +describe('processInChunks', () => { + it('returns an empty array for empty input', async () => { + const fn = jest.fn().mockResolvedValue('x'); + const results = await processInChunks({items: [], chunkSize: 3, fn}); + expect(results).toEqual([]); + expect(fn).not.toHaveBeenCalled(); + }); + + it('processes all items when count is less than chunkSize', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x * 2)); + const results = await processInChunks({items: [1, 2], chunkSize: 5, fn}); + expect(results).toEqual([ + {status: 'fulfilled', value: 2}, + {status: 'fulfilled', value: 4}, + ]); + }); + + it('processes all items when count equals chunkSize exactly', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x * 2)); + const results = await processInChunks({items: [1, 2, 3], chunkSize: 3, fn}); + expect(results).toHaveLength(3); + expect(results.every(r => r.status === 'fulfilled')).toBe(true); + }); + + it('processes all items across multiple chunks', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({ + items: [1, 2, 3, 4, 5], + chunkSize: 2, + fn, + }); + expect(results).toHaveLength(5); + expect(fn).toHaveBeenCalledTimes(5); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + + it('preserves result order matching input order', async () => { + // Simulate varying async latency: later items resolve faster + const fn = jest.fn( + (x: number) => + new Promise(resolve => setTimeout(() => resolve(x), (10 - x) * 10)) + ); + const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn}); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + + it('processes chunks sequentially, not all at once', async () => { + const callOrder: number[] = []; + const fn = jest.fn((x: number) => { + callOrder.push(x); + return Promise.resolve(x); + }); + + await processInChunks({items: [1, 2, 3, 4, 5, 6], chunkSize: 2, fn}); + + // Each chunk of 2 must start only after the previous chunk completes. + // Because fn is synchronous here, within each chunk the call order is + // preserved and chunks are processed sequentially. + expect(callOrder).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('marks rejected items as rejected without stopping other items', async () => { + const fn = jest.fn((x: number) => + x === 3 ? Promise.reject(new Error('boom')) : Promise.resolve(x) + ); + const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn}); + expect(results).toHaveLength(5); + expect(results[0]).toEqual({status: 'fulfilled', value: 1}); + expect(results[1]).toEqual({status: 'fulfilled', value: 2}); + expect(results[2]).toMatchObject({status: 'rejected', reason: expect.any(Error)}); + expect(results[3]).toEqual({status: 'fulfilled', value: 4}); + expect(results[4]).toEqual({status: 'fulfilled', value: 5}); + }); + + it('continues processing later chunks when an earlier chunk has failures', async () => { + const fn = jest.fn((x: number) => + x === 1 ? Promise.reject(new Error('first chunk error')) : Promise.resolve(x) + ); + // chunk 1: [1] (fails), chunk 2: [2, 3] (succeeds) + const results = await processInChunks({items: [1, 2, 3], chunkSize: 1, fn}); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject({status: 'rejected'}); + expect(results[1]).toEqual({status: 'fulfilled', value: 2}); + expect(results[2]).toEqual({status: 'fulfilled', value: 3}); + }); + + it('handles chunkSize of 1 by processing items one at a time', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({items: [10, 20, 30], chunkSize: 1, fn}); + expect(results).toHaveLength(3); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 10, 20, 30, + ]); + }); + + it('handles chunkSize larger than item count', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({items: [1, 2], chunkSize: 100, fn}); + expect(results).toHaveLength(2); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/static/app/utils/array/procesInChunks.ts b/static/app/utils/array/procesInChunks.ts new file mode 100644 index 00000000000000..5bb4458b2598e1 --- /dev/null +++ b/static/app/utils/array/procesInChunks.ts @@ -0,0 +1,19 @@ +interface Props { + chunkSize: number; + fn: (item: Item) => Promise; + items: Item[]; +} + +export async function processInChunks({ + items, + chunkSize, + fn, +}: Props): Promise>> { + const results: Array> = []; + for (let i = 0; i < items.length; i += chunkSize) { + const chunk = items.slice(i, i + chunkSize); + const chunkResults = await Promise.allSettled(chunk.map(fn)); + results.push(...chunkResults); + } + return results; +} diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx new file mode 100644 index 00000000000000..9c434d25068789 --- /dev/null +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx @@ -0,0 +1,567 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import { + render, + renderHookWithProviders, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import type {AutofixAutomationSettings} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import { + AutofixOverviewSection, + useAutofixOverviewData, +} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; + +function makeSettings( + overrides: Partial = {} +): AutofixAutomationSettings { + return { + projectId: '1', + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: 'code_changes', + automationHandoff: undefined, + reposCount: 0, + ...overrides, + }; +} + +describe('autofixOverviewSection', () => { + afterEach(() => { + MockApiClient.clearMockResponses(); + ProjectsStore.reset(); + }); + + describe('useAutofixOverviewData', () => { + function setupSettingsMock(settings: AutofixAutomationSettings[]) { + return MockApiClient.addMockResponse({ + url: `/organizations/org-slug/autofix/automation-settings/`, + method: 'GET', + body: settings, + }); + } + + describe('projectsWithRepos', () => { + it('returns empty when there are no settings', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(0); + }); + + it('returns projects where reposCount > 0', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([ + makeSettings({projectId: '1', reposCount: 2}), + makeSettings({projectId: '2', reposCount: 0}), + makeSettings({projectId: '3', reposCount: 1}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(2); + }); + + it('returns no projects when all have reposCount === 0', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([ + makeSettings({projectId: '1', reposCount: 0}), + makeSettings({projectId: '2', reposCount: 0}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(0); + }); + }); + + describe('projectsWithPreferredAgent — defaultCodingAgent is seer', () => { + it('returns empty when there are no settings', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + + it('counts projects without automationHandoff when defaultCodingAgent is seer', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([ + makeSettings({projectId: '1', automationHandoff: undefined}), + makeSettings({projectId: '2', automationHandoff: undefined}), + makeSettings({ + projectId: '3', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(2); + }); + + it('returns none when all projects have automationHandoff', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + }); + + describe('projectsWithPreferredAgent — defaultCodingAgent is an integration', () => { + it('counts projects where automationHandoff.integration_id matches org setting', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 99, + }, + }), + makeSettings({projectId: '3', automationHandoff: undefined}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(1); + }); + + it('returns none when no integration_id matches', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({projectId: '1', automationHandoff: undefined}), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 99, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + + it('matches integration_id numerically despite string/number coercion', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(1); + }); + }); + + describe('projectsWithCreatePr — autoOpenPrs is true', () => { + it('counts projects with automationHandoff === null and open_pr stopping point', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + // null (not undefined) is what the API actually returns; the source checks === null + makeSettings({ + projectId: '1', + automationHandoff: null as any, + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '2', + automationHandoff: null as any, + automatedRunStoppingPoint: 'code_changes', // null but not open_pr — no match + }), + makeSettings({ + projectId: '3', + automationHandoff: undefined, + automatedRunStoppingPoint: 'open_pr', // open_pr but undefined (not null) — no match + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // Only project 1: automationHandoff is null AND stopping point is open_pr + expect(result.current.data?.projectsWithCreatePr).toHaveLength(1); + }); + + it('counts projects with automationHandoff.auto_create_pr === true', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: false, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(1); + }); + + it('counts both null-handoff-with-open_pr and handoff-with-auto_create_pr', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: null as any, // null (not undefined) matches the === null check + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '3', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + }); + + describe('projectsWithCreatePr — autoOpenPrs is false', () => { + it('counts projects not configured to create PRs', async () => { + const organization = OrganizationFixture({autoOpenPrs: false}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + makeSettings({ + projectId: '2', + automationHandoff: undefined, + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '3', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '4', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: false, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // project 1: no open_pr stopping point AND no auto_create_pr + // project 4: no auto_create_pr (but integration exists) + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + + it('returns all projects when none are configured to create PRs', async () => { + const organization = OrganizationFixture({autoOpenPrs: false}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + makeSettings({ + projectId: '2', + automationHandoff: undefined, + automatedRunStoppingPoint: 'solution', + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + }); + }); + + describe('AutofixOverviewSection', () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + + function setupIntegrationsMock( + integrations: Array<{id: string; name: string; provider: string}> = [] + ) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + method: 'GET', + body: {integrations}, + }); + } + + function renderSection( + projectsWithPreferredAgent: AutofixAutomationSettings[], + { + projects = [ProjectFixture()], + org = organization, + projectsWithCreatePr = [] as AutofixAutomationSettings[], + } = {} + ) { + ProjectsStore.loadInitialData(projects); + + const data = { + projectsWithRepos: [], + projectsWithPreferredAgent, + projectsWithCreatePr, + } as any; + + return render( + , + {organization: org} + ); + } + + describe('AgentNameForm labels', () => { + beforeEach(() => { + setupIntegrationsMock(); + }); + + 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); + }); + + it('shows "Your existing project uses Seer Agent" when 1 project uses preferred agent', async () => { + renderSection([makeSettings({projectId: '1'})], { + projects: [ProjectFixture({id: '1'})], + }); + + expect( + await screen.findByText('Your existing project uses Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "Your existing project does not use Seer Agent" when 1 project does not use preferred agent', async () => { + renderSection([], {projects: [ProjectFixture({id: '1'})]}); + + expect( + await screen.findByText('Your existing project does not use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "All existing projects use Seer Agent" when all projects match', async () => { + const projects = [ + ProjectFixture({id: '1'}), + ProjectFixture({id: '2'}), + ProjectFixture({id: '3'}), + ]; + renderSection( + [ + makeSettings({projectId: '1'}), + makeSettings({projectId: '2'}), + makeSettings({projectId: '3'}), + ], + {projects} + ); + + expect( + await screen.findByText('All existing projects use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "{count} of {total} existing projects use {label}" when some match', async () => { + const projects = [ + ProjectFixture({id: '1'}), + ProjectFixture({id: '2'}), + ProjectFixture({id: '3'}), + ]; + renderSection([makeSettings({projectId: '1'})], {projects}); + + expect( + await screen.findByText('1 of 3 existing projects use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows correct label when the preferred agent is a named integration', async () => { + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + const org = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + renderSection([makeSettings({projectId: '1'})], { + projects: [ProjectFixture({id: '1'})], + org, + }); + + expect( + await screen.findByText('Your existing project uses Cursor') + ).toBeInTheDocument(); + }); + }); + + describe('codingAgentMutationOpts', () => { + it('sends PUT with defaultCodingAgent=seer when seer is selected', async () => { + const org = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + + const orgPutRequest = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/`, + method: 'PUT', + body: OrganizationFixture(), + }); + + renderSection([], {org}); + + // Open the select dropdown + await userEvent.click( + await screen.findByRole('textbox', {name: /default preferred coding agent/i}) + ); + + // Choose Seer Agent + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Seer Agent'})); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${org.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }, + }) + ); + }); + + it('sends PUT with integration provider and id when an integration is selected', async () => { + const org = OrganizationFixture({ + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }); + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + + const orgPutRequest = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/`, + method: 'PUT', + body: OrganizationFixture(), + }); + + renderSection([], {org}); + + // Open the select dropdown + await userEvent.click( + await screen.findByRole('textbox', {name: /default preferred coding agent/i}) + ); + + // Choose the Cursor integration + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Cursor'})); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${org.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: '42', + }, + }) + ); + }); + }); + }); +}); diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx new file mode 100644 index 00000000000000..3aa8360d482ec6 --- /dev/null +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx @@ -0,0 +1,80 @@ +import {Fragment} from 'react'; + +import type {AutofixAutomationSettings} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import * as Storybook from 'sentry/stories'; +import type {Organization} from 'sentry/types/organization'; +import {useQueryClient} from 'sentry/utils/queryClient'; +import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; + +function makeSettings(projectId: string): AutofixAutomationSettings { + return { + projectId, + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: 'code_changes', + automationHandoff: undefined, + reposCount: 0, + }; +} + +const BASE_ORG = { + slug: 'my-org', + defaultCodingAgent: 'seer', + autoOpenPrs: false, + access: ['org:read', 'org:write', 'org:admin'], +} as Organization; + +function makeProps( + projectsWithPreferredAgent: AutofixAutomationSettings[], + org: Organization = BASE_ORG +) { + return { + canWrite: false, + organization: org, + data: { + projectsWithRepos: [], + projectsWithPreferredAgent, + projectsWithCreatePr: [], + } as any, + isPending: false, + ...({} as any), + }; +} + +export default Storybook.story('AgentNameForm', story => { + story('Overview', () => ( + +

+ The (rendered inside{' '} + ) shows a summary label below + the agent selector. The label reflects how many of the organization's + existing projects are configured to use the preferred coding agent. +

+

+ The label has five variants: no projects, single project (uses / does not use), + all projects use, and a partial count. When the preferred agent is a named + integration (e.g. Cursor), the agent name in the label updates accordingly. +

+
+ )); + + story('0 projects with preferred agent', () => ( + + )); + + story('Named integration (Cursor) as preferred agent', () => { + const cursorOrg = { + ...BASE_ORG, + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + } as Organization; + + const queryClient = useQueryClient(); + queryClient.setQueryData(organizationIntegrationsCodingAgents(cursorOrg).queryKey, { + json: {integrations: [{id: '42', name: 'Cursor', provider: 'cursor'}]}, + headers: {Link: undefined, 'X-Hits': undefined, 'X-Max-Hits': undefined}, + }); + + return ; + }); +}); diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index d84cd8c4aa0587..4ce19c63456e61 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -1,17 +1,23 @@ +import {useState} from 'react'; import {mutationOptions} from '@tanstack/react-query'; import {z} from 'zod'; import {Alert} from '@sentry/scraps/alert'; +import {Button} from '@sentry/scraps/button'; import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; -import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import { + bulkAutofixAutomationSettingsInfiniteOptions, + type AutofixAutomationSettings, +} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; import {IconSettings} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +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'; @@ -19,7 +25,11 @@ import {fetchMutation, useQuery} from 'sentry/utils/queryClient'; import {useInfiniteQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; -import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks'; +import { + useAgentOptions, + useBulkMutateCreatePr, + useBulkMutateSelectedAgent, +} from 'sentry/views/settings/seer/seerAgentHooks'; export function useAutofixOverviewData() { const organization = useOrganization(); @@ -72,6 +82,9 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {}; + const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false); + const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false); + return ( ); @@ -108,15 +127,22 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} function AgentNameForm({ canWrite, + isPending, + isBulkMutatingAgent, + setIsBulkMutatingAgent, + isBulkMutatingCreatePr, organization, projects, - projectsWithPreferredAgentCount, + projectsWithPreferredAgent, }: { canWrite: boolean; + isBulkMutatingAgent: boolean; + isBulkMutatingCreatePr: boolean; isPending: boolean; organization: Organization; projects: Project[]; - projectsWithPreferredAgentCount: number; + projectsWithPreferredAgent: AutofixAutomationSettings[]; + setIsBulkMutatingAgent: (value: boolean) => void; }) { const {data: integrations} = useQuery( organizationIntegrationsCodingAgents(organization) @@ -161,6 +187,22 @@ function AgentNameForm({ option => option.value === preferredAgentValue )?.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 ( + {projects.length === 0 ? t('No projects found') : projects.length === 1 - ? projectsWithPreferredAgentCount === 1 + ? projectsWithPreferredAgent.length === 1 ? t('Your existing project uses %s', preferredAgentLabel) : t('Your existing project does not use %s', preferredAgentLabel) - : projects.length === projectsWithPreferredAgentCount + : projects.length === projectsWithPreferredAgent.length ? t('All existing projects use %s', preferredAgentLabel) : t( '%s of %s existing projects use %s', - projectsWithPreferredAgentCount, + projectsWithPreferredAgent.length, projects.length, preferredAgentLabel )} @@ -212,15 +280,22 @@ function AgentNameForm({ function CreatePrForm({ canWrite, + isPending, + isBulkMutatingCreatePr, + setIsBulkMutatingCreatePr, + isBulkMutatingAgent, organization, projects, - projectsWithCreatePrCount, + projectsWithCreatePr, }: { canWrite: boolean; + isBulkMutatingAgent: boolean; + isBulkMutatingCreatePr: boolean; isPending: boolean; organization: Organization; projects: Project[]; - projectsWithCreatePrCount: number; + projectsWithCreatePr: AutofixAutomationSettings[]; + setIsBulkMutatingCreatePr: (value: boolean) => void; }) { const orgMutationOpts = mutationOptions({ mutationFn: (updateData: Partial) => @@ -232,6 +307,11 @@ function CreatePrForm({ onSuccess: updateOrganization, }); + const projectsWithCreatePrIds = new Set(projectsWithCreatePr.map(s => s.projectId)); + const projectsToUpdate = projects.filter(p => !projectsWithCreatePrIds.has(p.id)); + + const bulkMutateCreatePr = useBulkMutateCreatePr({projects: projectsToUpdate}); + return ( + {projects.length === 0 ? t('No projects found') : projects.length === 1 - ? projectsWithCreatePrCount === 1 + ? projectsWithCreatePr.length === 1 ? t('Your existing project has Create PR enabled') : t('Your existing project does not have Create PR enabled') : field.state.value - ? projects.length === projectsWithCreatePrCount + ? projects.length === projectsWithCreatePr.length ? t('All existing projects have Create PR enabled') : t( '%s of %s existing projects have Create PR enabled', - projectsWithCreatePrCount, + projectsWithCreatePr.length, projects.length ) - : projects.length === projectsWithCreatePrCount + : projects.length === projectsWithCreatePr.length ? t('All existing projects have Create PR disabled') : t( '%s of %s existing projects have Create PR disabled', - projectsWithCreatePrCount, + projectsWithCreatePr.length, projects.length )} diff --git a/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts similarity index 100% rename from static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts rename to static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx index d7a0f5eaedb191..bb72d9c17e7663 100644 --- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -14,6 +14,8 @@ import {ProjectsStore} from 'sentry/stores/projectsStore'; import {useQueryClient} from 'sentry/utils/queryClient'; import { useAgentOptions, + useBulkMutateCreatePr, + useBulkMutateSelectedAgent, useMutateCreatePr, useMutateSelectedAgent, useSelectedAgentFromBulkSettings, @@ -35,7 +37,7 @@ describe('seerAgentHooks', () => { }); describe('useAgentOptions', () => { - it('returns Seer, integration options, and No Handoff Selection', () => { + it('returns Seer, integration options', () => { const integrations: CodingAgentIntegration[] = [ {id: '42', name: 'Cursor', provider: 'cursor'}, ]; @@ -616,6 +618,645 @@ 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'}); + const projects = [project1, project2]; + + const seerPreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + const integrationPreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: { + handoff_point: 'root_cause', + target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, + integration_id: 123, + auto_create_pr: false, + }, + }; + + function setupMocks( + p1Preference: ProjectSeerPreferences = seerPreference, + p2Preference: ProjectSeerPreferences = seerPreference + ) { + const perProjectPrefs = [p1Preference, p2Preference]; + const mocks = projects.map((p, i) => ({ + seerPreferencesGetRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: perProjectPrefs[i], + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }), + seerPreferencesPostRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }), + })); + return { + seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest), + seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest), + }; + } + + function renderBulkMutateCreatePr() { + return renderHookWithProviders( + (props: {projects: typeof projects}) => { + const mutate = useBulkMutateCreatePr(props); + return {mutate}; + }, + { + initialProps: {projects}, + organization, + } + ); + } + + beforeEach(() => { + ProjectsStore.loadInitialData(projects); + }); + + it('sets automated_run_stopping_point for Seer projects when enabling Create PR', async () => { + const {seerPreferencesPostRequests} = setupMocks(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }), + }) + ); + }); + }); + + it('sets automated_run_stopping_point for Seer projects when disabling Create PR', async () => { + const {seerPreferencesPostRequests} = setupMocks( + {...seerPreference, automated_run_stopping_point: 'open_pr'}, + {...seerPreference, automated_run_stopping_point: 'open_pr'} + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(false, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automated_run_stopping_point: 'code_changes', + }), + }) + ); + }); + }); + + it('sets auto_create_pr in automation_handoff for external agent projects', async () => { + const {seerPreferencesPostRequests} = setupMocks( + integrationPreference, + integrationPreference + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 123, + auto_create_pr: true, + }), + }), + }) + ); + }); + }); + + it('disables auto_create_pr in automation_handoff for external agent projects', async () => { + const {seerPreferencesPostRequests} = setupMocks( + { + ...integrationPreference, + automation_handoff: { + ...integrationPreference.automation_handoff!, + auto_create_pr: true, + }, + }, + { + ...integrationPreference, + automation_handoff: { + ...integrationPreference.automation_handoff!, + auto_create_pr: true, + }, + } + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(false, {}); + }); + + 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: false, + }), + }), + }) + ); + }); + }); + + it('handles mixed projects — Seer and external agent — correctly', async () => { + const {seerPreferencesPostRequests} = setupMocks( + seerPreference, + integrationPreference + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + // project1 (Seer): uses stopping_point + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project1.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }), + }) + ); + // project2 (external agent): uses auto_create_pr in handoff + expect(seerPreferencesPostRequests[1]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project2.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 123, + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('preserves existing repositories and other handoff fields', 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, + preferenceWithRepos + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + 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('calls onSuccess when all requests succeed', async () => { + setupMocks(); + const onSuccess = jest.fn(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {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: seerPreference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + statusCode: 500, + body: {}, + }); + }); + const onError = jest.fn(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {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( + () => useBulkMutateCreatePr({projects: []}), + {organization} + ); + const onSuccess = jest.fn(); + + act(() => { + emptyProjectsMutate.result.current(true, {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('useMutateCreatePr', () => { const basePreference: ProjectSeerPreferences = { repositories: [], diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx index 4c19a50cf6410d..7aac636bce3226 100644 --- a/static/app/views/settings/seer/seerAgentHooks.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.tsx @@ -1,9 +1,12 @@ import {useCallback, useMemo} from 'react'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import { bulkAutofixAutomationSettingsInfiniteOptions, type AutofixAutomationSettings, } 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 { useFetchProjectSeerPreferences, useUpdateProjectSeerPreferences, @@ -14,8 +17,10 @@ import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useA import {t} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Project} from 'sentry/types/project'; +import {processInChunks} from 'sentry/utils/array/procesInChunks'; import {useUpdateProject} from 'sentry/utils/project/useUpdateProject'; -import {useQueryClient} from 'sentry/utils/queryClient'; +import {fetchDataQuery, fetchMutation, useQueryClient} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; export function useAgentOptions({ @@ -95,11 +100,6 @@ export function useSelectedAgentFromBulkSettings({ ]); } -type MutateOptions = { - onError?: (error: Error) => void; - onSuccess?: () => void; -}; - function useApplyOptimisticUpdate({project}: {project: Project}) { const queryClient = useQueryClient(); const organization = useOrganization(); @@ -140,6 +140,11 @@ function useApplyOptimisticUpdate({project}: {project: Project}) { ); } +type MutateOptions = { + onError?: (error: Error) => void; + onSuccess?: () => void; +}; + export function useMutateSelectedAgent({project}: {project: Project}) { const {mutateAsync: updateProject} = useUpdateProject(project); const {mutateAsync: updateProjectSeerPreferences} = @@ -209,6 +214,159 @@ 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(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + async (value: boolean, {onSuccess, onError}: MutateOptions) => { + const results = await processInChunks({ + items: projects, + chunkSize: 10, + fn: async project => { + const [preferencesData] = await queryClient.fetchQuery({ + queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + queryFn: fetchDataQuery, + staleTime: 0, + }); + const preference = preferencesData?.preference; + + return fetchMutation({ + method: 'POST', + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + data: preference?.automation_handoff?.integration_id + ? { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: { + ...preference.automation_handoff, + auto_create_pr: value, + }, + } + : { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: value ? 'open_pr' : 'code_changes', + automation_handoff: preference?.automation_handoff, + }, + }); + }, + }); + + // 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 PR setting')); + } + } + }, + [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] + ); +} + export function useMutateCreatePr({project}: {project: Project}) { const {mutateAsync: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index 3e386b6b06f494..ba32d2dac42785 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -55,8 +55,8 @@ export function OrganizationFixture(params: Partial = {}): Organiz dateCreated: new Date().toISOString(), debugFilesRole: '', defaultCodeReviewTriggers: [], + defaultCodingAgentIntegrationId: null, defaultCodingAgent: 'seer', - defaultCodingAgentIntegrationId: undefined, defaultRole: '', enhancedPrivacy: false, eventsMemberAdmin: false,