Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ def get_project_seer_preferences(project_id: int):
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 in Seer.
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

project_preferences = get_project_seer_preferences(project_id)
has_repos = bool(project_preferences.code_mapping_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
8 changes: 8 additions & 0 deletions src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,14 @@ 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
# Import here to avoid circular import: utils.py imports from code_mapping.py
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: API failure after cache lock causes missed automation

The has_project_connected_repos check is placed after cache.add(automation_dispatch_cache_key, ...) on line 1688, which atomically blocks duplicate dispatches. If the Seer API call in has_project_connected_repos raises a SeerApiError (as the test test_raises_on_api_error confirms it can), the exception propagates but the cache key remains set for 5 minutes. This blocks any subsequent automation dispatch attempts for that group, even though no automation was actually dispatched. The repo check should happen before the cache lock to avoid this scenario.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The risk is acceptable IMO. Keeping outside the lock means a lot more volume to the API endpoint which we want to avoid.
If it fails because of an API error automation will be re-tried after 5 minutes when new events come.


# 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
82 changes: 82 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,84 @@ 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_preferences = Mock()
mock_preferences.code_mapping_repos = [
{"provider": "github", "owner": "test", "name": "repo"}
]
mock_get_preferences.return_value = mock_preferences

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.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_returns_false_when_no_repos(self, mock_get_preferences, mock_cache):
"""Test returns False when project has no connected repositories."""
mock_cache.get.return_value = None
mock_preferences = Mock()
mock_preferences.code_mapping_repos = []
mock_get_preferences.return_value = mock_preferences

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.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.cache")
@patch("sentry.seer.autofix.utils.get_project_seer_preferences")
def test_raises_on_api_error(self, mock_get_preferences, mock_cache):
"""Test raises SeerApiError when API call fails."""
mock_cache.get.return_value = None
mock_get_preferences.side_effect = SeerApiError("API Error", 500)

with pytest.raises(SeerApiError):
has_project_connected_repos(self.organization.id, self.project.id)
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