diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 076e6583124d..ed45b9ddb806 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -43,11 +43,12 @@ get_project_seer_preferences, make_autofix_start_request, make_autofix_update_request, + read_preference_from_sentry_db, set_project_seer_preference, write_preference_to_sentry_db, ) from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data -from sentry.seer.models import SeerProjectPreference +from sentry.seer.models import SeerApiError, SeerApiResponseValidationError, SeerProjectPreference from sentry.seer.signed_seer_api import SeerViewerContext from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent @@ -700,16 +701,27 @@ def get_all_tags_overview( def _resolve_project_preference( organization: Organization, project: Project, fallback_repos: list[dict] -) -> SeerProjectPreference: +) -> SeerProjectPreference | None: """ Resolve the Seer project preference for a project before triggering autofix. If an existing preference is found in Seer, returns it. If not, creates one from fallback_repos. """ - preference_response = get_project_seer_preferences(project.id) - if preference_response.preference: - return preference_response.preference + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preference = read_preference_from_sentry_db(project) + else: + try: + preference = get_project_seer_preferences(project.id).preference + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "seer.resolve_project_preference.get_failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + return None + + if preference: + return preference default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) preference = SeerProjectPreference( @@ -720,7 +732,14 @@ def _resolve_project_preference( automation_handoff=default_handoff, ) - set_project_seer_preference(preference) + try: + set_project_seer_preference(preference) + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "seer.resolve_project_preference.set_failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + return None if features.has("organizations:seer-project-settings-dual-write", organization): try: @@ -781,22 +800,19 @@ def trigger_autofix( return _respond_with_error("Cannot fix issues without an event.", 400) code_mappings = get_sorted_code_mapping_configs(group.project) - repos = get_autofix_repos_from_project_code_mappings(group.project, code_mappings=code_mappings) + code_mappings_repos = get_autofix_repos_from_project_code_mappings( + group.project, code_mappings=code_mappings + ) # Resolve the project preference from Seer, or bootstrap one from code mapping repos. # On success, preference.repositories becomes the source of truth for repos # (even if empty — matching Seer's behavior of unconditionally using preference repos). # On failure, we fall back to the original code mapping repos above. - preference: SeerProjectPreference | None = None - try: - preference = _resolve_project_preference(group.organization, group.project, repos) + preference = _resolve_project_preference(group.organization, group.project, code_mappings_repos) + if preference: repos = [repo.dict() for repo in preference.repositories] - except Exception: - logger.exception( - "seer.write_preferences.resolve_project_preference.failed", - extra={"project_id": group.project.id, "organization_id": group.organization.id}, - exc_info=True, - ) + else: + repos = code_mappings_repos # Pre-resolve stacktrace frame paths using code mappings so Seer can skip # expensive git tree fetches for large repos. diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index c14a8d2ba6dc..a6ac4426b485 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from rest_framework.exceptions import PermissionDenied -from sentry import analytics +from sentry import analytics, features from sentry.analytics.events.autofix_events import ( AiAutofixAgentHandoffEvent, AiAutofixCodeChangesCompletedEvent, @@ -44,6 +44,7 @@ AutofixStoppingPoint, get_autofix_state, get_project_seer_preferences, + read_preference_from_sentry_db, ) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.client import SeerExplorerClient @@ -504,21 +505,28 @@ def trigger_coding_agent_handoff( auto_create_pr = False repo_definitions: list[SeerRepoDefinition] = [] - try: - preference_response = get_project_seer_preferences(group.project_id) - if preference_response and preference_response.preference: - repo_definitions = list(preference_response.preference.repositories) - if preference_response.preference.automation_handoff: - auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except Exception: - logger.exception( - "autofix.coding_agent_handoff.get_preferences_error", - extra={ - "organization_id": group.organization.id, - "run_id": run_id, - "project_id": group.project_id, - }, - ) + if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): + preference = read_preference_from_sentry_db(group.project) + if preference: + repo_definitions = preference.repositories + if preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + else: + try: + preference = get_project_seer_preferences(group.project_id).preference + if preference: + repo_definitions = preference.repositories + if preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + except Exception: + logger.exception( + "autofix.coding_agent_handoff.get_preferences_error", + extra={ + "organization_id": group.organization.id, + "run_id": run_id, + "project_id": group.project_id, + }, + ) if not repo_definitions: return { diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 9ae620cae454..8d3ef5255bba 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import analytics, features +from sentry.models.project import Project class IntegrationNotFound(NotFound): @@ -57,6 +58,7 @@ class StateReposNotFound(NotFound): get_coding_agent_prompt, get_project_seer_preferences, make_store_coding_agent_states_request, + read_preference_from_sentry_db, update_coding_agent_state, ) from sentry.seer.models import SeerApiError, SeerApiResponseValidationError @@ -231,20 +233,35 @@ def _launch_agents_for_repos( # Fetch project preferences to get auto_create_pr setting from automation_handoff auto_create_pr = False - try: - preference_response = get_project_seer_preferences(autofix_state.request.project_id) - if preference_response and preference_response.preference: - if preference_response.preference.automation_handoff: - auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except (SeerApiError, SeerApiResponseValidationError): - logger.exception( - "coding_agent.get_project_seer_preferences_error", - extra={ - "organization_id": organization.id, - "run_id": run_id, - "project_id": autofix_state.request.project_id, - }, - ) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + try: + project = Project.objects.get_from_cache(id=autofix_state.request.project_id) + preference = read_preference_from_sentry_db(project) + if preference and preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + except Project.DoesNotExist: + logger.exception( + "coding_agent.project_not_found", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "project_id": autofix_state.request.project_id, + }, + ) + else: + try: + preference = get_project_seer_preferences(autofix_state.request.project_id).preference + if preference and preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "coding_agent.get_project_seer_preferences_error", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "project_id": autofix_state.request.project_id, + }, + ) repos = set( _extract_repos_from_root_cause(autofix_state) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 677e3ce7e273..f840d9394c91 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -35,6 +35,7 @@ is_seer_autotriggered_autofix_rate_limited_and_increment, is_seer_seat_based_tier_enabled, make_get_project_preference_request, + read_preference_from_sentry_db, ) from sentry.seer.entrypoints.cache import SeerOperatorAutofixCache from sentry.seer.entrypoints.operator import SeerAutofixOperator @@ -511,7 +512,13 @@ def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint: """ fixability_score = get_and_update_group_fixability_score(group) fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) - user_preference = _fetch_user_preference(group.project.id) + + if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): + preference = read_preference_from_sentry_db(group.project) + user_preference = preference.automated_run_stopping_point if preference else None + else: + user_preference = _fetch_user_preference(group.project.id) + return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index b392eedd63f5..7ec8a3254943 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -22,6 +22,7 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, get_project_seer_preferences, + read_preference_from_sentry_db, resolve_repository_ids, set_project_seer_preference, write_preference_to_sentry_db, @@ -488,50 +489,64 @@ def _get_handoff_config_if_applicable( return None # Check project preferences - try: - preference_response = get_project_seer_preferences(group.project_id) - except (SeerApiError, SeerApiResponseValidationError): - logger.exception( - "autofix.on_completion_hook.get_preferences_failed", - extra={"group_id": group.id, "project_id": group.project_id}, - ) - return None - if not preference_response or not preference_response.preference: - return None - handoff_config = preference_response.preference.automation_handoff - if not handoff_config: - return None + if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): + preference = read_preference_from_sentry_db(group.project) + else: + try: + preference = get_project_seer_preferences(group.project_id).preference + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "autofix.on_completion_hook.get_preferences_failed", + extra={"group_id": group.id, "project_id": group.project_id}, + ) + return None - return handoff_config + if not preference: + return None + return preference.automation_handoff @classmethod def _clear_handoff_preference( cls, project: Project, run_id: int, organization: Organization ) -> None: """Clear automation_handoff from project preferences after integration is not found.""" - try: - preference_response = get_project_seer_preferences(project.id) - if preference_response and preference_response.preference: - updated_preference = preference_response.preference.copy( - update={"automation_handoff": None} + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preference = read_preference_from_sentry_db(project) + else: + try: + preference = get_project_seer_preferences(project.id).preference + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "autofix.on_completion_hook.clear_handoff_preference_failed", + extra={"run_id": run_id, "organization_id": organization.id}, ) - set_project_seer_preference(updated_preference) + return - if features.has("organizations:seer-project-settings-dual-write", organization): - try: - validated_pref = SeerProjectPreference.validate(updated_preference) - resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) - write_preference_to_sentry_db(project, resolved_pref[0]) - except Exception: - logger.exception( - "seer.write_preferences.failed", - extra={"project_id": project.id, "organization_id": organization.id}, - ) + if not preference or preference.automation_handoff is None: + return + + updated_preference = preference.copy(update={"automation_handoff": None}) + + try: + set_project_seer_preference(updated_preference) except (SeerApiError, SeerApiResponseValidationError): logger.exception( "autofix.on_completion_hook.clear_handoff_preference_failed", extra={"run_id": run_id, "organization_id": organization.id}, ) + return + + if features.has("organizations:seer-project-settings-dual-write", organization): + try: + resolved_preference = resolve_repository_ids( + organization.id, [SeerProjectPreference.validate(updated_preference)] + )[0] + write_preference_to_sentry_db(project, resolved_preference) + except Exception: + logger.exception( + "seer.write_preferences.failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) @classmethod def _trigger_coding_agent_handoff( diff --git a/src/sentry/seer/autofix/trigger.py b/src/sentry/seer/autofix/trigger.py index b52e3ca9478a..c4608e500e56 100644 --- a/src/sentry/seer/autofix/trigger.py +++ b/src/sentry/seer/autofix/trigger.py @@ -182,7 +182,7 @@ def get_seat_based_seer_automation_skip_reason( return "automation_already_dispatched" # Another process already dispatched automation # Check if project has connected repositories - requirement for new pricing - if not has_project_connected_repos(group.organization.id, group.project.id): + if not has_project_connected_repos(group.organization, group.project): return "no_connected_repos" return None diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 15ac294d7ab9..3afa654a8863 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -626,24 +626,27 @@ def _write_preferences_to_sentry_db( repo_def.branch_overrides ) - # Create project repos. - created_project_repos = SeerProjectRepository.objects.bulk_create(project_repos_to_create) - - # Create branch overrides using the created project repos. - overrides_to_create: list[SeerProjectRepositoryBranchOverride] = [] - for seer_project_repo in created_project_repos: - for override in overrides_by_key.get( - (seer_project_repo.project_id, seer_project_repo.repository_id), [] - ): - overrides_to_create.append( - SeerProjectRepositoryBranchOverride( - seer_project_repository=seer_project_repo, - tag_name=override.tag_name, - tag_value=override.tag_value, - branch_name=override.branch_name, + if project_repos_to_create: + # Create project repos. + created_project_repos = SeerProjectRepository.objects.bulk_create( + project_repos_to_create + ) + + # Create branch overrides using the created project repos. + overrides_to_create: list[SeerProjectRepositoryBranchOverride] = [] + for seer_project_repo in created_project_repos: + for override in overrides_by_key.get( + (seer_project_repo.project_id, seer_project_repo.repository_id), [] + ): + overrides_to_create.append( + SeerProjectRepositoryBranchOverride( + seer_project_repository=seer_project_repo, + tag_name=override.tag_name, + tag_value=override.tag_value, + branch_name=override.branch_name, + ) ) - ) - SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) + SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) # Write ProjectOptions last so cache updates only happen after all DB writes succeed # (cache cannot be rolled back by the transaction). @@ -827,51 +830,46 @@ def set_project_seer_preference(preference: SeerProjectPreference) -> None: def has_project_connected_repos( - organization_id: int, project_id: int, *, skip_cache: bool = False + organization: Organization, project: Project, *, skip_cache: bool = False ) -> bool: """ Check if a project has connected repositories for Seer automation. Checks Seer preferences first, then falls back to Sentry code mappings. Results are cached for 15 minutes to minimize API calls. """ - cache_key = f"seer-project-has-repos:{organization_id}:{project_id}" + cache_key = f"seer-project-has-repos:{organization.id}:{project.id}" if not skip_cache: cached_value = cache.get(cache_key) if cached_value is not None: return cached_value has_repos = False - - try: - project_preferences = get_project_seer_preferences(project_id) - has_repos = bool( - project_preferences.preference and project_preferences.preference.repositories - ) - except (SeerApiError, SeerApiResponseValidationError): - pass + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preference = read_preference_from_sentry_db(project) + has_repos = bool(preference and preference.repositories) + else: + try: + preference = get_project_seer_preferences(project.id).preference + has_repos = bool(preference and preference.repositories) + except (SeerApiError, SeerApiResponseValidationError): + pass if not has_repos: # If it's the first autofix run of project we check code mapping. - try: - project = Project.objects.get(id=project_id) - has_repos = bool(get_autofix_repos_from_project_code_mappings(project)) - except Project.DoesNotExist: - pass + has_repos = bool(get_autofix_repos_from_project_code_mappings(project)) logger.info( "Checking if project has repositories connected", - extra={ - "org_id": organization_id, - "project_id": project_id, - "has_repos": has_repos, - }, + extra={"org_id": organization.id, "project_id": project.id, "has_repos": has_repos}, ) cache.set(cache_key, has_repos, timeout=60 * 15) # Cache for 15 minutes return has_repos -def bulk_get_project_preferences(organization_id: int, project_ids: list[int]) -> dict[str, dict]: +def bulk_get_project_preferences( + organization_id: int, project_ids: list[int] +) -> dict[str, dict | None]: """Bulk fetch Seer project preferences. Returns dict mapping project ID (string) to preference dict.""" viewer_context = SeerViewerContext(organization_id=organization_id) response = make_bulk_get_project_preferences_request( diff --git a/src/sentry/seer/endpoints/group_autofix_setup_check.py b/src/sentry/seer/endpoints/group_autofix_setup_check.py index c54e313341c8..6ec097c866ec 100644 --- a/src/sentry/seer/endpoints/group_autofix_setup_check.py +++ b/src/sentry/seer/endpoints/group_autofix_setup_check.py @@ -156,9 +156,7 @@ def get(self, request: Request, group: Group) -> Response: try: # Check if project has repos linked in Seer. # Skip cache to ensure latest data from Seer API. - seer_repos_linked = has_project_connected_repos( - org.id, group.project.id, skip_cache=True - ) + seer_repos_linked = has_project_connected_repos(org, group.project, skip_cache=True) except Exception as e: # Default to False if we can't check if the project has repos linked in Seer. sentry_sdk.capture_exception(e) diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index f2addd5ca1d4..a3f2e0d02ab6 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -25,6 +25,7 @@ AutofixStoppingPoint, SeerAutofixSettingsSerializer, bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, bulk_set_project_preferences, bulk_write_preferences_to_sentry_db, deduplicate_repositories, @@ -192,14 +193,23 @@ def _serialize_projects_with_settings( autofix_automation_tuning_map = ProjectOption.objects.get_value_bulk( projects, "sentry:autofix_automation_tuning" ) - seer_preferences_map = bulk_get_project_preferences(organization.id, project_ids_list) or {} + + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db(organization.id, project_ids_list) + preferences_map = { + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() + } + else: + preferences_map = bulk_get_project_preferences(organization.id, project_ids_list) or {} + results = [] for project in projects: autofix_automation_tuning = ( autofix_automation_tuning_map.get(project) or AutofixAutomationTuningSettings.OFF.value ) - seer_pref = seer_preferences_map.get(str(project.id)) or {} + seer_pref = preferences_map.get(str(project.id)) or {} results.append( { "projectId": project.id, @@ -303,9 +313,18 @@ def post(self, request: Request, organization: Organization) -> Response: preferences_to_set: list[dict[str, Any]] = [] if automated_run_stopping_point or filtered_repo_mappings: - existing_preferences = bulk_get_project_preferences( - organization.id, list(projects_by_id.keys()) - ) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + existing_preferences = bulk_read_preferences_from_sentry_db( + organization.id, list(projects_by_id.keys()) + ) + existing_preferences_map = { + str(project_id): preference.dict() if preference else None + for project_id, preference in existing_preferences.items() + } + else: + existing_preferences_map = bulk_get_project_preferences( + organization.id, list(projects_by_id.keys()) + ) for proj_id, project in projects_by_id.items(): has_stopping_point_update = automated_run_stopping_point is not None @@ -315,7 +334,7 @@ def post(self, request: Request, organization: Organization) -> Response: continue project_id_str = str(proj_id) - existing_pref = existing_preferences.get(project_id_str) or {} + existing_pref = existing_preferences_map.get(project_id_str) or {} pref_update: dict[str, Any] = { **default_seer_project_preference(project).dict(), diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index 6836bc2595b8..68ce8f7a5f3f 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -21,6 +21,7 @@ get_autofix_repos_from_project_code_mappings, make_get_project_preference_request, make_set_project_preference_request, + read_preference_from_sentry_db, write_preference_to_sentry_db, ) from sentry.seer.endpoints.organization_autofix_automation_settings import ( @@ -152,28 +153,32 @@ def post(self, request: Request, project: Project) -> Response: return Response(status=204) def get(self, request: Request, project: Project) -> Response: - body = GetProjectPreferenceRequest(project_id=project.id) - viewer_context = SeerViewerContext( - organization_id=project.organization.id, user_id=request.user.id - ) - response = make_get_project_preference_request( - body, - connection_pool=seer_autofix_default_connection_pool, - viewer_context=viewer_context, - ) - - if response.status >= 400: - raise SeerApiError("Seer request failed", response.status) - - result = response.json() + if features.has( + "organizations:seer-project-settings-read-from-sentry", project.organization + ): + preference = read_preference_from_sentry_db(project) + else: + body = GetProjectPreferenceRequest(project_id=project.id) + viewer_context = SeerViewerContext( + organization_id=project.organization.id, user_id=request.user.id + ) + response = make_get_project_preference_request( + body, + connection_pool=seer_autofix_default_connection_pool, + viewer_context=viewer_context, + ) + + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + + result = response.json() + preference = result.get("preference") code_mapping_repos = get_autofix_repos_from_project_code_mappings(project) return Response( - PreferenceResponse.validate( - { - **result, - "code_mapping_repos": code_mapping_repos, - } + PreferenceResponse( + preference=preference, + code_mapping_repos=code_mapping_repos, ).dict() ) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 30e9085a9297..2ef0fde5f3b5 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -83,6 +83,7 @@ from sentry.seer.autofix.utils import ( AutofixTriggerSource, get_project_seer_preferences, + read_preference_from_sentry_db, resolve_repository_ids, write_preference_to_sentry_db, ) @@ -615,17 +616,24 @@ def trigger_coding_agent_launch( }, ) try: - project = Project.objects.get_from_cache(id=project_id) organization = Organization.objects.get_from_cache(id=organization_id) if features.has("organizations:seer-project-settings-dual-write", organization): - preference_response = get_project_seer_preferences(project.id) - if preference_response and preference_response.preference: - updated_preference = preference_response.preference.copy( - update={"automation_handoff": None} - ) - validated_pref = SeerProjectPreference.validate(updated_preference) - resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) - write_preference_to_sentry_db(project, resolved_pref[0]) + project = Project.objects.get_from_cache(id=project_id) + + if features.has( + "organizations:seer-project-settings-read-from-sentry", organization + ): + preference = read_preference_from_sentry_db(project) + else: + preference = get_project_seer_preferences(project.id).preference + + if preference and preference.automation_handoff is not None: + updated_preference = preference.copy(update={"automation_handoff": None}) + resolved_preference = resolve_repository_ids( + organization.id, [SeerProjectPreference.validate(updated_preference)] + )[0] + write_preference_to_sentry_db(project, resolved_preference) + # Returning the error code will prompt Seer to clear the preference handoff in its own DB too. except Exception: logger.exception( "coding_agent.clear_handoff_preference_failed", diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 0ab062fa8a2c..273117335669 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -21,6 +21,7 @@ ) from sentry.seer.autofix.utils import ( bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, bulk_set_project_preferences, bulk_write_preferences_to_sentry_db, deduplicate_repositories, @@ -246,7 +247,14 @@ def configure_seer_for_existing_org(organization_id: int) -> None: default_handoff_dict = default_handoff.dict() if default_handoff else None valid_stopping_points = get_valid_automated_run_stopping_points(organization) - preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db(organization_id, project_ids) + preferences_by_id = { + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() + } + else: + preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) # Determine which projects need updates preferences_to_set = [] diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 15a9da37e49f..82745e3bc0b8 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -16,6 +16,7 @@ from sentry.search.events.types import SnubaParams from sentry.seer.autofix.utils import ( bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, get_autofix_repos_from_project_code_mappings, ) from sentry.seer.explorer.context_engine_utils import ( @@ -257,7 +258,16 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: org_repo_definitions: dict[tuple[str, str, str], RepoDetails] = {} - preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db( + organization_id, list(project_map.keys()) + ) + preferences_by_id = { + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() + } + else: + preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) for project_id, project in project_map.items(): existing_pref = preferences_by_id.get(str(project_id)) diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 530b26f235e4..cf42058aeb85 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1176,25 +1176,25 @@ def test_creates_preference_with_empty_repos_when_no_fallback( @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_raises_on_get_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): + def test_returns_none_on_get_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): mock_get_prefs.side_effect = SeerApiError("test error", 500) - with pytest.raises(SeerApiError): - _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + assert result is None mock_set_pref.assert_not_called() mock_write_sentry.assert_not_called() @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_raises_on_set_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): + def test_returns_none_on_set_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) mock_set_pref.side_effect = SeerApiError("test error", 500) - with pytest.raises(SeerApiError): - _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + assert result is None mock_write_sentry.assert_not_called() @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @@ -1237,6 +1237,32 @@ def test_returns_preference_with_empty_repos( mock_set_pref.assert_not_called() mock_write_sentry.assert_not_called() + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") + @patch("sentry.seer.autofix.autofix.set_project_seer_preference") + @patch("sentry.seer.autofix.autofix.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") + def test_reads_from_sentry_db( + self, mock_get_prefs, mock_read_db, mock_set_pref, mock_write_sentry + ): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + existing_repos = [self._mock_repo("seer", "999")] + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=existing_repos, + automated_run_stopping_point="root_cause", + ) + + result = _resolve_project_preference( + self.organization, self.project, [self._mock_repo("sentry", "123")] + ) + + assert result is not None + assert result.repositories[0].name == "seer" + mock_get_prefs.assert_not_called() + mock_set_pref.assert_not_called() + class TestCallAutofix(TestCase): @patch("sentry.seer.autofix.autofix._get_github_username_for_user") diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index d966e571c417..745c8e0d742c 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,4 +1,5 @@ -from unittest.mock import MagicMock, patch +from datetime import datetime, timezone +from unittest.mock import MagicMock, Mock, patch import pytest from rest_framework.exceptions import PermissionDenied @@ -11,15 +12,24 @@ trigger_coding_agent_handoff, trigger_push_changes, ) -from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.constants import AutofixReferrer, AutofixStatus +from sentry.seer.autofix.utils import AutofixRequest, AutofixState from sentry.seer.explorer.client_models import ( Artifact, MemoryBlock, Message, SeerRunState, ) +from sentry.seer.models import ( + AutofixHandoffPoint, + PreferenceResponse, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRepoDefinition, +) from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestGenerateAutofixHandoffPrompt(TestCase): @@ -384,16 +394,8 @@ def _make_run_state(self, artifacts: list[Artifact] | None = None) -> SeerRunSta repo_pr_states={}, ) - def _make_preference_response(self, repos=None, auto_create_pr=False): - """Helper to create a PreferenceResponse with repos.""" - from sentry.seer.models import ( - AutofixHandoffPoint, - PreferenceResponse, - SeerAutomationHandoffConfiguration, - SeerProjectPreference, - SeerRepoDefinition, - ) - + def _make_preference(self, repos=None, auto_create_pr=False): + """Helper to create a SeerProjectPreference with repos.""" if repos is None: repos = [ SeerRepoDefinition(provider="github", owner="owner", name="repo", external_id="123") @@ -410,12 +412,16 @@ def _make_preference_response(self, repos=None, auto_create_pr=False): else None ) - preference = SeerProjectPreference( + return SeerProjectPreference( organization_id=self.organization.id, project_id=self.project.id, repositories=repos, automation_handoff=handoff_config, ) + + def _make_preference_response(self, repos=None, auto_create_pr=False) -> PreferenceResponse: + """Helper to create a PreferenceResponse with repos.""" + preference = self._make_preference(repos, auto_create_pr) return PreferenceResponse(preference=preference, code_mapping_repos=[]) @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") @@ -619,7 +625,7 @@ def test_trigger_coding_agent_handoff_no_preferences_returns_failure( """Test handoff with None preference response returns failure.""" mock_client = MagicMock() mock_client_class.return_value = mock_client - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) result = trigger_coding_agent_handoff( group=self.group, @@ -639,8 +645,6 @@ def test_trigger_coding_agent_handoff_filters_to_relevant_repo( self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that only the repo named in relevant_repo is passed to launch_coding_agents.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -684,8 +688,6 @@ def test_trigger_coding_agent_handoff_falls_back_to_first_repo_when_no_relevant_ self, mock_client_class, mock_get_prefs, mock_logger, mock_get_autofix_state ): """Test that when relevant_repo is absent, first configured repo is used and a warning is logged.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -731,8 +733,6 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match self, mock_client_class, mock_get_prefs, mock_logger, mock_get_autofix_state ): """Test that when relevant_repo doesn't match any configured repo, first repo is used.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -795,12 +795,6 @@ def test_trigger_coding_agent_handoff_enriches_branch_name_from_autofix_state( self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that branch_name is resolved from autofix state when unset in preferences.""" - from datetime import datetime, timezone - - from sentry.seer.autofix.constants import AutofixStatus - from sentry.seer.autofix.utils import AutofixRequest, AutofixState - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -852,8 +846,6 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that branch_name from preferences is used as-is when already set.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -881,6 +873,45 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se repos = mock_client.launch_coding_agents.call_args.kwargs["repos"] assert repos[0].branch_name == "release/v2" + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") + @patch("sentry.seer.autofix.autofix_agent.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") + @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") + def test_trigger_coding_agent_handoff_reads_from_sentry_db( + self, mock_client_class, mock_get_prefs, mock_read_db, mock_get_autofix_state + ): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_read_db.return_value = self._make_preference( + repos=[ + SeerRepoDefinition( + provider="github", + owner="owner", + name="repo", + external_id="123", + branch_name="main", + ) + ], + auto_create_pr=True, + ) + + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.get_run.return_value = self._make_run_state() + mock_client.launch_coding_agents.return_value = {"successes": [], "failures": []} + mock_get_autofix_state.return_value = None + + trigger_coding_agent_handoff( + group=self.group, run_id=123, referrer=AutofixReferrer.UNKNOWN, integration_id=456 + ) + + mock_get_prefs.assert_not_called() + + call_kwargs = mock_client.launch_coding_agents.call_args.kwargs + assert len(call_kwargs["repos"]) == 1 + assert call_kwargs["repos"][0].branch_name == "main" + assert call_kwargs["auto_create_pr"] is True + class TestTriggerPushChanges(TestCase): """Tests for trigger_push_changes function.""" diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 327669d82ec0..28423a09e484 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -1,5 +1,5 @@ from typing import TypedDict -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from sentry.seer.autofix.autofix_agent import AutofixStep from sentry.seer.autofix.constants import AutofixReferrer @@ -275,7 +275,7 @@ def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger): def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger, mock_get_prefs): """Continues to next step when not at stopping point.""" # No handoff configured - should continue with normal pipeline - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) state = run_state( blocks=[root_cause_memory_block()], @@ -586,6 +586,28 @@ def test_get_handoff_config_returns_config_when_applicable(self, mock_get_prefs) assert result == handoff_config + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.on_completion_hook.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + def test_get_handoff_config_reads_from_sentry_db(self, mock_get_prefs, mock_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + handoff_config = self._make_handoff_config() + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automation_handoff=handoff_config, + ) + + result = AutofixOnCompletionHook._get_handoff_config_if_applicable( + stopping_point=AutofixStoppingPoint.CODE_CHANGES, + current_step=AutofixStep.ROOT_CAUSE, + group=self.group, + ) + + mock_get_prefs.assert_not_called() + assert result == handoff_config + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") def test_maybe_continue_pipeline_triggers_handoff_when_configured( diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 8cdf3bb573b5..1aedaabcee43 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -36,6 +36,7 @@ SeerProjectRepositoryBranchOverride, ) from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.utils.cache import cache @@ -468,7 +469,7 @@ def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache): mock_response.preference = mock_preference mock_get_preferences.return_value = mock_response - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_cache.set.assert_called_once_with( @@ -492,7 +493,7 @@ def test_returns_false_when_no_repos( mock_get_preferences.return_value = mock_response mock_get_code_mappings.return_value = [] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_cache.set.assert_called_once_with( @@ -514,7 +515,7 @@ def test_returns_false_when_preference_is_none_and_no_code_mappings( mock_get_preferences.return_value = mock_response mock_get_code_mappings.return_value = [] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_cache.set.assert_called_once_with( @@ -538,7 +539,7 @@ def test_falls_back_to_code_mappings_when_no_seer_preference( {"provider": "github", "owner": "test", "name": "repo"} ] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_code_mappings.assert_called_once() @@ -554,7 +555,7 @@ def test_returns_cached_value_true(self, mock_get_preferences, mock_cache): """Test returns cached True value without calling API.""" mock_cache.get.return_value = True - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_preferences.assert_not_called() @@ -566,7 +567,7 @@ def test_returns_cached_value_false(self, mock_get_preferences, mock_cache): """Test returns cached False value without calling API.""" mock_cache.get.return_value = False - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_get_preferences.assert_not_called() @@ -583,7 +584,7 @@ def test_skip_cache_bypasses_cached_value(self, mock_get_preferences, mock_cache mock_response.preference = mock_preference mock_get_preferences.return_value = mock_response - result = has_project_connected_repos(self.organization.id, self.project.id, skip_cache=True) + result = has_project_connected_repos(self.organization, self.project, skip_cache=True) assert result is True # Fresh value from API, not cached False mock_cache.get.assert_not_called() # Cache not checked @@ -603,11 +604,37 @@ def test_falls_back_to_code_mappings_on_api_error( {"provider": "github", "owner": "test", "name": "repo"} ] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_code_mappings.assert_called_once() + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.utils.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + @patch("sentry.seer.autofix.utils.cache") + def test_reads_from_sentry_db(self, mock_cache, mock_get_prefs, mock_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_cache.get.return_value = None + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition(provider="github", owner="owner", name="repo", external_id="123") + ], + ) + + result = has_project_connected_repos(self.organization, self.project) + + assert result is True + mock_get_prefs.assert_not_called() + mock_read_db.assert_called_once() + mock_cache.set.assert_called_once_with( + f"seer-project-has-repos:{self.organization.id}:{self.project.id}", + True, + timeout=60 * 15, + ) + class TestSetProjectSeerPreference(TestCase): """Test the set_project_seer_preference function.""" diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 793e239f2e94..badb2964ed42 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -25,9 +25,17 @@ CodingAgentState, CodingAgentStatus, ) -from sentry.seer.models import SeerApiError, SeerRepoDefinition +from sentry.seer.models import ( + AutofixHandoffPoint, + PreferenceResponse, + SeerApiError, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRepoDefinition, +) from sentry.shared_integrations.exceptions import ApiError from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestLaunchAgentsForRepos(TestCase): @@ -213,8 +221,6 @@ def test_auto_create_pr_defaults_to_false_when_no_automation_handoff( self, mock_get_preferences, mock_get_prompt, mock_store_states ): """Test that auto_create_pr defaults to False when automation_handoff is None.""" - from sentry.seer.models import PreferenceResponse, SeerProjectPreference - # Setup: Mock get_project_seer_preferences to return preference without automation_handoff preference = SeerProjectPreference( organization_id=self.organization.id, @@ -257,6 +263,54 @@ def test_auto_create_pr_defaults_to_false_when_no_automation_handoff( launch_request = mock_installation.launch.call_args[0][0] assert launch_request.auto_create_pr is False + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_reads_from_sentry_db( + self, mock_get_preferences, mock_read_db, mock_get_prompt, mock_store_states + ): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", owner="getsentry", name="sentry", external_id="123" + ) + ], + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target="cursor_background_agent", + integration_id=123, + auto_create_pr=True, + ), + ) + + mock_get_prompt.return_value = "Test prompt" + + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + mock_get_preferences.assert_not_called() + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is True + assert launch_request.repository.owner == "getsentry" + assert launch_request.repository.name == "sentry" + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 548b83ebe7c1..606872bcc5dd 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -26,6 +26,7 @@ ) from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SummarizeIssueResponse, SummarizeIssueScores +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature @@ -1557,3 +1558,20 @@ def test_low_fixability_returns_root_cause(self, mock_fixability, mock_preferenc mock_preference.return_value = "open_pr" assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.ROOT_CAUSE + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.issue_summary.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.issue_summary._fetch_user_preference") + @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") + def test_reads_stopping_point_from_sentry_db( + self, mock_fixability, mock_preference, mock_read_db + ): + mock_fixability.return_value = 0.80 + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automated_run_stopping_point="solution", + ) + + assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.SOLUTION diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 7fc51b0eb87c..68cb3d8d586f 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -1,11 +1,16 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from sentry.seer.autofix.coding_agent import IntegrationNotFound +from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook from sentry.seer.autofix.utils import CodingAgentProviderType -from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration +from sentry.seer.models.seer_api_models import ( + SeerAutomationHandoffConfiguration, + SeerProjectPreference, +) from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestTriggerCodingAgentHandoff(TestCase): @@ -28,8 +33,6 @@ def _make_handoff_config(self, integration_id: int = 789) -> SeerAutomationHando def test_not_found_clears_automation_handoff( self, mock_trigger, mock_get_prefs, mock_set_pref ) -> None: - from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook - mock_trigger.side_effect = IntegrationNotFound("Integration not found") mock_pref = MagicMock() @@ -56,10 +59,8 @@ def test_not_found_clears_automation_handoff( def test_not_found_no_preference_response_does_not_call_set( self, mock_trigger, mock_get_prefs, mock_set_pref ) -> None: - from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook - mock_trigger.side_effect = IntegrationNotFound("Integration not found") - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) AutofixOnCompletionHook._trigger_coding_agent_handoff( organization=self.organization, @@ -69,3 +70,34 @@ def test_not_found_no_preference_response_does_not_call_set( ) mock_set_pref.assert_not_called() + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_not_found_reads_from_sentry_db( + self, mock_trigger, mock_get_prefs, mock_read_db, mock_set_pref + ) -> None: + """When feature flag enabled, reads preferences from Sentry DB to clear handoff.""" + + mock_trigger.side_effect = IntegrationNotFound("Integration not found") + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automation_handoff=self._make_handoff_config(), + ) + + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=1, + group=self.group, + handoff_config=self._make_handoff_config(), + ) + + mock_get_prefs.assert_not_called() + mock_read_db.assert_called_once_with(self.project) + mock_set_pref.assert_called_once() + updated_pref = mock_set_pref.call_args[0][0] + assert updated_pref.automation_handoff is None diff --git a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py index 5b26b83fe011..ea1fbbb33402 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -9,6 +9,7 @@ from sentry.seer.models.project_repository import SeerProjectRepository from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode @@ -145,6 +146,53 @@ def test_get_reads_project_preferences(self, mock_bulk_get_preferences): }, ] + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_read_preferences_from_sentry_db" + ) + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_reads_from_sentry_db(self, mock_bulk_get_preferences, mock_bulk_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition + + project = self.create_project(organization=self.organization, name="DB Read Project") + project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM.value + ) + + mock_bulk_read_db.return_value = { + project.id: SeerProjectPreference( + organization_id=self.organization.id, + project_id=project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="test-org", + name="test-repo", + external_id="12345", + ) + ], + automated_run_stopping_point=AutofixStoppingPoint.OPEN_PR.value, + ) + } + + response = self.client.get(self.url) + + assert response.status_code == 200 + mock_bulk_read_db.assert_called_once() + mock_bulk_get_preferences.assert_not_called() + assert response.data == [ + { + "projectId": project.id, + "autofixAutomationTuning": AutofixAutomationTuningSettings.MEDIUM.value, + "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, + "automationHandoff": None, + "reposCount": 1, + } + ] + @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" ) @@ -911,18 +959,22 @@ def test_post_rejects_mismatched_repo_name_or_owner(self, mock_bulk_set_preferen assert response.data["detail"] == "Invalid repository" mock_bulk_set_preferences.assert_not_called() + @with_feature("organizations:seer-project-settings-dual-write") + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_write_preferences_to_sentry_db" + ) @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" ) @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" ) - def test_post_creates_seer_project_repository( - self, mock_bulk_get_preferences, mock_bulk_set_preferences + def test_post_writes_to_sentry_db( + self, mock_bulk_get_preferences, mock_bulk_set_preferences, mock_bulk_write_db ): - """Test that POST creates SeerProjectRepository when feature flag is enabled.""" + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" project = self.create_project(organization=self.organization) - repo = Repository.objects.create( + Repository.objects.create( organization_id=self.organization.id, name="test-org/test-repo", provider="github", @@ -939,23 +991,25 @@ def test_post_creates_seer_project_repository( "organizationId": self.organization.id, } - with self.feature("organizations:seer-project-settings-dual-write"): - response = self.client.post( - self.url, - { - "projectIds": [project.id], - "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, - "projectRepoMappings": { - str(project.id): [repo_data], - }, + response = self.client.post( + self.url, + { + "projectIds": [project.id], + "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, + "projectRepoMappings": { + str(project.id): [repo_data], }, - ) + }, + ) assert response.status_code == 204 - seer_repo = SeerProjectRepository.objects.get(project=project) - assert seer_repo.repository_id == repo.id - assert project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + mock_bulk_write_db.assert_called_once() + preferences = mock_bulk_write_db.call_args[0][1] + assert len(preferences) == 1 + assert preferences[0].project_id == project.id + assert preferences[0].repositories[0].owner == "test-org" + assert preferences[0].repositories[0].name == "test-repo" @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index d021ff9ba818..50dd05a3b580 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -4,8 +4,8 @@ from sentry.models.repository import Repository from sentry.seer.models import PreferenceResponse, SeerProjectPreference, SeerRepoDefinition -from sentry.seer.models.project_repository import SeerProjectRepository from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature class ProjectSeerPreferencesEndpointTest(APITestCase): @@ -704,9 +704,13 @@ def test_post_rejects_unsupported_repo_provider(self, mock_request: MagicMock) - assert response.status_code == 400 mock_request.assert_not_called() + @with_feature("organizations:seer-project-settings-dual-write") + @patch("sentry.seer.endpoints.project_seer_preferences.write_preference_to_sentry_db") @patch("sentry.seer.endpoints.project_seer_preferences.make_set_project_preference_request") - def test_post_creates_seer_project_repository(self, mock_request: MagicMock) -> None: - """Test that POST writes to SeerProjectRepository when feature flag is enabled.""" + def test_post_writes_to_sentry_db( + self, mock_request: MagicMock, mock_write_db: MagicMock + ) -> None: + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" mock_response = Mock() mock_response.status = 200 mock_request.return_value = mock_response @@ -727,11 +731,30 @@ def test_post_creates_seer_project_repository(self, mock_request: MagicMock) -> "automated_run_stopping_point": "open_pr", } - with self.feature("organizations:seer-project-settings-dual-write"): - response = self.client.post(self.url, data=request_data) + response = self.client.post(self.url, data=request_data) assert response.status_code == 204 - seer_repo = SeerProjectRepository.objects.get(project=self.project) - assert seer_repo.repository_id == self.repository.id - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + mock_write_db.assert_called_once() + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.endpoints.project_seer_preferences.read_preference_from_sentry_db") + def test_get_reads_from_sentry_db(self, mock_read_db: MagicMock) -> None: + """When feature flag enabled, reads from Sentry DB instead of Seer API.""" + preference = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", owner="getsentry", name="sentry", external_id="123" + ) + ], + automated_run_stopping_point="open_pr", + ) + mock_read_db.return_value = preference + + response = self.client.get(self.url) + + mock_read_db.assert_called_once() + assert response.status_code == 200 + assert response.data["preference"] == preference.dict() diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index 201d437ecf4d..56a8d34f7248 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -8,14 +8,20 @@ from sentry.models.repository import Repository from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource from sentry.seer.autofix.utils import AutofixState, get_seer_seat_based_tier_cache_key -from sentry.seer.models import SeerApiError, SummarizeIssueResponse, SummarizeIssueScores -from sentry.seer.models.project_repository import SeerProjectRepository +from sentry.seer.models import ( + SeerApiError, + SeerProjectPreference, + SeerRepoDefinition, + SummarizeIssueResponse, + SummarizeIssueScores, +) from sentry.tasks.seer.autofix import ( check_autofix_status, configure_seer_for_existing_org, generate_issue_summary_only, ) from sentry.testutils.cases import TestCase as SentryTestCase +from sentry.testutils.helpers.features import with_feature from sentry.utils.cache import cache @@ -466,14 +472,16 @@ def test_preserves_existing_repositories_when_preferences_exist( preferences = mock_bulk_set.call_args[0][1] assert preferences[0]["repositories"] == existing_repos + @with_feature("organizations:seer-project-settings-dual-write") + @patch("sentry.tasks.seer.autofix.bulk_write_preferences_to_sentry_db") @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") - def test_creates_seer_project_repository( - self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + def test_writes_to_sentry_db( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock, mock_bulk_write_db: MagicMock ) -> None: - """Test that SeerProjectRepository is created when feature flag is enabled.""" + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" project = self.create_project(organization=self.organization) - repo = Repository.objects.create( + Repository.objects.create( organization_id=self.organization.id, name="test-org/test-repo", provider="github", @@ -494,8 +502,48 @@ def test_creates_seer_project_repository( } } - with self.feature("organizations:seer-project-settings-dual-write"): - configure_seer_for_existing_org(organization_id=self.organization.id) + configure_seer_for_existing_org(organization_id=self.organization.id) - seer_repo = SeerProjectRepository.objects.get(project=project) - assert seer_repo.repository_id == repo.id + mock_bulk_write_db.assert_called_once() + preferences = mock_bulk_write_db.call_args[0][1] + assert len(preferences) == 1 + assert preferences[0].project_id == project.id + assert preferences[0].repositories[0].owner == "test-org" + assert preferences[0].repositories[0].name == "test-repo" + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_read_preferences_from_sentry_db") + def test_reads_from_sentry_db( + self, mock_bulk_read_db: MagicMock, mock_bulk_set: MagicMock + ) -> None: + project = self.create_project(organization=self.organization) + + mock_bulk_read_db.return_value = { + project.id: SeerProjectPreference( + organization_id=self.organization.id, + project_id=project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="test-org", + name="test-repo", + external_id="ext123", + ) + ], + automated_run_stopping_point=None, + ) + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_read_db.assert_called_once() + assert mock_bulk_set.called + preferences = mock_bulk_set.call_args[0][1] + assert len(preferences) == 1 + repos = preferences[0]["repositories"] + assert len(repos) == 1 + assert repos[0]["provider"] == "github" + assert repos[0]["owner"] == "test-org" + assert repos[0]["name"] == "test-repo" + assert repos[0]["external_id"] == "ext123" diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index f4ed9795f566..7b920e8d11fb 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -3,6 +3,7 @@ import pytest from sentry.seer.explorer.context_engine_utils import ProjectEventCounts +from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition from sentry.tasks.seer.context_engine_index import ( get_allowed_org_ids_context_engine_indexing, index_org_project_knowledge, @@ -469,6 +470,51 @@ def test_skips_projects_without_seer_preferences( assert repos[0]["name"] == "sentry" assert repos[0]["project_ids"] == [self.project1.id] + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_read_preferences_from_sentry_db") + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_reads_from_sentry_db( + self, + mock_make_org_repo_knowledge_index_request, + mock_bulk_get_preferences, + mock_bulk_read_db, + ) -> None: + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_make_org_repo_knowledge_index_request.return_value.status = 200 + mock_bulk_read_db.return_value = { + self.project1.id: SeerProjectPreference( + organization_id=self.org.id, + project_id=self.project1.id, + repositories=[ + SeerRepoDefinition( + provider="integrations:github", + owner="getsentry", + name="sentry", + external_id="123", + integration_id=str(self.integration.id), + ) + ], + ), + } + + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature( + { + "organizations:context-engine-experiments": True, + "organizations:seer-project-settings-read-from-sentry": True, + } + ): + index_repos(self.org.id) + + mock_bulk_get_preferences.assert_not_called() + mock_bulk_read_db.assert_called_once() + mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_make_org_repo_knowledge_index_request.call_args[0][0] + repos = body["repos"] + assert len(repos) == 1 + assert repos[0]["name"] == "sentry" + assert repos[0]["owner"] == "getsentry" + @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase):