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
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def post(
request=request,
actor_id=actor_id,
dry_run=validator.validated_data["dry_run"],
expected_action=validator.validated_data.get("action"),
)
except ApplyFailed as failed:
return Response(failed.error, status=_STATUS.get(failed.error["code"], 409))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ class ActionRequestApplyValidator(StrictCamelSnakeValidator):
default=False,
help_text="Run every check without applying, to see whether the apply would work.",
)
action = serializers.JSONField(
required=False,
help_text="The action the user saw. If the request's action is different, the apply fails "
"with `action_changed`.",
)
1 change: 1 addition & 0 deletions src/sentry/investigations/services/action_kinds/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ApplyErrorCode(StrEnum):
WITHDRAWN = "withdrawn"
STALE = "stale"
VALIDATION_FAILED = "validation_failed"
ACTION_CHANGED = "action_changed"


class ApplyError(TypedDict):
Expand Down
14 changes: 13 additions & 1 deletion src/sentry/investigations/services/action_requests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import logging
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Any

from django.db import router, transaction
from django.db.models import Q, QuerySet
Expand Down Expand Up @@ -287,6 +288,7 @@ def _apply(
request: Request,
actor_id: int,
dry_run: bool,
expected_action: Mapping[str, Any] | None,
) -> InvestigationActionRequest:
investigations = Investigation.objects.select_related("organization")
runs = InvestigationOrchestrationRun.objects.all()
Expand All @@ -300,6 +302,14 @@ def _apply(
run = runs.get(investigation=locked)
action_request = action_requests.get(id=action_request.id)
kind = _check_can_apply(locked, action_request, request)
# Seer can rewrite a suggestion under the same id, so only apply the one the user saw.
if expected_action is not None and expected_action != action_request.action_payload:
raise ApplyFailed(
{
"detail": "Seer changed this suggestion. Check the new one.",
"code": ApplyErrorCode.ACTION_CHANGED,
}
)

context = ApplyContext(
investigation=locked,
Expand Down Expand Up @@ -354,6 +364,7 @@ def apply_action_request(
request: Request,
actor_id: int,
dry_run: bool = False,
expected_action: Mapping[str, Any] | None = None,
) -> InvestigationActionRequest:
"""Apply a suggested action as the requesting user, or raise ApplyFailed. A dry run only
says whether it would work, and doesn't take locks or change anything.
Expand All @@ -375,6 +386,7 @@ def apply_action_request(
request=request,
actor_id=actor_id,
dry_run=dry_run,
expected_action=expected_action,
)
except ApplyFailed as failed:
error = failed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {RequestError} from 'sentry/utils/requestError/requestError';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
import {commentStoryAuthors} from 'sentry/views/investigations/__stories__/commentStoryFixtures';
import type {ActionRequestError} from 'sentry/views/investigations/api';
import {
InvestigationBlockExecutionFixture,
InvestigationCommentFixture,
Expand All @@ -23,7 +24,9 @@ import {
InvestigationQueryOutputFixture,
InvestigationTranscriptBlockFixture,
} from 'sentry/views/investigations/fixtures';
import {getSourceMonitor} from 'sentry/views/investigations/source';
import type {
InvestigationActionRequest,
InvestigationComment,
InvestigationDetail,
InvestigationExecutionDetail,
Expand All @@ -44,6 +47,8 @@ type InvestigationFixtureApiProps = {
/** Page-level and block comments, keyed by investigation id. */
comments?: Record<string, InvestigationComment[]>;
details?: InvestigationDetail[];
/** Dry-run error responses, keyed by action request id. */
dryRunErrors?: Record<string, FixtureErrorResponse>;
executions?: Record<string, InvestigationExecutionDetail>;
featureEnabled?: boolean;
list?: InvestigationListItem[];
Expand All @@ -65,6 +70,11 @@ type FixtureResponse = {
headers?: Record<string, string>;
};

type FixtureErrorResponse = {
body: ActionRequestError & {detail: string};
status: number;
};

type FixtureHandlerResult = Promise<FixtureResponse> | typeof NO_MATCH;
type FixtureHandler = (
path: string,
Expand All @@ -87,6 +97,7 @@ export function InvestigationFixtureApi({
commentPageLinks = {},
comments = {},
details = [],
dryRunErrors = {},
executions = {},
featureEnabled = true,
list = [],
Expand Down Expand Up @@ -117,6 +128,7 @@ export function InvestigationFixtureApi({
commentPageLinks,
comments,
details,
dryRunErrors,
executions,
featureEnabled,
list,
Expand Down Expand Up @@ -239,16 +251,11 @@ function createFixtureHandler(config: FixtureApiConfig): FixtureHandler {
return new Promise(() => {});
}
if (config.mode === 'error') {
const method = normalizeMethod(options.method);
const response: ResponseMeta = {
status: 500,
statusText: 'Internal Server Error',
responseJSON: {detail: 'Fixture API error'},
responseText: 'Fixture API error',
getResponseHeader: () => null,
};
return Promise.reject(
new RequestError(method, path, new Error('Fixture API error'), response)
fixtureRequestError(normalizeMethod(options.method), path, {
status: 500,
body: {detail: 'Fixture API error'},
})
);
}

Expand All @@ -262,6 +269,7 @@ type FixtureState = {
commentPageLinks: Record<string, string>;
comments: Map<string, InvestigationComment[]>;
details: Map<string, InvestigationDetail>;
dryRunErrors: Record<string, FixtureErrorResponse>;
executions: Map<string, InvestigationExecutionDetail>;
list: InvestigationListItem[];
orchestration: Map<string, InvestigationOrchestration>;
Expand Down Expand Up @@ -300,6 +308,7 @@ function createFixtureState(config: FixtureApiConfig): FixtureState {
])
),
details,
dryRunErrors: {...config.dryRunErrors},
list,
orchestration: new Map(
Object.entries(config.orchestration ?? {}).map(([key, run]) => [
Expand Down Expand Up @@ -455,6 +464,54 @@ function handleFixtureRequest(
};
}

if (
parts[1] === 'action-requests' &&
parts[3] === 'apply' &&
parts.length === 4 &&
method === 'POST'
) {
const detail = getFixtureDetail(state, investigationId);
const actionRequest = detail.actionRequests?.find(
candidate => candidate.id === parts[2]
);
if (!actionRequest?.action) {
throw new Error(`Unknown fixture action request: ${parts[2]}`);
}
if (data.dryRun === true) {
const dryRunError = state.dryRunErrors[actionRequest.id];
if (dryRunError) {
throw fixtureRequestError(method, path, dryRunError);
}
return {body: {actionRequest: cloneFixture(actionRequest)}};
}
const {action} = actionRequest;
const applied: InvestigationActionRequest = {
...actionRequest,
state: 'applied',
appliedBy: state.viewer,
appliedDetails:
action.kind === 'update_detector'
? {
detectorId: action.detectorId,
detectorName:
getSourceMonitor(detail.source, action.detectorId)?.name ??
action.detectorId,
fields: Object.keys(action.changes),
}
: null,
canApply: false,
cannotApplyReason: 'not_suggested',
dateApplied: new Date().toISOString(),
};
setFixtureDetail(state, {
...detail,
actionRequests: detail.actionRequests?.map(candidate =>
candidate.id === applied.id ? applied : candidate
),
});
return {body: {actionRequest: cloneFixture(applied)}};
}

if (parts[1] === 'title-generation' && method === 'GET') {
const detail = getFixtureDetail(state, investigationId);
return {
Expand Down Expand Up @@ -802,6 +859,21 @@ function setFixtureDetail(state: FixtureState, detail: InvestigationDetail) {
state.list[listIndex] = {...state.list[listIndex], ...detail};
}

function fixtureRequestError(
method: ReturnType<typeof normalizeMethod>,
path: string,
{body, status}: {body: {detail: string}; status: number}
) {
const response: ResponseMeta = {
status,
statusText: 'Fixture API error',
responseJSON: body,
responseText: JSON.stringify(body),
getResponseHeader: () => null,
};
return new RequestError(method, path, new Error('Fixture API error'), response);
}

function normalizeMethod(method: RequestOptions['method']) {
return method ?? 'GET';
}
Expand Down
52 changes: 52 additions & 0 deletions static/app/views/investigations/actionRequests/actionKinds.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type {ComponentType, ReactNode} from 'react';

import type {ApplyCheck} from 'sentry/views/investigations/actionRequests/applyButton';
import {updateDetectorUI} from 'sentry/views/investigations/actionRequests/updateDetector';
import type {ActionRequestError} from 'sentry/views/investigations/api';
import type {
InvestigationAction,
InvestigationActionRequest,
InvestigationDetail,
} from 'sentry/views/investigations/types';

type ActionByKind = {[A in InvestigationAction as A['kind']]: A};
type ActionKind = keyof ActionByKind;

export type ApplyBlockReason =
| InvestigationActionRequest['cannotApplyReason']
| ActionRequestError['code'];

type KindProps<A> = {action: A; actionRequest: InvestigationActionRequest};

export type ActionKindApplyProps<A> = KindProps<A> & {
applying: boolean;
check: ApplyCheck;
message: string | null;
onApply: () => void;
};

export type ActionKindDetailsProps<A> = KindProps<A> & {
source: InvestigationDetail['source'] | undefined;
};

export type ActionKindUI<A extends InvestigationAction> = {
Apply: ComponentType<ActionKindApplyProps<A>>;
checksBeforeApply: boolean;
getAppliedSummary: (
props: KindProps<A> & {source: InvestigationDetail['source'] | undefined}
) => ReactNode;
getCannotApplyMessage: (reason: ApplyBlockReason) => string | null;
Details?: ComponentType<ActionKindDetailsProps<A>>;
getSuccessMessage?: () => string;
};

const ACTION_KIND_UI: {[K in ActionKind]?: ActionKindUI<ActionByKind[K]>} = {
update_detector: updateDetectorUI,
};

export function getActionKind<K extends ActionKind>(
action: ActionByKind[K] | null
): {action: ActionByKind[K]; ui: ActionKindUI<ActionByKind[K]>} | null {
const ui = action ? ACTION_KIND_UI[action.kind as K] : undefined;
return action && ui ? {action, ui} : null;
}
Loading
Loading