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
59 changes: 55 additions & 4 deletions src/sentry/seer/endpoints/group_ai_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import uuid
from collections.abc import Collection
from typing import Any
from typing import Annotated, Any, Final, Literal, NotRequired, TypedDict

from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import serializers, status
Expand All @@ -24,6 +24,7 @@
RESPONSE_NOT_FOUND,
RESPONSE_UNAUTHORIZED,
)
from sentry.apidocs.descriptions import ApiDescription
from sentry.apidocs.examples.autofix_examples import AutofixExamples
from sentry.apidocs.parameters import GlobalParams, IssueParams
from sentry.apidocs.response_types import (
Expand Down Expand Up @@ -92,6 +93,7 @@
SEER_OUT_OF_CREDITS_CODE,
SEER_OUT_OF_QUOTA_CODE,
SEER_PERMISSION_DENIED,
SeerPaymentRequiredCode,
get_seer_run,
resolve_seer_run,
)
Expand All @@ -109,9 +111,13 @@

# Marks the one 409 from this endpoint that a caller can recover from on its own:
# the run named in the body is alive, so polling it is the whole remedy.
RUN_IN_FLIGHT_CODE = "run_in_flight"
RunInFlightCode = Literal["run_in_flight"]
RUN_IN_FLIGHT_CODE: Final[RunInFlightCode] = "run_in_flight"

AUTOFIX_SETUP_REQUIRED_DETAIL = {
# The API schema documents these codes as an enum, so a new code has to be added here.
AutofixSetupCode = Literal["scm_integration_required", "repos_not_linked"]

AUTOFIX_SETUP_REQUIRED_DETAIL: dict[AutofixSetupCode, str] = {
"scm_integration_required": (
"Seer Autofix requires a supported SCM integration (GitHub or GitLab) "
"to be installed for your organization before a new run can be started."
Expand All @@ -123,6 +129,43 @@
}


class AutofixConflictResponse(TypedDict):
detail: Annotated[str, ApiDescription("Why the step can't run")]
code: NotRequired[
Annotated[
Literal[AutofixSetupCode, RunInFlightCode],
ApiDescription(
"Set when a client can act on the conflict. `scm_integration_required` and "
"`repos_not_linked` mean the organization or project must finish Seer setup "
"before a new run can start. `run_in_flight` means a step is already running "
"for the run in `run_id`, so poll it instead of retrying"
),
]
]
run_id: NotRequired[
Annotated[int, ApiDescription("ID of the run in Seer. Set with `run_in_flight`")]
]
sentry_run_id: NotRequired[
Annotated[
str | None,
ApiDescription(
"ID of the run in Sentry. Set with `run_in_flight`, null for legacy runs"
),
]
]


class AutofixPaymentRequiredResponse(TypedDict):
detail: Annotated[str, ApiDescription("Why the organization can't run Seer")]
code: Annotated[
SeerPaymentRequiredCode,
ApiDescription(
"`seer_out_of_credits` when the organization has no Seer credits left, "
"`seer_out_of_quota` when it has no Seer budget"
),
]


class GroupAutofixPermission(GroupAiPermission):
# `seer:write` covers the analysis steps only; see _require_publish_scope.
scope_map = {
Expand Down Expand Up @@ -200,7 +243,7 @@ def _record_user_context_length(request_data: Any) -> None:
)


def _check_autofix_setup(organization: Organization, project: Project) -> str | None:
def _check_autofix_setup(organization: Organization, project: Project) -> AutofixSetupCode | None:
"""Return the setup code blocking a new autofix run, or None if it can start.

Mirrors the frontend gate in AutofixContent: legacy usage-based Seer plans
Expand Down Expand Up @@ -330,8 +373,14 @@ class GroupAutofixEndpoint(ConditionalGetResponseMixin, FormattableResponseMixin
202: inline_sentry_response_serializer("AutofixPostResponse", AutofixPostResponse),
400: RESPONSE_BAD_REQUEST,
401: RESPONSE_UNAUTHORIZED,
402: inline_sentry_response_serializer(
"AutofixPaymentRequiredResponse", AutofixPaymentRequiredResponse
),
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
409: inline_sentry_response_serializer(
"AutofixConflictResponse", AutofixConflictResponse
),
},
examples=AutofixExamples.AUTOFIX_POST_RESPONSE,
)
Expand All @@ -342,6 +391,8 @@ def post(
| Response[AutofixHandoffResponse]
| Response[None]
| Response[DetailResponse]
| Response[AutofixConflictResponse]
| Response[AutofixPaymentRequiredResponse]
| Response[ValidationErrorResponse]
| Response[str]
):
Expand Down
8 changes: 5 additions & 3 deletions src/sentry/seer/endpoints/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import uuid as uuid_module
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, TypeVar

from rest_framework import status
from rest_framework.response import Response
Expand All @@ -18,8 +18,10 @@
SEER_PERMISSION_DENIED = "You are not authorized to perform this action"

# Let API clients like the CLI and MCP tell billing 402s apart without parsing detail text.
SEER_OUT_OF_CREDITS_CODE = "seer_out_of_credits"
SEER_OUT_OF_QUOTA_CODE = "seer_out_of_quota"
# The API schema documents these codes as an enum, so a new code has to be added here.
SeerPaymentRequiredCode = Literal["seer_out_of_credits", "seer_out_of_quota"]
SEER_OUT_OF_CREDITS_CODE: Final[SeerPaymentRequiredCode] = "seer_out_of_credits"
SEER_OUT_OF_QUOTA_CODE: Final[SeerPaymentRequiredCode] = "seer_out_of_quota"

SEER_AUTOFIX_START_OUT_OF_CREDITS_DETAIL = (
"Your organization does not have enough credits to start an Autofix run."
Expand Down
Loading