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
1 change: 1 addition & 0 deletions src/sentry/projectoptions/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
"sentry:seer_automation_handoff_target",
"sentry:seer_automation_handoff_integration_id",
"sentry:seer_automation_handoff_auto_create_pr",
"sentry:autofix_automation_tuning",
]

# Boolean to enable/disable preprod size analysis for this project.
Expand Down
39 changes: 39 additions & 0 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference |
repositories=repo_definitions,
automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"),
automation_handoff=_build_automation_handoff(project.get_option),
autofix_automation_tuning=project.get_option("sentry:autofix_automation_tuning"),
)


Expand Down Expand Up @@ -813,11 +814,49 @@ def _get_project_option(key: str) -> Any:
"sentry:seer_automated_run_stopping_point"
),
automation_handoff=_build_automation_handoff(_get_project_option),
autofix_automation_tuning=_get_project_option("sentry:autofix_automation_tuning"),
)

return result


def bulk_read_preferences(
organization: Organization, project_ids: list[int]
) -> dict[int, SeerProjectPreference | None]:
"""Read Seer project preferences in bulk, using the correct source based on feature flag.

Always returns ``dict[int, SeerProjectPreference | None]`` regardless of the
underlying read path (Sentry DB or Seer API)."""
if features.has("organizations:seer-project-settings-read-from-sentry", organization):
return bulk_read_preferences_from_sentry_db(organization.id, project_ids)

raw = bulk_get_project_preferences(organization.id, project_ids)
tuning_by_id = ProjectOption.objects.get_value_bulk_id(
project_ids, "sentry:autofix_automation_tuning"
)
result: dict[int, SeerProjectPreference | None] = {}
for pid, data in raw.items():
int_pid = int(pid)
if data is None:
result[int_pid] = None
continue
try:
pref = SeerProjectPreference.validate(data)
except pydantic.ValidationError:
logger.exception(
"seer.bulk_read_preferences.validation_error",
extra={"project_id": pid, "organization_id": organization.id},
)
result[int_pid] = None
continue
tuning = tuning_by_id.get(int_pid)
if tuning is None:
tuning = projectoptions.get_well_known_default("sentry:autofix_automation_tuning")
pref.autofix_automation_tuning = tuning
result[int_pid] = pref
return result


def set_project_seer_preference(preference: SeerProjectPreference) -> None:
"""Set Seer project preference for a single project via Seer API."""
response = make_set_project_preference_request(
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/seer/models/seer_api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from pydantic import BaseModel, Field

from sentry.seer.autofix.constants import AutofixAutomationTuningSettings


class BranchOverride(BaseModel):
tag_name: str = Field(description="The tag key to match against")
Expand Down Expand Up @@ -96,6 +98,7 @@ class SeerProjectPreference(BaseModel):
repositories: list[SeerRepoDefinition]
automated_run_stopping_point: str | None = None
automation_handoff: SeerAutomationHandoffConfiguration | None = None
autofix_automation_tuning: AutofixAutomationTuningSettings = AutofixAutomationTuningSettings.OFF


class SeerRawPreferenceResponse(BaseModel):
Expand Down
44 changes: 27 additions & 17 deletions src/sentry/tasks/seer/night_shift/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.project import Project
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
from sentry.seer.autofix.utils import bulk_read_preferences
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue
from sentry.seer.models.project_repository import SeerProjectRepository
from sentry.tasks.base import instrumented_task
from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy
from sentry.taskworker.namespaces import seer_tasks
Expand Down Expand Up @@ -93,13 +93,22 @@ def run_night_shift_for_org(organization_id: int) -> None:

start_time = time.monotonic()

eligible_projects = _get_eligible_projects(organization)
if not eligible_projects:
logger.info(
"night_shift.no_eligible_projects",
try:
eligible_projects = _get_eligible_projects(organization)
if not eligible_projects:
logger.info(
"night_shift.no_eligible_projects",
extra={
"organization_id": organization_id,
"organization_slug": organization.slug,
},
)
return
except Exception:
logger.exception(
"night_shift.failed_to_get_eligible_projects",
extra={
"organization_id": organization_id,
"organization_slug": organization.slug,
},
)
return
Expand Down Expand Up @@ -186,18 +195,19 @@ def _get_eligible_orgs_from_batch(

def _get_eligible_projects(organization: Organization) -> list[Project]:
"""Return active projects that have automation enabled and connected repos."""
projects_with_repos = set(
SeerProjectRepository.objects.filter(
project__organization=organization,
project__status=ObjectStatus.ACTIVE,
).values_list("project_id", flat=True)
)
if not projects_with_repos:
project_map = {
p.id: p
for p in Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)
}
if not project_map:
return []

projects = Project.objects.filter(id__in=projects_with_repos)
preferences = bulk_read_preferences(organization, list(project_map))

return [
p
for p in projects
if p.get_option("sentry:autofix_automation_tuning") != AutofixAutomationTuningSettings.OFF
project_map[pid]
for pid, pref in preferences.items()
if pref is not None
and pref.repositories
and pref.autofix_automation_tuning != AutofixAutomationTuningSettings.OFF
]
62 changes: 61 additions & 1 deletion tests/sentry/seer/autofix/test_autofix_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory
from sentry.seer.autofix.constants import AutofixStatus
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus
from sentry.seer.autofix.trigger import is_issue_eligible_for_seer_automation
from sentry.seer.autofix.utils import (
AutofixState,
Expand Down Expand Up @@ -1311,6 +1311,37 @@ def test_project_with_repos_only(self):
assert result.automated_run_stopping_point == "code_changes"
assert result.automation_handoff is None

def test_autofix_automation_tuning_default(self):
SeerProjectRepository.objects.create(
project=self.project, repository=self.repo, branch_name="main"
)

result = read_preference_from_sentry_db(self.project)
assert result is not None
assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF

def test_autofix_automation_tuning_explicit(self):
SeerProjectRepository.objects.create(
project=self.project, repository=self.repo, branch_name="main"
)
self.project.update_option(
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
)

result = read_preference_from_sentry_db(self.project)
assert result is not None
assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.MEDIUM

def test_autofix_automation_tuning_alone_creates_preference(self):
self.project.update_option(
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH
)

result = read_preference_from_sentry_db(self.project)
assert result is not None
assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH
assert result.repositories == []

def test_project_with_stopping_point_only(self):
self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr")

Expand Down Expand Up @@ -1479,6 +1510,35 @@ def test_bulk_returns_correct_preferences(self):
assert pref2.automation_handoff.integration_id == 99
assert pref2.automation_handoff.auto_create_pr is False

def test_autofix_automation_tuning_populated(self):
SeerProjectRepository.objects.create(
project=self.project1, repository=self.repo, branch_name="main"
)
self.project1.update_option(
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH
)

result = bulk_read_preferences_from_sentry_db(
self.organization.id, [self.project1.id, self.project2.id]
)

pref1 = result[self.project1.id]
assert pref1 is not None
assert pref1.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH

assert result[self.project2.id] is None

def test_autofix_automation_tuning_defaults_to_off(self):
SeerProjectRepository.objects.create(
project=self.project1, repository=self.repo, branch_name="main"
)

result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id])

pref = result[self.project1.id]
assert pref is not None
assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF

def test_wrong_organization_excluded(self):
other_org = self.create_organization()
SeerProjectRepository.objects.create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def test_post_creates_project_preferences(
"repositories": [],
"automated_run_stopping_point": AutofixStoppingPoint.OPEN_PR.value,
"automation_handoff": None,
"autofix_automation_tuning": AutofixAutomationTuningSettings.OFF,
}
]

