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
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@
from sentry.integrations.api.endpoints.organization_repository_settings import (
OrganizationRepositorySettingsEndpoint,
)
from sentry.investigations.endpoints.organization_investigation_action_request_apply import (
OrganizationInvestigationActionRequestApplyEndpoint,
)
from sentry.investigations.endpoints.organization_investigation_block_details import (
OrganizationInvestigationBlockDetailsEndpoint,
)
Expand Down Expand Up @@ -2452,6 +2455,11 @@ def create_group_urls(
OrganizationInvestigationsDuplicateEndpoint.as_view(),
name="sentry-api-0-organization-investigation-duplicate",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/investigations/(?P<investigation_id>[^/]+)/action-requests/(?P<action_request_id>[^/]+)/apply/$",
OrganizationInvestigationActionRequestApplyEndpoint.as_view(),
name="sentry-api-0-organization-investigation-action-request-apply",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/investigations/(?P<investigation_id>[^/]+)/presence/$",
OrganizationInvestigationPresenceEndpoint.as_view(),
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/apidocs/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,13 @@ class InvestigationParams:
type=str,
description="The ID of an investigation in this organization.",
)
ACTION_REQUEST_ID = OpenApiParameter(
name="action_request_id",
location="path",
required=True,
type=str,
description="The ID of an action request in this investigation.",
)
CURSOR = OpenApiParameter(
name="cursor",
location="query",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.serializers import serialize
from sentry.apidocs import constants
from sentry.apidocs.parameters import GlobalParams, InvestigationParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.investigations.endpoints.base import (
OrganizationInvestigationEndpoint,
require_authenticated_user,
)
from sentry.investigations.endpoints.serializers import InvestigationActionRequestSerializer
from sentry.investigations.endpoints.serializers.responses import (
InvestigationActionRequestApplyResponse,
)
from sentry.investigations.endpoints.validators import ActionRequestApplyValidator
from sentry.investigations.models import Investigation
from sentry.investigations.services.action_kinds.base import (
ApplyErrorCode,
ApplyFailed,
CannotApplyReason,
)
from sentry.investigations.services.action_requests import apply_action_request
from sentry.models.organization import Organization

_STATUS = {
CannotApplyReason.INVESTIGATION_ARCHIVED: status.HTTP_400_BAD_REQUEST,
CannotApplyReason.UNSUPPORTED_ACTION: status.HTTP_400_BAD_REQUEST,
ApplyErrorCode.VALIDATION_FAILED: status.HTTP_400_BAD_REQUEST,
CannotApplyReason.NO_ACCESS: status.HTTP_403_FORBIDDEN,
CannotApplyReason.PERMISSION_DENIED: status.HTTP_403_FORBIDDEN,
}


@extend_schema(tags=["Investigations"])
@cell_silo_endpoint
class OrganizationInvestigationActionRequestApplyEndpoint(OrganizationInvestigationEndpoint):
owner = ApiOwner.ML_AI
publish_status = {"POST": ApiPublishStatus.PRIVATE}

@extend_schema(
operation_id="Apply an Investigation Action Request",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
InvestigationParams.INVESTIGATION_ID,
InvestigationParams.ACTION_REQUEST_ID,
],
request=ActionRequestApplyValidator,
responses={
200: inline_sentry_response_serializer(
"InvestigationActionRequestApplyResponse",
InvestigationActionRequestApplyResponse,
),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
403: constants.RESPONSE_FORBIDDEN,
404: constants.RESPONSE_NOT_FOUND,
409: constants.RESPONSE_CONFLICT,
},
)
def post(
self,
request: Request,
organization: Organization,
investigation: Investigation,
action_request_id: str,
) -> Response:
"""
Apply a suggested action request as the current user. With `dryRun`, it runs the same
checks and changes nothing.

Errors about the request have a `code`. It's a 409 when the request was applied,
withdrawn or replaced, or its kind refuses it, and a 403 when the viewer can't apply it.
"""
actor_id = require_authenticated_user(request)
validator = ActionRequestApplyValidator(data=request.data)
if not validator.is_valid():
return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST)
try:
action_request = apply_action_request(
investigation=investigation,
action_request_id=action_request_id,
request=request,
actor_id=actor_id,
dry_run=validator.validated_data["dry_run"],
)
except ApplyFailed as failed:
return Response(failed.error, status=_STATUS.get(failed.error["code"], 409))
body: InvestigationActionRequestApplyResponse = {
"actionRequest": serialize(
action_request,
request.user,
InvestigationActionRequestSerializer(access=request.access),
),
}
return Response(body)
25 changes: 12 additions & 13 deletions src/sentry/investigations/endpoints/serializers/action_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections import defaultdict
from collections.abc import Mapping, MutableMapping, Sequence
from datetime import datetime
from typing import Any, TypedDict, override
from typing import Any, TypedDict, cast, override

