Skip to content
Merged
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
97 changes: 30 additions & 67 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Callable, Iterable, Mapping
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any, Literal, NotRequired, TypedDict
from typing import Any, NotRequired, TypedDict

import orjson
import sentry_sdk
Expand Down Expand Up @@ -728,16 +728,19 @@ def _get_project_option(key: str) -> Any:

class SeerProjectSettingsUpdate(TypedDict, total=False):
agent: AutomationCodingAgent
integrationId: int
stoppingPoint: AutofixStoppingPoint | Literal["off"]
scannerAutomation: bool
integration_id: int
stopping_point: str
automation_tuning: str
scanner_automation: bool
auto_create_pr: bool


def _get_seer_project_options_to_update(
data: SeerProjectSettingsUpdate,
) -> tuple[dict[str, Any], list[str]]:
"""Return (options_to_set, options_to_clear) for the given Seer project settings update.
Clear the option if it's the default; otherwise, set it."""
def update_seer_project_settings(project_ids: list[int], data: SeerProjectSettingsUpdate) -> None:
"""Apply Seer project settings to one or more projects.
For any ProjectOptions, delete the row if we're setting that field to its default."""
if not project_ids or not data:
return

options_to_set: dict[str, Any] = {}
options_to_clear: list[str] = []

Expand All @@ -756,84 +759,44 @@ def _set_or_clear(key: str, value: Any, default: Any) -> None:
"sentry:seer_automation_handoff_integration_id",
]
else:
integration_id = data.get("integrationId")
integration_id = data.get("integration_id")
if integration_id is None:
raise ValueError("integrationId is required for external coding agents")
options_to_set["sentry:seer_automation_handoff_point"] = AutofixHandoffPoint.ROOT_CAUSE
options_to_set["sentry:seer_automation_handoff_target"] = agent
options_to_set["sentry:seer_automation_handoff_integration_id"] = integration_id

if "scannerAutomation" in data:
_set_or_clear("sentry:seer_scanner_automation", data["scannerAutomation"], default=True)
if "scanner_automation" in data:
_set_or_clear("sentry:seer_scanner_automation", data["scanner_automation"], default=True)

if "stoppingPoint" not in data:
return options_to_set, options_to_clear
elif data["stoppingPoint"] == "off":
# Disable automation and leave stopping point and handoff_auto_create_pr unchanged
# so that reenabling restores the prior state.
_set_or_clear(
"sentry:autofix_automation_tuning",
AutofixAutomationTuningSettings.OFF,
default=AUTOFIX_AUTOMATION_TUNING_DEFAULT,
)
else:
# Enable automation and set the stopping point.
_set_or_clear(
"sentry:autofix_automation_tuning",
AutofixAutomationTuningSettings.MEDIUM,
default=AUTOFIX_AUTOMATION_TUNING_DEFAULT,
)
if "stopping_point" in data:
_set_or_clear(
"sentry:seer_automated_run_stopping_point",
data["stoppingPoint"],
data["stopping_point"],
default=SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
)

if data["stoppingPoint"] == AutofixStoppingPoint.OPEN_PR:
# Safe to set even if no external handoff is configured
# since we'll only read it if the other handoff options are all non-null.
options_to_set["sentry:seer_automation_handoff_auto_create_pr"] = True
else:
options_to_clear.append("sentry:seer_automation_handoff_auto_create_pr")
return options_to_set, options_to_clear


def update_seer_project_settings(project: Project, data: SeerProjectSettingsUpdate) -> None:
"""Apply high-level Seer settings to a single project."""
options_to_set, options_to_delete = _get_seer_project_options_to_update(data)

with transaction.atomic(using=router.db_for_write(ProjectOption)):
# Lock project rows to serialize concurrent writes.
Project.objects.select_for_update().filter(id=project.id).first()

for key in options_to_delete:
project.delete_option(key)
for key, value in options_to_set.items():
project.update_option(key, value)

if "auto_create_pr" in data:
_set_or_clear(
"sentry:seer_automation_handoff_auto_create_pr", data["auto_create_pr"], default=False
)

def bulk_update_seer_project_settings(
projects: list[Project], data: SeerProjectSettingsUpdate
) -> None:
"""Apply high-level Seer settings to multiple projects in bulk."""
if not projects:
return
if "automation_tuning" in data:
_set_or_clear(
"sentry:autofix_automation_tuning",
data["automation_tuning"],
default=AUTOFIX_AUTOMATION_TUNING_DEFAULT,
)

options_to_set, options_to_delete = _get_seer_project_options_to_update(data)
if not options_to_set and not options_to_delete:
if not options_to_set and not options_to_clear:
return

project_ids = [p.id for p in projects]

with transaction.atomic(using=router.db_for_write(ProjectOption)):
# Lock project rows to serialize concurrent writes.
list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id"))
Comment thread
srest2021 marked this conversation as resolved.

if options_to_delete:
if options_to_clear:
# Use _raw_delete to skip per-row post_delete signals that each trigger reload_cache.
# For efficiency, we reload once per project after the transaction instead.
ProjectOption.objects.filter(
project_id__in=project_ids, key__in=options_to_delete
project_id__in=project_ids, key__in=options_to_clear
)._raw_delete(using=router.db_for_write(ProjectOption))

