diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 2d7d5a502221..58f69081b32d 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -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 @@ -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 ( @@ -92,6 +93,7 @@ SEER_OUT_OF_CREDITS_CODE, SEER_OUT_OF_QUOTA_CODE, SEER_PERMISSION_DENIED, + SeerPaymentRequiredCode, get_seer_run, resolve_seer_run, ) @@ -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." @@ -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 = { @@ -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 @@ -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, ) @@ -342,6 +391,8 @@ def post( | Response[AutofixHandoffResponse] | Response[None] | Response[DetailResponse] + | Response[AutofixConflictResponse] + | Response[AutofixPaymentRequiredResponse] | Response[ValidationErrorResponse] | Response[str] ): diff --git a/src/sentry/seer/endpoints/utils.py b/src/sentry/seer/endpoints/utils.py index 3414c3bd2aba..e306540c8e97 100644 --- a/src/sentry/seer/endpoints/utils.py +++ b/src/sentry/seer/endpoints/utils.py @@ -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 @@ -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."