from django.contrib.auth.models import AnonymousUser

Expand All @@ -13,12 +13,13 @@
Investigation,
InvestigationActionRequest,
InvestigationActionRequestAnchorType,
InvestigationActionRequestState,
InvestigationBlock,
InvestigationStatus,
)
from sentry.investigations.services.action_kinds import get_action_kind
from sentry.investigations.services.action_kinds.base import CannotApplyReason
from sentry.investigations.services.action_kinds import (
get_action_kind,
shared_cannot_apply_reason,
)
from sentry.investigations.services.action_kinds.base import ActionRequestError, CannotApplyReason
from sentry.users.api.serializers.user import UserSerializerResponse
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
Expand Down Expand Up @@ -61,7 +62,7 @@ class InvestigationActionRequestSerializerResponse(TypedDict):
appliedBy: UserSerializerResponse | None
appliedDetails: dict[str, Any] | None
dateApplied: datetime | None
error: dict[str, Any] | None
error: ActionRequestError | None
dateCreated: datetime


Expand Down Expand Up @@ -99,12 +100,9 @@ def _cannot_apply_reasons(
reasons: dict[int, CannotApplyReason | None] = {}
by_kind: dict[str, list[InvestigationActionRequest]] = defaultdict(list)
for item in item_list:
if investigations[item.investigation_id].status == InvestigationStatus.ARCHIVED:
reasons[item.id] = CannotApplyReason.INVESTIGATION_ARCHIVED
elif item.state != InvestigationActionRequestState.SUGGESTED:
reasons[item.id] = CannotApplyReason.NOT_SUGGESTED
elif get_action_kind(item.action_kind) is None:
reasons[item.id] = CannotApplyReason.UNSUPPORTED_ACTION
reason = shared_cannot_apply_reason(investigations[item.investigation_id], item)
if reason is not None:
reasons[item.id] = reason
elif access is None:
reasons[item.id] = CannotApplyReason.NO_ACCESS
else:
Expand Down Expand Up @@ -203,6 +201,7 @@ def serialize(
"appliedBy": attrs["applied_by"],
"appliedDetails": obj.applied_details,
"dateApplied": obj.date_applied,
"error": obj.error,
# Only the shared apply path writes this column.
"error": cast(ActionRequestError | None, obj.error),
"dateCreated": obj.date_added,
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from datetime import datetime
from typing import Any, NotRequired, TypedDict

from sentry.investigations.endpoints.serializers.action_request import (
InvestigationActionRequestSerializerResponse,
)
from sentry.investigations.endpoints.serializers.investigation import (
InvestigationOrchestrationSerializerResponse,
)
Expand Down Expand Up @@ -258,3 +261,7 @@ class InvestigationPresenceResponse(TypedDict):
class InvestigationTitlePreviewResponse(TypedDict):
status: str | None
preview: str | None


class InvestigationActionRequestApplyResponse(TypedDict):
actionRequest: InvestigationActionRequestSerializerResponse
2 changes: 2 additions & 0 deletions src/sentry/investigations/endpoints/validators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
__all__ = (
"ActionRequestApplyValidator",
"BlockCreateValidator",
"BlockDeleteValidator",
"BlockExecutionResumeValidator",
Expand All @@ -20,6 +21,7 @@
)


from .action_request import ActionRequestApplyValidator
from .base import StrictCamelSnakeValidator
from .block import (
BlockCreateValidator,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from __future__ import annotations

from rest_framework import serializers

from sentry.investigations.endpoints.validators.base import StrictCamelSnakeValidator


class ActionRequestApplyValidator(StrictCamelSnakeValidator):
dry_run = serializers.BooleanField(
default=False,
help_text="Run every check without applying, to see whether the apply would work.",
)
21 changes: 20 additions & 1 deletion src/sentry/investigations/services/action_kinds/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from __future__ import annotations

from sentry.investigations.services.action_kinds.base import ActionKind
from sentry.investigations.models import (
Investigation,
InvestigationActionRequest,
InvestigationActionRequestState,
InvestigationStatus,
)
from sentry.investigations.services.action_kinds.base import ActionKind, CannotApplyReason
from sentry.investigations.services.action_kinds.connect_telemetry import ConnectTelemetryKind
from sentry.investigations.services.action_kinds.update_detector import UpdateDetectorKind

Expand All @@ -11,3 +17,16 @@

def get_action_kind(kind: str | None) -> ActionKind | None:
return ACTION_KINDS.get(kind) if kind is not None else None


def shared_cannot_apply_reason(
investigation: Investigation, action_request: InvestigationActionRequest
) -> CannotApplyReason | None:
"""Why no one can apply this request, whatever its kind, or None if its kind decides."""
if investigation.status == InvestigationStatus.ARCHIVED:
return CannotApplyReason.INVESTIGATION_ARCHIVED
if action_request.state != InvestigationActionRequestState.SUGGESTED:
return CannotApplyReason.NOT_SUGGESTED
if get_action_kind(action_request.action_kind) is None:
return CannotApplyReason.UNSUPPORTED_ACTION
return None
88 changes: 86 additions & 2 deletions src/sentry/investigations/services/action_kinds/base.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from __future__ import annotations

import abc
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from enum import StrEnum
from typing import NotRequired, TypedDict

from django.contrib.auth.models import AnonymousUser
from rest_framework.request import Request

from sentry.auth.access import Access
from sentry.investigations.contracts import ActionPayloadSerializer
from sentry.investigations.models import Investigation, InvestigationActionRequest
from sentry.investigations.models import (
Investigation,
InvestigationActionRequest,
InvestigationOrchestrationRun,
)
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser

Expand All @@ -26,6 +33,71 @@ class CannotApplyReason(StrEnum):
DYNAMIC_DETECTOR = "dynamic_detector"


# One setting an action reads or changes, as JSON.
SettingValue = int | float | str | None


class ApplyErrorCode(StrEnum):
"""What else the apply endpoint can say went wrong. The UI switches on these."""

ALREADY_APPLIED = "already_applied"
WITHDRAWN = "withdrawn"
STALE = "stale"
VALIDATION_FAILED = "validation_failed"


class ApplyError(TypedDict):
detail: str
code: CannotApplyReason | ApplyErrorCode
# Seer's reason for withdrawing it.
reason: NotRequired[str | None]
# The settings that moved since Seer suggested the change.
current: NotRequired[dict[str, SettingValue]]
# Why the change wouldn't save.
messages: NotRequired[list[str]]


class ActionRequestError(TypedDict):
"""What's stored on a request that went wrong, and shown with it."""

code: ApplyErrorCode
current: NotRequired[dict[str, SettingValue]]
messages: NotRequired[list[str]]


class ApplyFailed(Exception):
def __init__(self, error: ApplyError) -> None:
super().__init__(error["detail"])
self.error = error


@dataclass(frozen=True)
class ApplyContext:
"""When applying, the caller holds the investigation, run, and request row locks."""

investigation: Investigation
run: InvestigationOrchestrationRun
action_request: InvestigationActionRequest
request: Request
actor_id: int


class ActionStale(Exception):
"""What the action changes moved since Seer suggested it."""

def __init__(self, current: dict[str, SettingValue]) -> None:
super().__init__("The action is stale.")
self.current = current


class ActionInvalid(Exception):
"""The change wouldn't save, and why."""

def __init__(self, messages: list[str]) -> None:
super().__init__("The action is invalid.")
self.messages = messages


class ActionKind(abc.ABC):
"""Everything Sentry needs to check, apply, and record one kind of action."""

Expand All @@ -43,6 +115,18 @@ def cannot_apply_reasons(
"""For each suggested request, why the viewer can't apply it, or None if they can."""
return {item.id: CannotApplyReason.UNSUPPORTED_ACTION for item in action_requests}

def check(self, context: ApplyContext) -> None:
"""Run the checks apply would, without changing anything. Raise ActionStale or
ActionInvalid where apply would. Kinds with nothing to check beyond
cannot_apply_reasons can leave this as is.
"""

def apply(self, context: ApplyContext) -> Mapping[str, object]:
"""Make the change, and return what the activity feed should show about it. Raise
ActionStale or ActionInvalid instead of updating the request; the caller records it.
"""
raise NotImplementedError

def after_upsert(self, action_request: InvestigationActionRequest) -> None:
"""Runs when Seer creates or changes a request, in the same transaction. Only requests
that are still suggested once the whole event is handled get it.
Expand Down
Loading
Loading