if options_to_set:
Expand Down
73 changes: 37 additions & 36 deletions src/sentry/seer/endpoints/project_seer_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter
from sentry.api.event_search import parse_search_query as base_parse_search_query
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
from sentry.constants import (
AUTOFIX_AUTOMATION_TUNING_DEFAULT,
SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
Expand All @@ -36,7 +37,6 @@
from sentry.seer.autofix.utils import (
AutofixStoppingPoint,
AutomationCodingAgent,
bulk_update_seer_project_settings,
get_automation_handoff,
get_valid_automated_run_stopping_points,
update_seer_project_settings,
Expand Down Expand Up @@ -80,6 +80,8 @@ class SeerProjectSettingsResponse(TypedDict):
agent: str
integrationId: str | None
stoppingPoint: str
autoCreatePr: bool | None
automationTuning: str
scannerAutomation: bool
reposCount: int

Expand Down Expand Up @@ -154,16 +156,20 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe
# No configured external handoff means use Seer agent.
agent: str = "seer"
integration_id: str | None = None
auto_create_pr: bool | None = None
else:
agent = handoff.target
integration_id = str(handoff.integration_id)
auto_create_pr = handoff.auto_create_pr

return SeerProjectSettingsResponse(
projectId=str(project.id),
projectSlug=project.slug,
agent=agent,
integrationId=integration_id,
stoppingPoint=stopping_point,
autoCreatePr=auto_create_pr,
automationTuning=settings["automation_tuning"],
scannerAutomation=settings["scanner_automation"],
reposCount=settings["repos_count"],
)
Expand Down Expand Up @@ -307,22 +313,22 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]):
return queryset


class ProjectSettingsUpdateSerializer(serializers.Serializer):
class ProjectSettingsUpdateSerializer(CamelSnakeSerializer):
agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False)
integrationId = serializers.IntegerField(required=False)
stoppingPoint = serializers.ChoiceField(choices=["off", *AutofixStoppingPoint], required=False)
scannerAutomation = serializers.BooleanField(required=False)

def validate_stoppingPoint(self, value: str) -> str:
if value == "off":
return value
integration_id = serializers.IntegerField(required=False)
stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False)
Comment thread
srest2021 marked this conversation as resolved.
scanner_automation = serializers.BooleanField(required=False)
automation_tuning = serializers.ChoiceField(
choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM],
required=False,
)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's easier to keep stopping point and tuning as independent fields, so that we can support legacy seer which allows other tuning values besides "off" and "medium".


organization = self.context["organization"]
if value not in get_valid_automated_run_stopping_points(organization):
def validate_stopping_point(self, value: str) -> str:
if value not in get_valid_automated_run_stopping_points(self.context["organization"]):
raise serializers.ValidationError(f'"{value}" is not a valid choice.')
return value

def validate_integrationId(self, value: int) -> int:
def validate_integration_id(self, value: int) -> int:
organization = self.context["organization"]
org_integrations = integration_service.get_organization_integrations(
organization_id=organization.id, integration_id=value
Expand All @@ -332,25 +338,31 @@ def validate_integrationId(self, value: int) -> int:
return value

def validate(self, data):
if "agent" in data and data["agent"] != "seer" and "integrationId" not in data:
if "agent" in data and data["agent"] != "seer" and "integration_id" not in data:
raise serializers.ValidationError(
{"integrationId": "Required when agent is an external coding agent."}
{"integration_id": "Required when agent is an external coding agent."}
)

if "integrationId" in data:
if "integration_id" in data:
if "agent" not in data:
raise serializers.ValidationError(
{"agent": "Required when integrationId is provided."}
{"agent": "Required when integration_id is provided."}
)
elif data["agent"] == "seer":
raise serializers.ValidationError(
{"agent": "Must be an external coding agent when integrationId is provided."}
{"agent": "Must be an external coding agent when integration_id is provided."}
)

has_update = any(k in data for k in ("agent", "stoppingPoint", "scannerAutomation"))
if not has_update:
if not any(
k in data
for k in ("agent", "stopping_point", "scanner_automation", "automation_tuning")
):
raise serializers.ValidationError("At least one update field must be provided.")

# Keep stopping point in sync with handoff auto_create_pr.
if "stopping_point" in data and "auto_create_pr" not in data:
data["auto_create_pr"] = data["stopping_point"] == AutofixStoppingPoint.OPEN_PR
Comment thread
srest2021 marked this conversation as resolved.

Copy link
Copy Markdown
Member Author

@srest2021 srest2021 May 27, 2026

Choose a reason for hiding this comment

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

Here we keep auto_create_pr in sync with the stopping point before we pass the serializer data to update_seer_project_settings.

return data


Expand All @@ -373,20 +385,15 @@ def put(self, request: Request, project: Project) -> Response:
if not serializer.is_valid():
return Response(serializer.errors, status=400)

update_seer_project_settings(project, serializer.validated_data)
data = serializer.validated_data
update_seer_project_settings([project.id], data)

self.create_audit_entry(
request=request,
organization=project.organization,
target_object=project.id,
event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"),
data={
"project_id": project.id,
"agent": serializer.validated_data.get("agent"),
"integration_id": serializer.validated_data.get("integrationId"),
"stopping_point": serializer.validated_data.get("stoppingPoint"),
"scanner_automation": serializer.validated_data.get("scannerAutomation"),
},
data={"project_id": project.id, **data},
)

return Response(serialize_project(project))
Expand Down Expand Up @@ -455,21 +462,15 @@ def put(self, request: Request, organization: Organization) -> Response:
return Response({"detail": "Invalid search query"}, status=400)

projects = list(queryset)
bulk_update_seer_project_settings(projects, data)
if projects:
update_seer_project_settings([p.id for p in projects], data)

self.create_audit_entry(
request=request,
organization=organization,
target_object=organization.id,
event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"),
data={
"project_count": len(projects),
"project_ids": [p.id for p in projects],
"agent": data.get("agent"),
"integration_id": data.get("integrationId"),
"stopping_point": data.get("stoppingPoint"),
"scanner_automation": data.get("scannerAutomation"),
},
data={"project_count": len(projects), "project_ids": [p.id for p in projects], **data},
)

return Response(status=204)
Loading
Loading