diff --git a/static/app/types/prevent.test.tsx b/static/app/types/prevent.test.tsx new file mode 100644 index 00000000000000..2dc74022df2178 --- /dev/null +++ b/static/app/types/prevent.test.tsx @@ -0,0 +1,70 @@ +import type {PreventAIFeatureConfig, Sensitivity} from 'sentry/types/prevent'; + +describe('Prevent Types', () => { + describe('Sensitivity', () => { + it('should accept all valid sensitivity values', () => { + const validSensitivities: Sensitivity[] = ['low', 'medium', 'high', 'critical']; + + validSensitivities.forEach(sensitivity => { + expect(typeof sensitivity).toBe('string'); + expect(['low', 'medium', 'high', 'critical']).toContain(sensitivity); + }); + }); + + it('should be assignable to string but maintain type safety', () => { + const lowSensitivity: Sensitivity = 'low'; + const mediumSensitivity: Sensitivity = 'medium'; + const highSensitivity: Sensitivity = 'high'; + const criticalSensitivity: Sensitivity = 'critical'; + + expect(lowSensitivity).toBe('low'); + expect(mediumSensitivity).toBe('medium'); + expect(highSensitivity).toBe('high'); + expect(criticalSensitivity).toBe('critical'); + }); + }); + + describe('PreventAIFeatureConfig with Sensitivity', () => { + it('should accept sensitivity as optional Sensitivity type', () => { + const configWithSensitivity: PreventAIFeatureConfig = { + enabled: true, + triggers: { + on_command_phrase: false, + on_ready_for_review: true, + }, + sensitivity: 'high', + }; + + expect(configWithSensitivity.sensitivity).toBe('high'); + }); + + it('should work without sensitivity field', () => { + const configWithoutSensitivity: PreventAIFeatureConfig = { + enabled: false, + triggers: { + on_command_phrase: true, + on_ready_for_review: false, + }, + }; + + expect(configWithoutSensitivity.sensitivity).toBeUndefined(); + }); + + it('should handle all sensitivity levels in feature config', () => { + const sensitivityLevels: Sensitivity[] = ['low', 'medium', 'high', 'critical']; + + sensitivityLevels.forEach(sensitivity => { + const config: PreventAIFeatureConfig = { + enabled: true, + triggers: { + on_command_phrase: false, + on_ready_for_review: false, + }, + sensitivity, + }; + + expect(config.sensitivity).toBe(sensitivity); + }); + }); + }); +}); diff --git a/static/app/types/prevent.tsx b/static/app/types/prevent.tsx index db8885962e9d42..db3e699bdad367 100644 --- a/static/app/types/prevent.tsx +++ b/static/app/types/prevent.tsx @@ -1,6 +1,8 @@ // Add any new providers here e.g., 'github' | 'bitbucket' | 'gitlab' export type PreventAIProvider = 'github'; +export type Sensitivity = 'low' | 'medium' | 'high' | 'critical'; + interface PreventAIRepo { fullName: string; id: string; @@ -18,7 +20,7 @@ export interface PreventAIOrg { interface PreventAIFeatureConfig { enabled: boolean; triggers: PreventAIFeatureTriggers; - sensitivity?: string; + sensitivity?: Sensitivity; } export interface PreventAIFeatureTriggers { diff --git a/static/app/views/prevent/preventAI/hooks/useUpdatePreventAIFeature.tsx b/static/app/views/prevent/preventAI/hooks/useUpdatePreventAIFeature.tsx index fc933ffb8c3239..6dfbdf6315771a 100644 --- a/static/app/views/prevent/preventAI/hooks/useUpdatePreventAIFeature.tsx +++ b/static/app/views/prevent/preventAI/hooks/useUpdatePreventAIFeature.tsx @@ -1,6 +1,10 @@ import {updateOrganization} from 'sentry/actionCreators/organizations'; import type {Organization} from 'sentry/types/organization'; -import type {PreventAIConfig, PreventAIFeatureTriggers} from 'sentry/types/prevent'; +import type { + PreventAIConfig, + PreventAIFeatureTriggers, + Sensitivity, +} from 'sentry/types/prevent'; import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; @@ -10,6 +14,7 @@ interface UpdatePreventAIFeatureParams { orgName: string; // if repoName is provided, edit repo_overrides for that repo, otherwise edit org_defaults repoName?: string; + sensitivity?: Sensitivity; trigger?: Partial; } @@ -73,7 +78,7 @@ export function makePreventAIConfig( featureConfig[params.feature] = { enabled: params.enabled, triggers: {...featureConfig[params.feature].triggers, ...params.trigger}, - sensitivity: featureConfig[params.feature].sensitivity, + sensitivity: params.sensitivity ?? featureConfig[params.feature].sensitivity, }; return updatedConfig; diff --git a/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx b/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx index 561798e5722012..606ef57f7cba67 100644 --- a/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx +++ b/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx @@ -1,6 +1,7 @@ +import selectEvent from 'react-select-event'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import type {PreventAIOrgConfig} from 'sentry/types/prevent'; import ManageReposPanel, { @@ -9,7 +10,10 @@ import ManageReposPanel, { let mockUpdatePreventAIFeatureReturn: any = {}; jest.mock('sentry/views/prevent/preventAI/hooks/useUpdatePreventAIFeature', () => ({ - useUpdatePreventAIFeature: () => mockUpdatePreventAIFeatureReturn, + useUpdatePreventAIFeature: () => ({ + enableFeature: jest.fn(), + ...mockUpdatePreventAIFeatureReturn, + }), })); describe('ManageReposPanel', () => { @@ -22,6 +26,7 @@ describe('ManageReposPanel', () => { }; beforeEach(() => { + MockApiClient.clearMockResponses(); jest.clearAllMocks(); mockUpdatePreventAIFeatureReturn = {}; }); @@ -152,30 +157,227 @@ describe('ManageReposPanel', () => { }, }); }); + }); - it('returns org defaults when repo override is not present', () => { - const orgConfig: PreventAIOrgConfig = { - org_defaults: { - bug_prediction: { - enabled: true, - triggers: {on_command_phrase: true, on_ready_for_review: false}, + describe('Sensitivity Dropdowns', () => { + const mockOrganizationWithEnabledFeatures = OrganizationFixture({ + preventAiConfigGithub: { + schema_version: 'v1', + github_organizations: {}, + default_org_config: { + org_defaults: { + bug_prediction: { + enabled: true, + triggers: {on_command_phrase: true, on_ready_for_review: false}, + sensitivity: 'medium', + }, + test_generation: { + enabled: false, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + }, + vanilla: { + enabled: true, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + sensitivity: 'high', + }, }, - test_generation: { - enabled: false, - triggers: {on_command_phrase: false, on_ready_for_review: false}, + repo_overrides: {}, + }, + }, + }); + + it('shows sensitivity dropdown for enabled vanilla feature', async () => { + render(, { + organization: mockOrganizationWithEnabledFeatures, + }); + + const dropdown = await screen.findByTestId('pr-review-sensitivity-dropdown'); + expect(dropdown).toBeInTheDocument(); + + // Check that the current value is displayed + expect(screen.getByDisplayValue('High')).toBeInTheDocument(); + }); + + it('shows sensitivity dropdown for enabled bug prediction feature', async () => { + render(, { + organization: mockOrganizationWithEnabledFeatures, + }); + + const dropdown = await screen.findByTestId('error-prediction-sensitivity-dropdown'); + expect(dropdown).toBeInTheDocument(); + + // Check that the current value is displayed + expect(screen.getByDisplayValue('Medium')).toBeInTheDocument(); + }); + + it('does not show sensitivity dropdown when vanilla feature is disabled', async () => { + const orgWithDisabledVanilla = OrganizationFixture({ + preventAiConfigGithub: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub, + default_org_config: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub! + .default_org_config, + org_defaults: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub! + .default_org_config.org_defaults, + vanilla: { + enabled: false, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + sensitivity: 'medium', + }, + }, }, - vanilla: { - enabled: true, - triggers: {on_command_phrase: false, on_ready_for_review: false}, + }, + }); + + render(, { + organization: orgWithDisabledVanilla, + }); + + expect( + screen.queryByTestId('pr-review-sensitivity-dropdown') + ).not.toBeInTheDocument(); + }); + + it('does not show sensitivity dropdown when bug prediction feature is disabled', async () => { + const orgWithDisabledBugPrediction = OrganizationFixture({ + preventAiConfigGithub: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub, + default_org_config: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub! + .default_org_config, + org_defaults: { + ...mockOrganizationWithEnabledFeatures.preventAiConfigGithub! + .default_org_config.org_defaults, + bug_prediction: { + enabled: false, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + sensitivity: 'medium', + }, + }, }, }, - repo_overrides: {}, + }); + + render(, { + organization: orgWithDisabledBugPrediction, + }); + + expect( + screen.queryByTestId('error-prediction-sensitivity-dropdown') + ).not.toBeInTheDocument(); + }); + + it('calls enableFeature with correct sensitivity when vanilla sensitivity is changed', async () => { + const mockEnableFeature = jest.fn(); + mockUpdatePreventAIFeatureReturn = { + enableFeature: mockEnableFeature, + isLoading: false, }; - const result = getRepoConfig(orgConfig, 'repo-2'); - expect(result).toEqual({ - doesUseOrgDefaults: true, - repoConfig: orgConfig.org_defaults, + + render(, { + organization: mockOrganizationWithEnabledFeatures, + }); + + const dropdown = await screen.findByTestId('pr-review-sensitivity-dropdown'); + await selectEvent.openMenu(dropdown); + await selectEvent.select(dropdown, 'Low'); + + await waitFor(() => { + expect(mockEnableFeature).toHaveBeenCalledWith({ + feature: 'vanilla', + enabled: true, + orgName: 'org-1', + repoName: 'repo-1', + sensitivity: 'low', + }); + }); + }); + + it('calls enableFeature with correct sensitivity when bug prediction sensitivity is changed', async () => { + const mockEnableFeature = jest.fn(); + mockUpdatePreventAIFeatureReturn = { + enableFeature: mockEnableFeature, + isLoading: false, + }; + + render(, { + organization: mockOrganizationWithEnabledFeatures, }); + + const dropdown = await screen.findByTestId('error-prediction-sensitivity-dropdown'); + await selectEvent.openMenu(dropdown); + await selectEvent.select(dropdown, 'Critical'); + + await waitFor(() => { + expect(mockEnableFeature).toHaveBeenCalledWith({ + feature: 'bug_prediction', + enabled: true, + orgName: 'org-1', + repoName: 'repo-1', + sensitivity: 'critical', + }); + }); + }); + + it('disables sensitivity dropdowns when loading', async () => { + mockUpdatePreventAIFeatureReturn = { + enableFeature: jest.fn(), + isLoading: true, + }; + + render(, { + organization: mockOrganizationWithEnabledFeatures, + }); + + const vanillaDropdown = await screen.findByTestId('pr-review-sensitivity-dropdown'); + const bugPredictionDropdown = await screen.findByTestId( + 'error-prediction-sensitivity-dropdown' + ); + + expect(vanillaDropdown).toBeDisabled(); + expect(bugPredictionDropdown).toBeDisabled(); + }); + + it('shows default sensitivity value when sensitivity is undefined', async () => { + const orgWithUndefinedSensitivity = OrganizationFixture({ + preventAiConfigGithub: { + schema_version: 'v1', + github_organizations: {}, + default_org_config: { + org_defaults: { + bug_prediction: { + enabled: true, + triggers: {on_command_phrase: true, on_ready_for_review: false}, + // sensitivity is undefined + }, + test_generation: { + enabled: false, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + }, + vanilla: { + enabled: true, + triggers: {on_command_phrase: false, on_ready_for_review: false}, + // sensitivity is undefined + }, + }, + repo_overrides: {}, + }, + }, + }); + + render(, { + organization: orgWithUndefinedSensitivity, + }); + + // Should default to 'medium' when sensitivity is undefined + const vanillaDropdown = await screen.findByTestId('pr-review-sensitivity-dropdown'); + const bugPredictionDropdown = await screen.findByTestId( + 'error-prediction-sensitivity-dropdown' + ); + + expect(vanillaDropdown).toHaveDisplayValue('Medium'); + expect(bugPredictionDropdown).toHaveDisplayValue('Medium'); }); }); }); diff --git a/static/app/views/prevent/preventAI/manageReposPanel.tsx b/static/app/views/prevent/preventAI/manageReposPanel.tsx index 1f59ae83d2700c..5498741ca9ff48 100644 --- a/static/app/views/prevent/preventAI/manageReposPanel.tsx +++ b/static/app/views/prevent/preventAI/manageReposPanel.tsx @@ -1,5 +1,6 @@ import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; +import {CompactSelect} from 'sentry/components/core/compactSelect'; import {Flex} from 'sentry/components/core/layout'; import {ExternalLink} from 'sentry/components/core/link'; import {Switch} from 'sentry/components/core/switch'; @@ -9,7 +10,7 @@ import SlideOverPanel from 'sentry/components/slideOverPanel'; import {IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {type PreventAIOrgConfig} from 'sentry/types/prevent'; -import type {PreventAIFeatureConfigsByName} from 'sentry/types/prevent'; +import type {PreventAIFeatureConfigsByName, Sensitivity} from 'sentry/types/prevent'; import useOrganization from 'sentry/utils/useOrganization'; import {useUpdatePreventAIFeature} from 'sentry/views/prevent/preventAI/hooks/useUpdatePreventAIFeature'; @@ -20,6 +21,31 @@ interface ManageReposPanelProps { repoName: string; } +interface SensitivityOption { + details: string; + label: string; + value: Sensitivity; +} + +const sensitivityOptions: SensitivityOption[] = [ + {value: 'low', label: 'Low', details: 'Post all potential issues for maximum breadth.'}, + { + value: 'medium', + label: 'Medium', + details: 'Post likely issues for a balance of thoroughness and noise.', + }, + { + value: 'high', + label: 'High', + details: 'Post only major issues to highlight most impactful findings.', + }, + { + value: 'critical', + label: 'Critical', + details: 'Post only high-impact, high-sensitivity issues for maximum focus.', + }, +]; + function ManageReposPanel({ collapsed, onClose, @@ -111,9 +137,9 @@ function ManageReposPanel({ justify="between" > - Enable PR Review + {t('Enable PR Review')} - Run when @sentry review is commented on a PR. + {t('Run when @sentry review is commented on a PR.')} + {repoConfig.vanilla.enabled && ( + + + {t('Sensitivity')}} + help={ + + {t('Set the sensitivity level for PR review analysis.')} + + } + alignRight + flexibleControlStateSize + > + + await enableFeature({ + feature: 'vanilla', + enabled: true, + orgName, + repoName, + sensitivity: option.value, + }) + } + aria-label="PR Review Sensitivity" + menuWidth={350} + maxMenuWidth={500} + data-test-id="pr-review-sensitivity-dropdown" + /> + + + + )} {/* Test Generation Feature */} @@ -145,9 +206,9 @@ function ManageReposPanel({ justify="between" > - Enable Test Generation + {t('Enable Test Generation')} - Run when @sentry generate-test is commented on a PR. + {t('Run when @sentry generate-test is commented on a PR.')} - Enable Error Prediction + {t('Enable Error Prediction')} - Allow organization members to review potential bugs. + {t('Allow organization members to review potential bugs.')} { const newValue = !repoConfig.bug_prediction.enabled; - // Enable/disable the main bug prediction feature await enableFeature({ feature: 'bug_prediction', enabled: newValue, @@ -202,15 +262,37 @@ function ManageReposPanel({ /> {repoConfig.bug_prediction.enabled && ( - // width 150% because FieldGroup > FieldDescription has fixed width 50% - - + + + {t('Sensitivity')}} + help={ + + {t('Set the sensitivity level for error prediction.')} + + } + alignRight + flexibleControlStateSize + > + + await enableFeature({ + feature: 'bug_prediction', + enabled: true, + orgName, + repoName, + sensitivity: option.value, + }) + } + aria-label="Error Prediction Sensitivity" + menuWidth={350} + maxMenuWidth={500} + data-test-id="error-prediction-sensitivity-dropdown" + /> + {t('Auto Run on Opened Pull Requests')}} help={ @@ -218,7 +300,7 @@ function ManageReposPanel({ {t('Run when a PR is published, ignoring new pushes.')} } - inline + alignRight flexibleControlStateSize > {t('Run When Mentioned')}} help={ - {t('Run when @sentry review is commented on a PR')} + {t('Run when @sentry review is commented on a PR.')} } - inline + alignRight flexibleControlStateSize > { + it('should return a valid PreventAIConfig object', () => { + const fixture = PreventAIConfigFixture(); + + expect(fixture).toHaveProperty('schema_version', 'v1'); + expect(fixture).toHaveProperty('github_organizations'); + expect(fixture).toHaveProperty('default_org_config'); + expect(fixture.default_org_config).toHaveProperty('org_defaults'); + expect(fixture.default_org_config).toHaveProperty('repo_overrides'); + }); + + it('should have proper sensitivity types for all features', () => { + const fixture = PreventAIConfigFixture(); + const orgDefaults = fixture.default_org_config.org_defaults; + + expect(orgDefaults.bug_prediction.sensitivity).toBe('medium'); + expect(orgDefaults.test_generation.sensitivity).toBe('medium'); + expect(orgDefaults.vanilla.sensitivity).toBe('medium'); + + // Type check - should be of type Sensitivity + const bugPredictionSensitivity: Sensitivity = orgDefaults.bug_prediction.sensitivity; + const testGenerationSensitivity: Sensitivity = + orgDefaults.test_generation.sensitivity; + const vanillaSensitivity: Sensitivity = orgDefaults.vanilla.sensitivity; + + expect(bugPredictionSensitivity).toBe('medium'); + expect(testGenerationSensitivity).toBe('medium'); + expect(vanillaSensitivity).toBe('medium'); + }); + + it('should have all features disabled by default', () => { + const fixture = PreventAIConfigFixture(); + const orgDefaults = fixture.default_org_config.org_defaults; + + expect(orgDefaults.bug_prediction.enabled).toBe(false); + expect(orgDefaults.test_generation.enabled).toBe(false); + expect(orgDefaults.vanilla.enabled).toBe(false); + }); + + it('should have all triggers disabled by default', () => { + const fixture = PreventAIConfigFixture(); + const orgDefaults = fixture.default_org_config.org_defaults; + + Object.values(orgDefaults).forEach(feature => { + expect(feature.triggers.on_command_phrase).toBe(false); + expect(feature.triggers.on_ready_for_review).toBe(false); + }); + }); + + it('should have empty github_organizations and repo_overrides by default', () => { + const fixture = PreventAIConfigFixture(); + + expect(fixture.github_organizations).toEqual({}); + expect(fixture.default_org_config.repo_overrides).toEqual({}); + }); +}); diff --git a/tests/js/fixtures/prevent.ts b/tests/js/fixtures/prevent.ts index f0a5a047e1ecf8..53adf3574add67 100644 --- a/tests/js/fixtures/prevent.ts +++ b/tests/js/fixtures/prevent.ts @@ -1,3 +1,5 @@ +import type {Sensitivity} from 'sentry/types/prevent'; + export function PreventAIConfigFixture() { return { schema_version: 'v1', @@ -7,17 +9,17 @@ export function PreventAIConfigFixture() { bug_prediction: { enabled: false, triggers: {on_command_phrase: false, on_ready_for_review: false}, - sensitivity: 'medium', + sensitivity: 'medium' as Sensitivity, }, test_generation: { enabled: false, triggers: {on_command_phrase: false, on_ready_for_review: false}, - sensitivity: 'medium', + sensitivity: 'medium' as Sensitivity, }, vanilla: { enabled: false, triggers: {on_command_phrase: false, on_ready_for_review: false}, - sensitivity: 'medium', + sensitivity: 'medium' as Sensitivity, }, }, repo_overrides: {},