Skip to content
Merged
59 changes: 47 additions & 12 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
from sentry.net.http import connection_from_url
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus
from sentry.seer.models import (
PreferenceResponse,
SeerApiError,
SeerApiResponseValidationError,
SeerPermissionError,
SeerRawPreferenceResponse,
SeerRepoDefinition,
)
from sentry.seer.signed_seer_api import make_signed_seer_api_request, sign_with_seer_secret
Expand Down Expand Up @@ -142,15 +142,15 @@ class CodingAgentStateUpdateRequest(BaseModel):
)


def get_project_seer_preferences(project_id: int):
def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse:
"""
Fetch Seer project preferences from the Seer API.

Args:
project_id: The project ID to fetch preferences for

Returns:
PreferenceResponse object if successful, None otherwise
SeerRawPreferenceResponse object if successful
"""
path = "/v1/project-preference"
body = orjson.dumps({"project_id": project_id})
Expand All @@ -166,13 +166,56 @@ def get_project_seer_preferences(project_id: int):
if response.status == 200:
try:
result = orjson.loads(response.data)
return PreferenceResponse.validate(result)
return SeerRawPreferenceResponse.validate(result)
except (pydantic.ValidationError, orjson.JSONDecodeError, UnicodeDecodeError) as e:
raise SeerApiResponseValidationError(str(e)) from e

raise SeerApiError(response.data.decode("utf-8"), response.status)


def has_project_connected_repos(organization_id: int, project_id: int) -> 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 60 minutes to minimize API calls.
"""
cache_key = f"seer-project-has-repos:{organization_id}:{project_id}"
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 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

logger.info(
"Checking if project has repositories connected",
extra={
"org_id": organization_id,
"project_id": project_id,
"has_repos": has_repos,
},
)

cache.set(cache_key, has_repos, timeout=60 * 60) # Cache for 1 hour
return has_repos


def bulk_get_project_preferences(organization_id: int, project_ids: list[int]) -> dict[str, dict]:
"""Bulk fetch Seer project preferences. Returns dict mapping project ID (string) to preference dict."""
path = "/v1/project-preference/bulk"
Expand Down Expand Up @@ -384,14 +427,6 @@ def is_seer_seat_based_tier_enabled(organization: Organization) -> bool:

has_seat_based_seer = features.has("organizations:seat-based-seer-enabled", organization)
cache.set(cache_key, has_seat_based_seer, timeout=60 * 60 * 4) # 4 hours TTL
logger.info(
"Checking if seat-based Seer tier is enabled",
extra={
"org_id": organization.id,
"org_slug": organization.slug,
"has_seat_based_seer": has_seat_based_seer,
},
)

return has_seat_based_seer

Expand Down
8 changes: 8 additions & 0 deletions src/sentry/seer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,15 @@ class SeerProjectPreference(BaseModel):
automation_handoff: SeerAutomationHandoffConfiguration | None = None


class SeerRawPreferenceResponse(BaseModel):
"""Response model for Seer's /v1/project-preference endpoint."""

preference: SeerProjectPreference | None


class PreferenceResponse(BaseModel):
"""Response model used by ProjectSeerPreferencesEndpoint which adds code_mapping_repos."""

preference: SeerProjectPreference | None
code_mapping_repos: list[SeerRepoDefinition]

Expand Down
7 changes: 7 additions & 0 deletions src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,13 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
if not cache.add(automation_dispatch_cache_key, True, timeout=300):
return # Another process already dispatched automation

# Check if project has connected repositories - requirement for new pricing
# which triggers Django model loading before apps are ready
from sentry.seer.autofix.utils import has_project_connected_repos

if not has_project_connected_repos(group.organization.id, group.project.id):
return

# Check if summary exists in cache
cache_key = get_issue_summary_cache_key(group.id)
if cache.get(cache_key) is not None:
Expand Down
143 changes: 143 additions & 0 deletions tests/sentry/seer/autofix/test_autofix_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
CodingAgentStatus,
get_autofix_prompt,
get_coding_agent_prompt,
has_project_connected_repos,
is_issue_eligible_for_seer_automation,
is_seer_seat_based_tier_enabled,
)
Expand Down Expand Up @@ -403,3 +404,145 @@ def test_returns_cached_value(self):
# Even without feature flags enabled, should return cached True
result = is_seer_seat_based_tier_enabled(self.organization)
assert result is True


class TestHasProjectConnectedRepos(TestCase):
"""Test the has_project_connected_repos function."""

def setUp(self):
super().setUp()
self.organization = self.create_organization()
self.project = self.create_project(organization=self.organization)

@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache):
"""Test returns True when project has connected repositories."""
mock_cache.get.return_value = None
mock_preference = Mock()
mock_preference.repositories = [{"provider": "github", "owner": "test", "name": "repo"}]
mock_response = Mock()
mock_response.preference = mock_preference
mock_get_preferences.return_value = mock_response

result = has_project_connected_repos(self.organization.id, self.project.id)

assert result is True
mock_cache.set.assert_called_once_with(
f"seer-project-has-repos:{self.organization.id}:{self.project.id}",
True,
timeout=60 * 60,
)

@patch("sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings")
@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_returns_false_when_no_repos(
self, mock_get_preferences, mock_cache, mock_get_code_mappings
):
"""Test returns False when project has no connected repositories."""
mock_cache.get.return_value = None
mock_preference = Mock()
mock_preference.repositories = []
mock_response = Mock()
mock_response.preference = mock_preference
mock_get_preferences.return_value = mock_response
mock_get_code_mappings.return_value = []

