|
13 | 13 | from sentry.locks import locks |
14 | 14 | from sentry.seer.autofix.constants import SeerAutomationSource |
15 | 15 | from sentry.seer.autofix.issue_summary import ( |
| 16 | + _apply_user_preference_upper_bound, |
16 | 17 | _call_seer, |
| 18 | + _fetch_user_preference, |
17 | 19 | _get_event, |
18 | 20 | _get_stopping_point_from_fixability, |
19 | 21 | _run_automation, |
@@ -789,3 +791,215 @@ def test_without_feature_flag(self, mock_gen, mock_budget, mock_state, mock_rate |
789 | 791 |
|
790 | 792 | mock_trigger.assert_called_once() |
791 | 793 | assert mock_trigger.call_args[1]["stopping_point"] is None |
| 794 | + |
| 795 | + |
| 796 | +class TestFetchUserPreference: |
| 797 | + @patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={}) |
| 798 | + @patch("sentry.seer.autofix.issue_summary.requests.post") |
| 799 | + def test_fetch_user_preference_success(self, mock_post, mock_sign): |
| 800 | + mock_response = Mock() |
| 801 | + mock_response.json.return_value = { |
| 802 | + "preference": {"automated_run_stopping_point": "solution"} |
| 803 | + } |
| 804 | + mock_response.raise_for_status = Mock() |
| 805 | + mock_post.return_value = mock_response |
| 806 | + |
| 807 | + result = _fetch_user_preference(project_id=123) |
| 808 | + |
| 809 | + assert result == "solution" |
| 810 | + mock_post.assert_called_once() |
| 811 | + mock_response.raise_for_status.assert_called_once() |
| 812 | + |
| 813 | + @patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={}) |
| 814 | + @patch("sentry.seer.autofix.issue_summary.requests.post") |
| 815 | + def test_fetch_user_preference_no_preference(self, mock_post, mock_sign): |
| 816 | + mock_response = Mock() |
| 817 | + mock_response.json.return_value = {"preference": None} |
| 818 | + mock_response.raise_for_status = Mock() |
| 819 | + mock_post.return_value = mock_response |
| 820 | + |
| 821 | + result = _fetch_user_preference(project_id=123) |
| 822 | + |
| 823 | + assert result is None |
| 824 | + |
| 825 | + @patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={}) |
| 826 | + @patch("sentry.seer.autofix.issue_summary.requests.post") |
| 827 | + def test_fetch_user_preference_empty_preference(self, mock_post, mock_sign): |
| 828 | + mock_response = Mock() |
| 829 | + mock_response.json.return_value = {"preference": {"automated_run_stopping_point": None}} |
| 830 | + mock_response.raise_for_status = Mock() |
| 831 | + mock_post.return_value = mock_response |
| 832 | + |
| 833 | + result = _fetch_user_preference(project_id=123) |
| 834 | + |
| 835 | + assert result is None |
| 836 | + |
| 837 | + @patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={}) |
| 838 | + @patch("sentry.seer.autofix.issue_summary.requests.post") |
| 839 | + def test_fetch_user_preference_api_error(self, mock_post, mock_sign): |
| 840 | + mock_post.side_effect = Exception("API error") |
| 841 | + |
| 842 | + result = _fetch_user_preference(project_id=123) |
| 843 | + |
| 844 | + assert result is None |
| 845 | + |
| 846 | + |
| 847 | +class TestApplyUserPreferenceUpperBound: |
| 848 | + @pytest.mark.parametrize( |
| 849 | + "fixability,user_pref,expected", |
| 850 | + [ |
| 851 | + # Fixability is None - always return None |
| 852 | + (None, "open_pr", None), |
| 853 | + (None, "solution", None), |
| 854 | + (None, None, None), |
| 855 | + # User preference is None - return fixability suggestion |
| 856 | + (AutofixStoppingPoint.OPEN_PR, None, AutofixStoppingPoint.OPEN_PR), |
| 857 | + (AutofixStoppingPoint.CODE_CHANGES, None, AutofixStoppingPoint.CODE_CHANGES), |
| 858 | + (AutofixStoppingPoint.SOLUTION, None, AutofixStoppingPoint.SOLUTION), |
| 859 | + (AutofixStoppingPoint.ROOT_CAUSE, None, AutofixStoppingPoint.ROOT_CAUSE), |
| 860 | + # User preference limits automation (user is more conservative) |
| 861 | + ( |
| 862 | + AutofixStoppingPoint.OPEN_PR, |
| 863 | + "code_changes", |
| 864 | + AutofixStoppingPoint.CODE_CHANGES, |
| 865 | + ), |
| 866 | + (AutofixStoppingPoint.OPEN_PR, "solution", AutofixStoppingPoint.SOLUTION), |
| 867 | + (AutofixStoppingPoint.OPEN_PR, "root_cause", AutofixStoppingPoint.ROOT_CAUSE), |
| 868 | + (AutofixStoppingPoint.CODE_CHANGES, "solution", AutofixStoppingPoint.SOLUTION), |
| 869 | + ( |
| 870 | + AutofixStoppingPoint.CODE_CHANGES, |
| 871 | + "root_cause", |
| 872 | + AutofixStoppingPoint.ROOT_CAUSE, |
| 873 | + ), |
| 874 | + (AutofixStoppingPoint.SOLUTION, "root_cause", AutofixStoppingPoint.ROOT_CAUSE), |
| 875 | + # Fixability is more conservative (fixability limits automation) |
| 876 | + (AutofixStoppingPoint.SOLUTION, "open_pr", AutofixStoppingPoint.SOLUTION), |
| 877 | + ( |
| 878 | + AutofixStoppingPoint.SOLUTION, |
| 879 | + "code_changes", |
| 880 | + AutofixStoppingPoint.SOLUTION, |
| 881 | + ), |
| 882 | + (AutofixStoppingPoint.ROOT_CAUSE, "open_pr", AutofixStoppingPoint.ROOT_CAUSE), |
| 883 | + ( |
| 884 | + AutofixStoppingPoint.ROOT_CAUSE, |
| 885 | + "code_changes", |
| 886 | + AutofixStoppingPoint.ROOT_CAUSE, |
| 887 | + ), |
| 888 | + (AutofixStoppingPoint.ROOT_CAUSE, "solution", AutofixStoppingPoint.ROOT_CAUSE), |
| 889 | + # Same level - return fixability |
| 890 | + (AutofixStoppingPoint.OPEN_PR, "open_pr", AutofixStoppingPoint.OPEN_PR), |
| 891 | + ( |
| 892 | + AutofixStoppingPoint.CODE_CHANGES, |
| 893 | + "code_changes", |
| 894 | + AutofixStoppingPoint.CODE_CHANGES, |
| 895 | + ), |
| 896 | + (AutofixStoppingPoint.SOLUTION, "solution", AutofixStoppingPoint.SOLUTION), |
| 897 | + ( |
| 898 | + AutofixStoppingPoint.ROOT_CAUSE, |
| 899 | + "root_cause", |
| 900 | + AutofixStoppingPoint.ROOT_CAUSE, |
| 901 | + ), |
| 902 | + ], |
| 903 | + ) |
| 904 | + def test_upper_bound_combinations(self, fixability, user_pref, expected): |
| 905 | + result = _apply_user_preference_upper_bound(fixability, user_pref) |
| 906 | + assert result == expected |
| 907 | + |
| 908 | + |
| 909 | +@with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) |
| 910 | +class TestRunAutomationWithUpperBound(APITestCase, SnubaTestCase): |
| 911 | + def setUp(self) -> None: |
| 912 | + super().setUp() |
| 913 | + self.group = self.create_group() |
| 914 | + event_data = load_data("python") |
| 915 | + self.event = self.store_event(data=event_data, project_id=self.project.id) |
| 916 | + |
| 917 | + @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") |
| 918 | + @patch("sentry.seer.autofix.issue_summary._fetch_user_preference") |
| 919 | + @patch( |
| 920 | + "sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited", |
| 921 | + return_value=False, |
| 922 | + ) |
| 923 | + @patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None) |
| 924 | + @patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True) |
| 925 | + @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") |
| 926 | + def test_user_preference_limits_high_fixability( |
| 927 | + self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger |
| 928 | + ): |
| 929 | + """High fixability (CODE_CHANGES) limited by user preference (SOLUTION)""" |
| 930 | + self.project.update_option("sentry:autofix_automation_tuning", "always") |
| 931 | + mock_gen.return_value = SummarizeIssueResponse( |
| 932 | + group_id=str(self.group.id), |
| 933 | + headline="h", |
| 934 | + whats_wrong="w", |
| 935 | + trace="t", |
| 936 | + possible_cause="c", |
| 937 | + scores=SummarizeIssueScores(fixability_score=0.80), # High = CODE_CHANGES |
| 938 | + ) |
| 939 | + mock_fetch.return_value = "solution" |
| 940 | + |
| 941 | + _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) |
| 942 | + |
| 943 | + mock_trigger.assert_called_once() |
| 944 | + # Should be limited to SOLUTION by user preference |
| 945 | + assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION |
| 946 | + |
| 947 | + @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") |
| 948 | + @patch("sentry.seer.autofix.issue_summary._fetch_user_preference") |
| 949 | + @patch( |
| 950 | + "sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited", |
| 951 | + return_value=False, |
| 952 | + ) |
| 953 | + @patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None) |
| 954 | + @patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True) |
| 955 | + @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") |
| 956 | + def test_fixability_limits_permissive_user_preference( |
| 957 | + self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger |
| 958 | + ): |
| 959 | + """Medium fixability (SOLUTION) used despite user allowing OPEN_PR""" |
| 960 | + self.project.update_option("sentry:autofix_automation_tuning", "always") |
| 961 | + mock_gen.return_value = SummarizeIssueResponse( |
| 962 | + group_id=str(self.group.id), |
| 963 | + headline="h", |
| 964 | + whats_wrong="w", |
| 965 | + trace="t", |
| 966 | + possible_cause="c", |
| 967 | + scores=SummarizeIssueScores(fixability_score=0.50), # Medium = SOLUTION |
| 968 | + ) |
| 969 | + mock_fetch.return_value = "open_pr" |
| 970 | + |
| 971 | + _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) |
| 972 | + |
| 973 | + mock_trigger.assert_called_once() |
| 974 | + # Should use SOLUTION from fixability, not OPEN_PR from user |
| 975 | + assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION |
| 976 | + |
| 977 | + @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") |
| 978 | + @patch("sentry.seer.autofix.issue_summary._fetch_user_preference") |
| 979 | + @patch( |
| 980 | + "sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited", |
| 981 | + return_value=False, |
| 982 | + ) |
| 983 | + @patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None) |
| 984 | + @patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True) |
| 985 | + @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") |
| 986 | + def test_no_user_preference_uses_fixability_only( |
| 987 | + self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger |
| 988 | + ): |
| 989 | + """When user has no preference, use fixability score alone""" |
| 990 | + self.project.update_option("sentry:autofix_automation_tuning", "always") |
| 991 | + mock_gen.return_value = SummarizeIssueResponse( |
| 992 | + group_id=str(self.group.id), |
| 993 | + headline="h", |
| 994 | + whats_wrong="w", |
| 995 | + trace="t", |
| 996 | + possible_cause="c", |
| 997 | + scores=SummarizeIssueScores(fixability_score=0.80), # High = CODE_CHANGES |
| 998 | + ) |
| 999 | + mock_fetch.return_value = None |
| 1000 | + |
| 1001 | + _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) |
| 1002 | + |
| 1003 | + mock_trigger.assert_called_once() |
| 1004 | + # Should use CODE_CHANGES from fixability |
| 1005 | + assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES |
0 commit comments