Expand Down Expand Up @@ -293,6 +294,7 @@ def test_post_updates_each_preference_field_independently(
"repositories": [],
"automated_run_stopping_point": AutofixStoppingPoint.OPEN_PR.value,
"automation_handoff": None,
"autofix_automation_tuning": AutofixAutomationTuningSettings.OFF,
}
]

Expand Down
34 changes: 23 additions & 11 deletions tests/sentry/tasks/seer/test_night_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,20 @@ def test_filters_by_automation_and_repos(self) -> None:
eligible.update_option(
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
)
repo = self.create_repo(project=eligible, provider="github")
repo = self.create_repo(project=eligible, provider="github", name="owner/eligible-repo")
SeerProjectRepository.objects.create(project=eligible, repository=repo)

# Automation off (even with repo)
off = self.create_project(organization=org)
off.update_option("sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF)
repo2 = self.create_repo(project=off, provider="github")
repo2 = self.create_repo(project=off, provider="github", name="owner/off-repo")
SeerProjectRepository.objects.create(project=off, repository=repo2)

# No connected repo
self.create_project(organization=org)

assert _get_eligible_projects(org) == [eligible]
with self.feature("organizations:seer-project-settings-read-from-sentry"):
assert _get_eligible_projects(org) == [eligible]


@django_db_all
Expand All @@ -115,7 +116,7 @@ def _make_eligible(self, project):
project.update_option(
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
)
repo = self.create_repo(project=project, provider="github")
repo = self.create_repo(project=project, provider="github", name=f"owner/{project.slug}")
SeerProjectRepository.objects.create(project=project, repository=repo)

def _store_event_and_update_group(self, project, fingerprint, **group_attrs):
Expand All @@ -139,7 +140,10 @@ def test_no_eligible_projects(self) -> None:
org = self.create_organization()
self.create_project(organization=org)

with patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger:
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger,
):
run_night_shift_for_org(org.id)
mock_logger.info.assert_called_once()
assert mock_logger.info.call_args.args[0] == "night_shift.no_eligible_projects"
Expand All @@ -166,6 +170,7 @@ def test_selects_candidates_and_skips_triggered(self) -> None:
)

with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request",
return_value=_mock_llm_response([high_fix.id, low_fix.id]),
Expand Down Expand Up @@ -204,6 +209,7 @@ def test_global_ranking_across_projects(self) -> None:
)

with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request",
return_value=_mock_llm_response([high_group.id, low_group.id]),
Expand All @@ -225,9 +231,12 @@ def test_triage_error_records_error_message(self) -> None:
project, "fixable", seer_fixability_score=0.9, times_seen=5
)

with patch(
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
side_effect=RuntimeError("boom"),
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
side_effect=RuntimeError("boom"),
),
):
run_night_shift_for_org(org.id)

Expand All @@ -244,9 +253,12 @@ def test_empty_candidates_creates_run_with_no_issues(self) -> None:
project, "fixable", seer_fixability_score=0.9, times_seen=5
)

with patch(
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
return_value=[],
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
return_value=[],
),
):
run_night_shift_for_org(org.id)

Expand Down
Loading