result = has_project_connected_repos(self.organization.id, self.project.id)

assert result is False
mock_cache.set.assert_called_once_with(
f"seer-project-has-repos:{self.organization.id}:{self.project.id}",
False,
timeout=60 * 60,
)

@patch("sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings")
@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_returns_false_when_preference_is_none_and_no_code_mappings(
self, mock_get_preferences, mock_cache, mock_get_code_mappings
):
"""Test returns False when preference is None and no code mappings exist."""
mock_cache.get.return_value = None
mock_response = Mock()
mock_response.preference = None
mock_get_preferences.return_value = mock_response
mock_get_code_mappings.return_value = []

result = has_project_connected_repos(self.organization.id, self.project.id)

assert result is False
mock_cache.set.assert_called_once_with(
f"seer-project-has-repos:{self.organization.id}:{self.project.id}",
False,
timeout=60 * 60,
)

@patch("sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings")
@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_falls_back_to_code_mappings_when_no_seer_preference(
self, mock_get_preferences, mock_cache, mock_get_code_mappings
):
"""Test falls back to code mappings when Seer has no preference."""
mock_cache.get.return_value = None
mock_response = Mock()
mock_response.preference = None
mock_get_preferences.return_value = mock_response
mock_get_code_mappings.return_value = [
{"provider": "github", "owner": "test", "name": "repo"}
]

result = has_project_connected_repos(self.organization.id, self.project.id)

assert result is True
mock_get_code_mappings.assert_called_once()
mock_cache.set.assert_called_once_with(
f"seer-project-has-repos:{self.organization.id}:{self.project.id}",
True,
timeout=60 * 60,
)

@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
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)

assert result is True
mock_get_preferences.assert_not_called()
mock_cache.set.assert_not_called()

@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
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)

assert result is False
mock_get_preferences.assert_not_called()
mock_cache.set.assert_not_called()

@patch("sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings")
@patch("sentry.seer.autofix.utils.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_falls_back_to_code_mappings_on_api_error(
self, mock_get_preferences, mock_cache, mock_get_code_mappings
):
"""Test falls back to code mappings when Seer API fails."""
mock_cache.get.return_value = None
mock_get_preferences.side_effect = SeerApiError("API Error", 500)
mock_get_code_mappings.return_value = [
{"provider": "github", "owner": "test", "name": "repo"}
]

result = has_project_connected_repos(self.organization.id, self.project.id)

assert result is True
mock_get_code_mappings.assert_called_once()
64 changes: 62 additions & 2 deletions tests/sentry/tasks/test_post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -3108,12 +3108,16 @@ def test_triage_signals_event_count_less_than_10_with_cache(
"sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner",
return_value=True,
)
@patch(
"sentry.seer.autofix.utils.has_project_connected_repos",
return_value=True,
)
@patch("sentry.tasks.autofix.run_automation_only_task.delay")
@with_feature(
{"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True}
)
def test_triage_signals_event_count_gte_10_with_cache(
self, mock_run_automation, mock_get_seer_org_acknowledgement
self, mock_run_automation, mock_has_repos, mock_get_seer_org_acknowledgement
):
"""Test that with event count >= 10 and cached summary exists, we run automation directly."""
self.project.update_option("sentry:seer_scanner_automation", True)
Expand Down Expand Up @@ -3157,12 +3161,19 @@ def mock_buffer_get(model, columns, filters):
"sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner",
return_value=True,
)
@patch(
"sentry.seer.autofix.utils.has_project_connected_repos",
return_value=True,
)
@patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay")
@with_feature(
{"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True}
)
def test_triage_signals_event_count_gte_10_no_cache(
self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement
self,
mock_generate_summary_and_run_automation,
mock_has_repos,
mock_get_seer_org_acknowledgement,
):
"""Test that with event count >= 10 and no cached summary, we generate summary + run automation."""
self.project.update_option("sentry:seer_scanner_automation", True)
Expand Down Expand Up @@ -3196,6 +3207,55 @@ def mock_buffer_get(model, columns, filters):
# Should call generate_summary_and_run_automation to generate summary + run automation
mock_generate_summary_and_run_automation.assert_called_once_with(group.id)

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner",
return_value=True,
)
@patch(
"sentry.seer.autofix.utils.has_project_connected_repos",
return_value=False,
)
@patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay")
@with_feature(
{"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True}
)
def test_triage_signals_event_count_gte_10_skips_without_connected_repos(
self,
mock_generate_summary_and_run_automation,
mock_has_repos,
mock_get_seer_org_acknowledgement,
):
"""Test that with event count >= 10 but no connected repos, we skip automation."""
self.project.update_option("sentry:seer_scanner_automation", True)
self.project.update_option("sentry:autofix_automation_tuning", "always")
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Update group times_seen to simulate >= 10 events
group = event.group
group.times_seen = 1
group.save()
event.group.times_seen = 1

# Mock buffer backend to return pending increments
from sentry import buffer

def mock_buffer_get(model, columns, filters):
return {"times_seen": 9}

with patch.object(buffer.backend, "get", side_effect=mock_buffer_get):
self.call_post_process_group(
is_new=False,
is_regression=False,
is_new_group_environment=False,
event=event,
)

# Should not call automation since no connected repos
mock_generate_summary_and_run_automation.assert_not_called()

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner",
return_value=True,
Expand Down
Loading