Skip to content

Commit 0dd1f62

Browse files
feat(seer setting): Task to change Seer settings for projects for new and existing orgs (#104290)
## PR Details + This is task that will be called from the billing code in `getsentry` by @brendanhsentry to set project level preferences for each org that signs up for the new pricing. This will be called when **an existing org or new org ** switches over to the new pricing. + The task needs org id and run on all projects in that org. + It sets "sentry:seer_scanner_automation" to True (even if it was false) and "sentry:autofix_automation_tuning" to "medium" (even if it was false since medium is the new default) . + It changes the "Where Seer should stop" setting in Seer via the API if it's not already code changes or open PR.
1 parent 6561cf8 commit 0dd1f62

File tree

3 files changed

+210
-3
lines changed

3 files changed

+210
-3
lines changed

src/sentry/seer/autofix/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,44 @@ def get_project_seer_preferences(project_id: int):
172172
raise SeerApiError(response.data.decode("utf-8"), response.status)
173173

174174

175+
def bulk_get_project_preferences(organization_id: int, project_ids: list[int]) -> dict[str, dict]:
176+
"""Bulk fetch Seer project preferences. Returns dict mapping project ID (string) to preference dict."""
177+
path = "/v1/project-preference/bulk"
178+
body = orjson.dumps({"organization_id": organization_id, "project_ids": project_ids})
179+
180+
response = make_signed_seer_api_request(
181+
autofix_connection_pool,
182+
path,
183+
body=body,
184+
timeout=10,
185+
)
186+
187+
if response.status >= 400:
188+
raise SeerApiError(response.data.decode("utf-8"), response.status)
189+
190+
result = orjson.loads(response.data)
191+
return result.get("preferences", {})
192+
193+
194+
def bulk_set_project_preferences(organization_id: int, preferences: list[dict]) -> None:
195+
"""Bulk set Seer project preferences for multiple projects."""
196+
if not preferences:
197+
return
198+
199+
path = "/v1/project-preference/bulk-set"
200+
body = orjson.dumps({"organization_id": organization_id, "preferences": preferences})
201+
202+
response = make_signed_seer_api_request(
203+
autofix_connection_pool,
204+
path,
205+
body=body,
206+
timeout=15,
207+
)
208+
209+
if response.status >= 400:
210+
raise SeerApiError(response.data.decode("utf-8"), response.status)
211+
212+
175213
def get_autofix_repos_from_project_code_mappings(project: Project) -> list[dict]:
176214
if settings.SEER_AUTOFIX_FORCE_USE_REPOS:
177215
# This is for testing purposes only, for example in s4s we want to force the use of specific repo(s)

src/sentry/tasks/autofix.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
from datetime import datetime, timedelta
33

44
from sentry.models.group import Group
5+
from sentry.models.organization import Organization
6+
from sentry.models.project import Project
57
from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource
6-
from sentry.seer.autofix.utils import get_autofix_state
8+
from sentry.seer.autofix.utils import (
9+
bulk_get_project_preferences,
10+
bulk_set_project_preferences,
11+
get_autofix_state,
12+
)
713
from sentry.tasks.base import instrumented_task
814
from sentry.taskworker.namespaces import ingest_errors_tasks, issues_tasks
915
from sentry.taskworker.retry import Retry
@@ -95,3 +101,60 @@ def run_automation_only_task(group_id: int) -> None:
95101
run_automation(
96102
group=group, user=AnonymousUser(), event=event, source=SeerAutomationSource.POST_PROCESS
97103
)
104+
105+
106+
@instrumented_task(
107+
name="sentry.tasks.autofix.configure_seer_for_existing_org",
108+
namespace=issues_tasks,
109+
processing_deadline_duration=90,
110+
retry=Retry(times=3),
111+
)
112+
def configure_seer_for_existing_org(organization_id: int) -> None:
113+
"""
114+
Configure Seer settings for a new or existing organization migrating to new Seer pricing.
115+
116+
Sets:
117+
- Org-level: enable_seer_coding=True - to override old check
118+
- Project-level (all projects): seer_scanner_automation=True, autofix_automation_tuning="medium" or "off"
119+
- Seer API (all projects): automated_run_stopping_point="code_changes" or "open_pr"
120+
"""
121+
organization = Organization.objects.get(id=organization_id)
122+
123+
# Set org-level options
124+
organization.update_option("sentry:enable_seer_coding", True)
125+
126+
projects = list(Project.objects.filter(organization_id=organization_id, status=0))
127+
project_ids = [p.id for p in projects]
128+
129+
if len(project_ids) == 0:
130+
return
131+
132+
# If seer is enabled for an org, every project must have project level settings
133+
for project in projects:
134+
project.update_option("sentry:seer_scanner_automation", True)
135+
# New automation default for the new pricing is medium.
136+
project.update_option("sentry:autofix_automation_tuning", "medium")
137+
138+
preferences_by_id = bulk_get_project_preferences(organization_id, project_ids)
139+
140+
# Determine which projects need updates
141+
preferences_to_set = []
142+
for project_id in project_ids:
143+
existing_pref = preferences_by_id.get(str(project_id), {})
144+
145+
# Skip projects that already have an acceptable stopping point configured
146+
if existing_pref.get("automated_run_stopping_point") in ("open_pr", "code_changes"):
147+
continue
148+
149+
# Preserve existing repositories and automation_handoff, only update the stopping point
150+
preferences_to_set.append(
151+
{
152+
"organization_id": organization_id,
153+
"project_id": project_id,
154+
"repositories": existing_pref.get("repositories") or [],
155+
"automated_run_stopping_point": "code_changes",
156+
"automation_handoff": existing_pref.get("automation_handoff"),
157+
}
158+
)
159+
if len(preferences_to_set) > 0:
160+
bulk_set_project_preferences(organization_id, preferences_to_set)

tests/sentry/tasks/test_autofix.py

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
from datetime import datetime, timedelta
22
from unittest.mock import MagicMock, patch
33

4+
import pytest
45
from django.test import TestCase
56

67
from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource
78
from sentry.seer.autofix.utils import AutofixState
8-
from sentry.seer.models import SummarizeIssueResponse, SummarizeIssueScores
9-
from sentry.tasks.autofix import check_autofix_status, generate_issue_summary_only
9+
from sentry.seer.models import SeerApiError, SummarizeIssueResponse, SummarizeIssueScores
10+
from sentry.tasks.autofix import (
11+
check_autofix_status,
12+
configure_seer_for_existing_org,
13+
generate_issue_summary_only,
14+
)
1015
from sentry.testutils.cases import TestCase as SentryTestCase
1116

1217

@@ -127,3 +132,104 @@ def test_generates_fixability_score(
127132

128133
group.refresh_from_db()
129134
assert group.seer_fixability_score == 0.75
135+
136+
137+
class TestConfigureSeerForExistingOrg(SentryTestCase):
138+
@patch("sentry.tasks.autofix.bulk_set_project_preferences")
139+
@patch("sentry.tasks.autofix.bulk_get_project_preferences")
140+
def test_configures_org_and_project_settings(
141+
self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock
142+
) -> None:
143+
"""Test that org and project settings are configured correctly."""
144+
project1 = self.create_project(organization=self.organization)
145+
project2 = self.create_project(organization=self.organization)
146+
# Set to non-off value so we can verify it gets changed to medium
147+
project1.update_option("sentry:autofix_automation_tuning", "low")
148+
project2.update_option("sentry:autofix_automation_tuning", "high")
149+
150+
mock_bulk_get.return_value = {}
151+
152+
configure_seer_for_existing_org(organization_id=self.organization.id)
153+
154+
# Check org-level option
155+
assert self.organization.get_option("sentry:enable_seer_coding") is True
156+
157+
# Check project-level options
158+
assert project1.get_option("sentry:seer_scanner_automation") is True
159+
assert project1.get_option("sentry:autofix_automation_tuning") == "medium"
160+
assert project2.get_option("sentry:seer_scanner_automation") is True
161+
assert project2.get_option("sentry:autofix_automation_tuning") == "medium"
162+
163+
mock_bulk_get.assert_called_once()
164+
mock_bulk_set.assert_called_once()
165+
166+
@patch("sentry.tasks.autofix.bulk_set_project_preferences")
167+
@patch("sentry.tasks.autofix.bulk_get_project_preferences")
168+
def test_overrides_autofix_off_to_medium(
169+
self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock
170+
) -> None:
171+
"""Test that projects with autofix set to off are migrated to medium."""
172+
project = self.create_project(organization=self.organization)
173+
project.update_option("sentry:autofix_automation_tuning", "off")
174+
175+
mock_bulk_get.return_value = {}
176+
177+
configure_seer_for_existing_org(organization_id=self.organization.id)
178+
179+
# autofix_automation_tuning should be migrated to medium for new pricing
180+
assert project.get_option("sentry:autofix_automation_tuning") == "medium"
181+
# Scanner should be enabled
182+
assert project.get_option("sentry:seer_scanner_automation") is True
183+
184+
@patch("sentry.tasks.autofix.bulk_set_project_preferences")
185+
@patch("sentry.tasks.autofix.bulk_get_project_preferences")
186+
def test_skips_projects_with_existing_stopping_point(
187+
self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock
188+
) -> None:
189+
"""Test that projects with open_pr or code_changes stopping point are skipped."""
190+
project1 = self.create_project(organization=self.organization)
191+
project2 = self.create_project(organization=self.organization)
192+
193+
mock_bulk_get.return_value = {
194+
str(project1.id): {"automated_run_stopping_point": "open_pr"},
195+
str(project2.id): {"automated_run_stopping_point": "code_changes"},
196+
}
197+
198+
configure_seer_for_existing_org(organization_id=self.organization.id)
199+
200+
# bulk_set should not be called since both projects are skipped
201+
mock_bulk_set.assert_not_called()
202+
203+
@patch("sentry.tasks.autofix.bulk_get_project_preferences")
204+
def test_raises_on_bulk_get_api_failure(self, mock_bulk_get: MagicMock) -> None:
205+
"""Test that task raises on bulk GET API failure to trigger retry."""
206+
project1 = self.create_project(organization=self.organization)
207+
project2 = self.create_project(organization=self.organization)
208+
209+
mock_bulk_get.side_effect = SeerApiError("API error", 500)
210+
211+
with pytest.raises(SeerApiError):
212+
configure_seer_for_existing_org(organization_id=self.organization.id)
213+
214+
# Sentry DB options should still be set before the API call
215+
assert project1.get_option("sentry:seer_scanner_automation") is True
216+
assert project2.get_option("sentry:seer_scanner_automation") is True
217+
218+
@patch("sentry.tasks.autofix.bulk_set_project_preferences")
219+
@patch("sentry.tasks.autofix.bulk_get_project_preferences")
220+
def test_raises_on_bulk_set_api_failure(
221+
self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock
222+
) -> None:
223+
"""Test that task raises on bulk SET API failure to trigger retry."""
224+
project1 = self.create_project(organization=self.organization)
225+
project2 = self.create_project(organization=self.organization)
226+
227+
mock_bulk_get.return_value = {}
228+
mock_bulk_set.side_effect = SeerApiError("API error", 500)
229+
230+
with pytest.raises(SeerApiError):
231+
configure_seer_for_existing_org(organization_id=self.organization.id)
232+
233+
# Sentry DB options should still be set before the API call
234+
assert project1.get_option("sentry:seer_scanner_automation") is True
235+
assert project2.get_option("sentry:seer_scanner_automation") is True

0 commit comments

Comments
 (0)