diff --git a/src/sentry/apidocs/build.py b/src/sentry/apidocs/build.py index 1ecc425ff0d7..08d1bc8962b9 100644 --- a/src/sentry/apidocs/build.py +++ b/src/sentry/apidocs/build.py @@ -31,6 +31,11 @@ def get_old_json_components(filename: str) -> Any: "description": "Private endpoints for investigation notebooks and agent workflows. Requires the organizations:investigations feature and open organization membership. Notebook and workflow versions are independent optimistic concurrency tokens.", "x-display-description": True, }, + { + "name": "Notebooks", + "description": "Endpoints for notebooks, which people and Seer agents write. Requires the organizations:investigations feature and open organization membership. A notebook's version is an optimistic concurrency token.", + "x-display-description": True, + }, { "name": "Teams", "description": "Endpoints for teams", diff --git a/src/sentry/apidocs/examples/notebook_examples.py b/src/sentry/apidocs/examples/notebook_examples.py new file mode 100644 index 000000000000..c8c73e8e21d0 --- /dev/null +++ b/src/sentry/apidocs/examples/notebook_examples.py @@ -0,0 +1,85 @@ +from drf_spectacular.utils import OpenApiExample + +BLOCK: dict[str, object] = { + "id": "12", + "position": 0, + "kind": "text", + "title": "Summary", + "content": "Checkout latency rose after the 2.4.0 release.", + "generationPrompt": "", + "generatedContent": "Checkout latency rose after the 2.4.0 release.", + "output": { + "schemaVersion": 1, + "markdown": "Checkout latency rose after the 2.4.0 release.", + }, + "outputStatus": "available", + "currentExecution": { + "id": "40", + "status": "completed", + "executor": "code_mode", + "schemaVersion": 1, + "startedAt": "2026-11-10T18:00:00.000000Z", + "completedAt": "2026-11-10T18:00:00.000000Z", + "error": None, + }, + "config": {}, + "display": {"type": "markdown"}, + "dependencies": [], + "parameterKeys": [], + "version": 1, + "staleAt": None, + "createdBy": None, + "lastEditedBy": None, + "producerKey": "summary", + "producerRevision": 1, +} + +NOTEBOOK: dict[str, object] = { + "id": "7", + "title": "Checkout latency", + "summary": None, + "summaryDescription": None, + "status": "active", + "sourceType": "manual", + "createdBy": "1", + "creator": {"type": "user", "id": "1"}, + "dateCreated": "2026-11-10T17:55:00.000000Z", + "dateUpdated": "2026-11-10T18:00:00.000000Z", + "version": 2, + "blockCount": 1, + "isFavorited": False, + "titleGeneration": {"status": None}, + "orchestration": None, + "template": None, + "source": {"type": "manual", "ref": {}, "revision": None}, + "filters": {}, + "projectIds": [1], + "parameters": [], + "blocks": [BLOCK], + "actionRequests": [], +} + + +class NotebookExamples: + NOTEBOOK_DETAILS = [ + OpenApiExample("Notebook details", value=NOTEBOOK, status_codes=["200"], response_only=True) + ] + CREATE_NOTEBOOK = [ + OpenApiExample( + "Created notebook", + value={**NOTEBOOK, "version": 1, "blockCount": 0, "blocks": []}, + status_codes=["201"], + response_only=True, + ), + OpenApiExample( + "Existing notebook for the source", + value=NOTEBOOK, + status_codes=["200"], + response_only=True, + ), + ] + BLOCK_DETAILS = [OpenApiExample("Block", value=BLOCK, status_codes=["200"], response_only=True)] + CREATE_BLOCK = [ + OpenApiExample("Created block", value=BLOCK, status_codes=["201"], response_only=True), + OpenApiExample("Updated block", value=BLOCK, status_codes=["200"], response_only=True), + ] diff --git a/src/sentry/investigations/apidocs.py b/src/sentry/investigations/apidocs.py index 261115d22240..96de98647363 100644 --- a/src/sentry/investigations/apidocs.py +++ b/src/sentry/investigations/apidocs.py @@ -11,10 +11,12 @@ def investigation_endpoints(endpoints: Any) -> Any: + # Published operations are in the public schema. return [ - endpoint - for endpoint in endpoints - if issubclass(endpoint[3].view_class, OrganizationNotebooksBaseEndpoint) + (path, path_regex, method, callback) + for path, path_regex, method, callback in endpoints + if issubclass(callback.view_class, OrganizationNotebooksBaseEndpoint) + and not callback.view_class.publish_status[method].is_published ] diff --git a/src/sentry/investigations/services/action_kinds/connect_telemetry.py b/src/sentry/investigations/services/action_kinds/connect_telemetry.py index 6d200dadfc5b..461d83fe23f6 100644 --- a/src/sentry/investigations/services/action_kinds/connect_telemetry.py +++ b/src/sentry/investigations/services/action_kinds/connect_telemetry.py @@ -1,13 +1,14 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Literal, TypedDict, cast +from typing import Annotated, Literal, TypedDict, cast from django.db import router, transaction from django.db.models import QuerySet from rest_framework import serializers from sentry import features +from sentry.apidocs.descriptions import ApiDescription from sentry.auth.access import Access from sentry.auth.principal import RequestPrincipal from sentry.models.organization import Organization @@ -36,7 +37,9 @@ class ConnectTelemetryActionSerializer(ActionPayloadSerializer): class ConnectTelemetryAppliedDetails(TypedDict): - provider: TelemetryProvider + provider: Annotated[ + TelemetryProvider, ApiDescription("The telemetry provider that was connected") + ] def telemetry_available(organization: Organization, provider: TelemetryProvider) -> bool: diff --git a/src/sentry/investigations/services/action_kinds/update_detector.py b/src/sentry/investigations/services/action_kinds/update_detector.py index b9bc02dda621..dd490925429a 100644 --- a/src/sentry/investigations/services/action_kinds/update_detector.py +++ b/src/sentry/investigations/services/action_kinds/update_detector.py @@ -3,13 +3,14 @@ from collections import defaultdict from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass -from typing import Any, Literal, TypedDict, cast +from typing import Annotated, Any, Literal, TypedDict, cast from django.db import router, transaction from rest_framework import serializers from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize +from sentry.apidocs.descriptions import ApiDescription from sentry.auth.access import Access from sentry.auth.principal import RequestPrincipal from sentry.db.models.fields.bounded import I32_MAX, I64_MAX @@ -354,9 +355,9 @@ def _action_detector_id(action_request: NotebookActionRequest) -> int: class UpdateDetectorAppliedDetails(TypedDict): - detectorId: str - detectorName: str - fields: list[str] + detectorId: Annotated[str, ApiDescription("The ID of the monitor that was changed")] + detectorName: Annotated[str, ApiDescription("The name of the monitor that was changed")] + fields: Annotated[list[str], ApiDescription("The monitor settings that were changed")] def _error_messages(errors: Mapping[str, Any] | Sequence[Any] | str) -> list[str]: diff --git a/src/sentry/notebooks/endpoints/base.py b/src/sentry/notebooks/endpoints/base.py index 1194e58b9acb..4b89e7620a4a 100644 --- a/src/sentry/notebooks/endpoints/base.py +++ b/src/sentry/notebooks/endpoints/base.py @@ -12,6 +12,7 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.helpers.creator import get_creator_reference +from sentry.apidocs.response_types import DetailResponse, ValidationErrorResponse from sentry.auth.access import ServiceAccountAccess from sentry.auth.principal import acting_user_id from sentry.constants import ObjectStatus @@ -34,7 +35,9 @@ class NotebookArchivedError(APIException): default_detail = "Archived investigations are read-only." -def service_error(error: Exception) -> Response | None: +def service_error( + error: Exception, +) -> Response[ValidationErrorResponse] | Response[DetailResponse] | None: if isinstance(error, NotebookValidationError): return Response(error.errors, status=status.HTTP_400_BAD_REQUEST) if isinstance(error, NotebookConflictError): diff --git a/src/sentry/notebooks/endpoints/organization_notebook_block_details.py b/src/sentry/notebooks/endpoints/organization_notebook_block_details.py index d4b4e7e1c14b..9cf4621a3ab7 100644 --- a/src/sentry/notebooks/endpoints/organization_notebook_block_details.py +++ b/src/sentry/notebooks/endpoints/organization_notebook_block_details.py @@ -11,7 +11,13 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize from sentry.apidocs import constants +from sentry.apidocs.examples.notebook_examples import NotebookExamples from sentry.apidocs.parameters import GlobalParams, NotebookParams +from sentry.apidocs.response_types import ( + DetailResponse, + ValidationErrorResponse, + as_validation_errors, +) from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.notebooks.endpoints.base import ( @@ -32,10 +38,15 @@ @cell_silo_endpoint class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint): owner = ApiOwner.ML_AI - publish_status = {"PUT": ApiPublishStatus.PRIVATE, "DELETE": ApiPublishStatus.PRIVATE} + publish_status = { + "PUT": ApiPublishStatus.PUBLIC_EXPERIMENTAL, + "DELETE": ApiPublishStatus.PUBLIC_EXPERIMENTAL, + } + idempotent_methods = frozenset() @extend_schema( - operation_id="Update an Investigation Block", + operation_id="Update a Notebook Block", + tags=["Notebooks"], parameters=[ GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID, @@ -44,7 +55,7 @@ class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint request=BlockUpdateValidator, responses={ 200: inline_sentry_response_serializer( - "InvestigationBlockResponse", NotebookBlockSerializerResponse + "NotebookBlockResponse", NotebookBlockSerializerResponse ), 400: constants.RESPONSE_BAD_REQUEST, 401: constants.RESPONSE_UNAUTHORIZED, @@ -58,7 +69,8 @@ class OrganizationNotebookBlockDetailsEndpoint(OrganizationNotebookBlockEndpoint value={"notebookVersion": 2, "version": 1, "content": "Revised observations"}, request_only=True, ) - ], + ] + + NotebookExamples.BLOCK_DETAILS, ) def put( self, @@ -66,7 +78,11 @@ def put( organization: Organization, notebook: Notebook, block: NotebookBlock, - ) -> Response: + ) -> ( + Response[NotebookBlockSerializerResponse] + | Response[ValidationErrorResponse] + | Response[DetailResponse] + ): """ Update at least one of title, content, generationPrompt, config, or display. Send both notebook and block versions. The block kind cannot change. Returns the updated block. @@ -79,7 +95,7 @@ def put( validator = BlockUpdateValidator(data=request.data, context={"block": block}) if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST) values = dict(validator.validated_data) expected_notebook_version = values.pop("notebook_version") @@ -109,7 +125,8 @@ def put( ) @extend_schema( - operation_id="Delete an Investigation Block", + operation_id="Delete a Notebook Block", + tags=["Notebooks"], parameters=[ GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID, @@ -138,7 +155,7 @@ def delete( organization: Organization, notebook: Notebook, block: NotebookBlock, - ) -> Response: + ) -> Response[None] | Response[ValidationErrorResponse] | Response[DetailResponse]: """ Soft-delete an active block using notebook and block versions. Active executions and other state conflicts return 409. Returns no body. Requires a user or service account. @@ -150,7 +167,7 @@ def delete( validator = BlockDeleteValidator(data=request.data) if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST) try: delete_block( block=block, diff --git a/src/sentry/notebooks/endpoints/organization_notebook_block_index.py b/src/sentry/notebooks/endpoints/organization_notebook_block_index.py index 4ed517f4ec95..b5d0c783d33a 100644 --- a/src/sentry/notebooks/endpoints/organization_notebook_block_index.py +++ b/src/sentry/notebooks/endpoints/organization_notebook_block_index.py @@ -10,7 +10,13 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.serializers import serialize from sentry.apidocs import constants +from sentry.apidocs.examples.notebook_examples import NotebookExamples from sentry.apidocs.parameters import GlobalParams, NotebookParams +from sentry.apidocs.response_types import ( + DetailResponse, + ValidationErrorResponse, + as_validation_errors, +) from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.notebooks.endpoints.base import ( @@ -32,18 +38,20 @@ @cell_silo_endpoint class OrganizationNotebookBlockIndexEndpoint(OrganizationNotebookEndpoint): owner = ApiOwner.ML_AI - publish_status = {"POST": ApiPublishStatus.PRIVATE} + publish_status = {"POST": ApiPublishStatus.PUBLIC_EXPERIMENTAL} + idempotent_methods = frozenset() @extend_schema( - operation_id="Create an Investigation Block", + operation_id="Create a Notebook Block", + tags=["Notebooks"], parameters=[GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID], request=BlockCreateValidator, responses={ 200: inline_sentry_response_serializer( - "InvestigationBlockResponse", NotebookBlockSerializerResponse + "NotebookBlockResponse", NotebookBlockSerializerResponse ), 201: inline_sentry_response_serializer( - "InvestigationBlockResponse", NotebookBlockSerializerResponse + "NotebookBlockResponse", NotebookBlockSerializerResponse ), 400: constants.RESPONSE_BAD_REQUEST, 401: constants.RESPONSE_UNAUTHORIZED, @@ -71,9 +79,16 @@ class OrganizationNotebookBlockIndexEndpoint(OrganizationNotebookEndpoint): }, request_only=True, ), - ], + ] + + NotebookExamples.CREATE_BLOCK, ) - def post(self, request: Request, organization: Organization, notebook: Notebook) -> Response: + def post( + self, request: Request, organization: Organization, notebook: Notebook + ) -> ( + Response[NotebookBlockSerializerResponse] + | Response[ValidationErrorResponse] + | Response[DetailResponse] + ): """ Append a text or query block to an active investigation, using notebookVersion for concurrency. A producer sends key and revision instead to write its text block: the @@ -95,7 +110,7 @@ def post(self, request: Request, organization: Organization, notebook: Notebook) }, ) if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST) try: block, created = validator.save() except Exception as error: diff --git a/src/sentry/notebooks/endpoints/organization_notebook_block_order.py b/src/sentry/notebooks/endpoints/organization_notebook_block_order.py index 8e646387357e..f8330c075c49 100644 --- a/src/sentry/notebooks/endpoints/organization_notebook_block_order.py +++ b/src/sentry/notebooks/endpoints/organization_notebook_block_order.py @@ -10,7 +10,13 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.serializers import serialize from sentry.apidocs import constants +from sentry.apidocs.examples.notebook_examples import NotebookExamples from sentry.apidocs.parameters import GlobalParams, NotebookParams +from sentry.apidocs.response_types import ( + DetailResponse, + ValidationErrorResponse, + as_validation_errors, +) from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.notebooks.endpoints.base import ( @@ -31,15 +37,17 @@ @cell_silo_endpoint class OrganizationNotebookBlockOrderEndpoint(OrganizationNotebookEndpoint): owner = ApiOwner.ML_AI - publish_status = {"PUT": ApiPublishStatus.PRIVATE} + publish_status = {"PUT": ApiPublishStatus.PUBLIC_EXPERIMENTAL} + idempotent_methods = frozenset() @extend_schema( - operation_id="Reorder Investigation Blocks", + operation_id="Reorder Notebook Blocks", + tags=["Notebooks"], parameters=[GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID], request=BlockOrderValidator, responses={ 200: inline_sentry_response_serializer( - "InvestigationDetailsResponse", NotebookDetailsSerializerResponse + "NotebookDetailsResponse", NotebookDetailsSerializerResponse ), 400: constants.RESPONSE_BAD_REQUEST, 401: constants.RESPONSE_UNAUTHORIZED, @@ -53,9 +61,16 @@ class OrganizationNotebookBlockOrderEndpoint(OrganizationNotebookEndpoint): value={"notebookVersion": 3, "blockIds": [2, 1]}, request_only=True, ) - ], + ] + + NotebookExamples.NOTEBOOK_DETAILS, ) - def put(self, request: Request, organization: Organization, notebook: Notebook) -> Response: + def put( + self, request: Request, organization: Organization, notebook: Notebook + ) -> ( + Response[NotebookDetailsSerializerResponse] + | Response[ValidationErrorResponse] + | Response[DetailResponse] + ): """ Set the order of all active blocks. Supply every active block ID exactly once and the current notebook version. Returns the updated notebook with ordered blocks. Requires a @@ -65,7 +80,7 @@ def put(self, request: Request, organization: Organization, notebook: Notebook) require_notebook_writer(request, notebook) validator = BlockOrderValidator(data=request.data) if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST) try: updated = reorder_blocks( notebook=notebook, @@ -78,10 +93,9 @@ def put(self, request: Request, organization: Organization, notebook: Notebook) return response raise - return Response( - serialize( - updated, - request.user, - NotebookDetailsSerializer(access=request.access), - ) + body: NotebookDetailsSerializerResponse = serialize( + updated, + request.user, + NotebookDetailsSerializer(access=request.access), ) + return Response(body) diff --git a/src/sentry/notebooks/endpoints/organization_notebook_details.py b/src/sentry/notebooks/endpoints/organization_notebook_details.py index d42b0561f41d..ddf74b8772d4 100644 --- a/src/sentry/notebooks/endpoints/organization_notebook_details.py +++ b/src/sentry/notebooks/endpoints/organization_notebook_details.py @@ -10,6 +10,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.serializers import serialize from sentry.apidocs import constants +from sentry.apidocs.examples.notebook_examples import NotebookExamples from sentry.apidocs.parameters import GlobalParams, NotebookParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization @@ -38,39 +39,42 @@ class OrganizationNotebookDetailsEndpoint(OrganizationNotebookEndpoint): owner = ApiOwner.ML_AI publish_status = { - "GET": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PUBLIC_EXPERIMENTAL, "PUT": ApiPublishStatus.PRIVATE, "DELETE": ApiPublishStatus.PRIVATE, } @extend_schema( - operation_id="Retrieve an Investigation", + operation_id="Retrieve a Notebook", + tags=["Notebooks"], parameters=[GlobalParams.ORG_ID_OR_SLUG, NotebookParams.NOTEBOOK_ID], request=None, responses={ 200: inline_sentry_response_serializer( - "InvestigationDetailsResponse", NotebookDetailsSerializerResponse + "NotebookDetailsResponse", NotebookDetailsSerializerResponse ), 400: constants.RESPONSE_BAD_REQUEST, 401: constants.RESPONSE_UNAUTHORIZED, 403: constants.RESPONSE_FORBIDDEN, 404: constants.RESPONSE_NOT_FOUND, }, + examples=NotebookExamples.NOTEBOOK_DETAILS, ) - def get(self, request: Request, organization: Organization, notebook: Notebook) -> Response: + def get( + self, request: Request, organization: Organization, notebook: Notebook + ) -> Response[NotebookDetailsSerializerResponse]: """ Return notebook metadata, source context, filters, project IDs, parameter definitions and saved values, and ordered active blocks. Poll this endpoint for notebook and block output updates. Requires the organizations:investigations feature and open organization membership. """ - return Response( - serialize( - notebook, - request.user, - NotebookDetailsSerializer(access=request.access), - ) + body: NotebookDetailsSerializerResponse = serialize( + notebook, + request.user, + NotebookDetailsSerializer(access=request.access), ) + return Response(body) @extend_schema( operation_id="Update an Investigation", diff --git a/src/sentry/notebooks/endpoints/organization_notebook_index.py b/src/sentry/notebooks/endpoints/organization_notebook_index.py index 2947c25efb81..5b0cdb91a1a7 100644 --- a/src/sentry/notebooks/endpoints/organization_notebook_index.py +++ b/src/sentry/notebooks/endpoints/organization_notebook_index.py @@ -13,7 +13,13 @@ from sentry.api.paginator import DateTimePaginator from sentry.api.serializers import serialize from sentry.apidocs import constants +from sentry.apidocs.examples.notebook_examples import NotebookExamples from sentry.apidocs.parameters import GlobalParams, NotebookParams +from sentry.apidocs.response_types import ( + DetailResponse, + ValidationErrorResponse, + as_validation_errors, +) from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import acting_user_id from sentry.models.organization import Organization @@ -79,7 +85,8 @@ def filter_by_trigger( @cell_silo_endpoint class OrganizationNotebookIndexEndpoint(OrganizationNotebooksBaseEndpoint): owner = ApiOwner.ML_AI - publish_status = {"GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PRIVATE} + publish_status = {"GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PUBLIC_EXPERIMENTAL} + idempotent_methods = frozenset() @extend_schema( operation_id="List an Organization's Investigations", @@ -179,15 +186,16 @@ def get(self, request: Request, organization: Organization) -> Response: ) @extend_schema( - operation_id="Create an Investigation", + operation_id="Create a Notebook", + tags=["Notebooks"], parameters=[GlobalParams.ORG_ID_OR_SLUG], request=NotebookCreateValidator, responses={ 200: inline_sentry_response_serializer( - "InvestigationDetailsResponse", NotebookDetailsSerializerResponse + "NotebookDetailsResponse", NotebookDetailsSerializerResponse ), 201: inline_sentry_response_serializer( - "InvestigationDetailsResponse", NotebookDetailsSerializerResponse + "NotebookDetailsResponse", NotebookDetailsSerializerResponse ), 400: constants.RESPONSE_BAD_REQUEST, 401: constants.RESPONSE_UNAUTHORIZED, @@ -200,9 +208,16 @@ def get(self, request: Request, organization: Organization) -> Response: value={"title": "Checkout latency", "projectIds": [1]}, request_only=True, ) - ], + ] + + NotebookExamples.CREATE_NOTEBOOK, ) - def post(self, request: Request, organization: Organization) -> Response: + def post( + self, request: Request, organization: Organization + ) -> ( + Response[NotebookDetailsSerializerResponse] + | Response[ValidationErrorResponse] + | Response[DetailResponse] + ): """ Create a blank notebook, or a notebook for a producer's source. Returns 201 with notebook details, blocks, parameters, and orchestration summary, or 200 when an active @@ -215,17 +230,17 @@ def post(self, request: Request, organization: Organization) -> Response: context={"organization": organization, "creator": get_creator_reference(request)}, ) if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(validator), status=status.HTTP_400_BAD_REQUEST) values = validator.validated_data accessible_project_ids = organization_project_ids(organization) source: ProducerSource | None = values.get("source") if source is not None and not set(source.resolved.project_ids).issubset( accessible_project_ids ): - return Response( - {"source": ["One or more of the source's projects are inaccessible."]}, - status=status.HTTP_400_BAD_REQUEST, - ) + source_error: ValidationErrorResponse = { + "source": ["One or more of the source's projects are inaccessible."] + } + return Response(source_error, status=status.HTTP_400_BAD_REQUEST) if not set(values.get("project_ids", [])).issubset(accessible_project_ids): return Response( {"detail": "One or more projects are inaccessible."}, @@ -244,11 +259,9 @@ def post(self, request: Request, organization: Organization) -> Response: raise if created: record_notebook_started(notebook) - return Response( - serialize( - notebook, - request.user, - NotebookDetailsSerializer(access=request.access), - ), - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + body: NotebookDetailsSerializerResponse = serialize( + notebook, + request.user, + NotebookDetailsSerializer(access=request.access), ) + return Response(body, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) diff --git a/src/sentry/notebooks/endpoints/serializers/action_request.py b/src/sentry/notebooks/endpoints/serializers/action_request.py index d9d1c016a306..181449b99abd 100644 --- a/src/sentry/notebooks/endpoints/serializers/action_request.py +++ b/src/sentry/notebooks/endpoints/serializers/action_request.py @@ -8,7 +8,8 @@ from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register -from sentry.apidocs.descriptions import ApiSchema +from sentry.apidocs.descriptions import ApiDescription, ApiSchema +from sentry.apidocs.formats import JsonValue from sentry.apidocs.spectacular_ports import resolve_type_hint from sentry.auth.access import Access from sentry.notebooks.models import ( @@ -45,9 +46,11 @@ class NotebookActionRequestAnchorResponse(TypedDict): it points to, if there is one. """ - type: str - id: str - blockId: str | None + type: Annotated[str, ApiDescription("block or hypothesis")] + id: Annotated[str, ApiDescription("The agent's ID for the block or hypothesis")] + blockId: Annotated[ + str | None, ApiDescription("The ID of the block the request is under, if it still exists") + ] class NotebookActionRequestFollowUpResponse(TypedDict): @@ -56,10 +59,20 @@ class NotebookActionRequestFollowUpResponse(TypedDict): re-check. `retryable` says whether the user can try it again. """ - status: Annotated[str, _schema_from(follow_up_status_hint)] - reason: Annotated[str | None, _schema_from(follow_up_reason_hint)] - rerun: Annotated[str | None, _schema_from(follow_up_rerun_hint)] - retryable: bool + status: Annotated[ + str, _schema_from(follow_up_status_hint), ApiDescription("Where the follow-up is") + ] + reason: Annotated[ + str | None, + _schema_from(follow_up_reason_hint), + ApiDescription("Why the agent turned down or dropped the follow-up, if it did"), + ] + rerun: Annotated[ + str | None, + _schema_from(follow_up_rerun_hint), + ApiDescription("What the agent did with a re-run. Null for a re-check"), + ] + retryable: Annotated[bool, ApiDescription("Whether the requesting user can send it again")] class NotebookActionRequestSerializerResponse(TypedDict): @@ -72,25 +85,48 @@ class NotebookActionRequestSerializerResponse(TypedDict): Sentry sent for this request. """ - id: str - key: str - reportRevision: int - primary: bool - anchor: NotebookActionRequestAnchorResponse | None - title: str - rationale: str - impact: str | None - state: str - reason: str | None - action: dict[str, Any] | None - canApply: bool - cannotApplyReason: CannotApplyReason | None - appliedBy: UserSerializerResponse | None - appliedDetails: Annotated[Mapping[str, object] | None, _schema_from(applied_details_hint)] - dateApplied: datetime | None - error: ActionRequestError | None - followUp: NotebookActionRequestFollowUpResponse | None - dateCreated: datetime + id: Annotated[str, ApiDescription("The action request ID")] + key: Annotated[str, ApiDescription("The agent's key for the request")] + reportRevision: Annotated[int, ApiDescription("The report revision that suggested it")] + primary: Annotated[bool, ApiDescription("Whether it's the recommended next step")] + anchor: Annotated[ + NotebookActionRequestAnchorResponse | None, + ApiDescription("The block or hypothesis the request is under, if any"), + ] + title: Annotated[str, ApiDescription("A short title for the step")] + rationale: Annotated[str, ApiDescription("Why the agent suggests it")] + impact: Annotated[str | None, ApiDescription("What the step would change, if the agent said")] + state: Annotated[str, ApiDescription("suggested, applied, failed, superseded, or withdrawn")] + reason: Annotated[ + str | None, ApiDescription("Why the request is in its state, if there's a reason") + ] + action: Annotated[ + dict[str, JsonValue] | None, + ApiDescription("The agent's action payload, or null when the request is only advice"), + ] + canApply: Annotated[bool, ApiDescription("Whether the requesting user can apply it now")] + cannotApplyReason: Annotated[ + CannotApplyReason | None, + ApiDescription("Why the requesting user can't apply it, if they can't"), + ] + appliedBy: Annotated[ + UserSerializerResponse | None, + ApiDescription("The user who applied it. Null when Sentry applied it"), + ] + appliedDetails: Annotated[ + Mapping[str, object] | None, + _schema_from(applied_details_hint), + ApiDescription("What applying it changed. The fields depend on the action"), + ] + dateApplied: Annotated[datetime | None, ApiDescription("When it was applied")] + error: Annotated[ + ActionRequestError | None, ApiDescription("What went wrong, if applying it failed") + ] + followUp: Annotated[ + NotebookActionRequestFollowUpResponse | None, + ApiDescription("The re-check or re-run Sentry sent the agent, if any"), + ] + dateCreated: Annotated[datetime, ApiDescription("When the agent suggested it")] @register(NotebookActionRequest) diff --git a/src/sentry/notebooks/endpoints/serializers/block.py b/src/sentry/notebooks/endpoints/serializers/block.py index 3cf370912c8d..7eb17c61654b 100644 --- a/src/sentry/notebooks/endpoints/serializers/block.py +++ b/src/sentry/notebooks/endpoints/serializers/block.py @@ -2,12 +2,14 @@ from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime -from typing import Any, TypedDict, override +from typing import Annotated, Any, TypedDict, override from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer +from sentry.apidocs.descriptions import ApiDescription +from sentry.apidocs.formats import JsonValue from sentry.notebooks.models import ( Notebook, NotebookBlock, @@ -20,13 +22,15 @@ class NotebookBlockExecutionSerializerResponse(TypedDict): - id: str - status: str - executor: str - schemaVersion: int - startedAt: datetime | None - completedAt: datetime | None - error: Any + id: Annotated[str, ApiDescription("The execution ID")] + status: Annotated[str, ApiDescription("The execution status")] + executor: Annotated[str, ApiDescription("What ran the block")] + schemaVersion: Annotated[int, ApiDescription("The version of the result's schema")] + startedAt: Annotated[datetime | None, ApiDescription("When the execution started")] + completedAt: Annotated[ + datetime | None, ApiDescription("When the execution finished, if it has") + ] + error: Annotated[JsonValue, ApiDescription("What went wrong, if the execution failed")] class NotebookBlockSerializerResponse(TypedDict): @@ -38,26 +42,56 @@ class NotebookBlockSerializerResponse(TypedDict): set on blocks a producer wrote. """ - id: str - position: int - kind: str - title: str - content: str - generationPrompt: str - generatedContent: str - output: Any - outputStatus: str - currentExecution: NotebookBlockExecutionSerializerResponse | None - config: dict[str, Any] - display: dict[str, Any] - dependencies: list[str] - parameterKeys: list[str] - version: int - staleAt: datetime | None - createdBy: str | None - lastEditedBy: str | None - producerKey: str | None - producerRevision: int | None + id: Annotated[str, ApiDescription("The block ID")] + position: Annotated[int, ApiDescription("The block's position in the notebook, starting at 0")] + kind: Annotated[str, ApiDescription("text for Markdown, or query for a data query")] + title: Annotated[str, ApiDescription("The block title. May be empty")] + content: Annotated[ + str, ApiDescription("The block's content: Markdown for text, the query for query") + ] + generationPrompt: Annotated[str, ApiDescription("Instructions used when running the block")] + generatedContent: Annotated[ + str, ApiDescription("The latest generated content, before anyone edited it") + ] + output: Annotated[ + JsonValue, + ApiDescription("The latest successful result. Text blocks have schemaVersion and markdown"), + ] + outputStatus: Annotated[ + str, ApiDescription("available, notRun, or the status of the latest execution") + ] + currentExecution: Annotated[ + NotebookBlockExecutionSerializerResponse | None, + ApiDescription("The latest execution, if the block has run"), + ] + config: Annotated[dict[str, JsonValue], ApiDescription("How the block runs, such as auto-run")] + display: Annotated[ + dict[str, JsonValue], + ApiDescription( + "How the block is shown. Text uses type markdown; query uses table, line, bar, or area" + ), + ] + dependencies: Annotated[list[str], ApiDescription("The IDs of blocks this block depends on")] + parameterKeys: Annotated[list[str], ApiDescription("The keys of the parameters the block uses")] + version: Annotated[ + int, ApiDescription("The block's concurrency token. Send it as version with a change") + ] + staleAt: Annotated[ + datetime | None, + ApiDescription("When an input changed after the output was made, if one did"), + ] + createdBy: Annotated[ + str | None, ApiDescription("The ID of the user who added the block, if a user did") + ] + lastEditedBy: Annotated[ + str | None, ApiDescription("The ID of the user who last edited the block, if a user did") + ] + producerKey: Annotated[ + str | None, ApiDescription("The producer's key for the block, if a producer wrote it") + ] + producerRevision: Annotated[ + int | None, ApiDescription("The producer's revision for the block, if a producer wrote it") + ] class NotebookBlockSerializer(Serializer[NotebookBlockSerializerResponse]): diff --git a/src/sentry/notebooks/endpoints/serializers/notebook.py b/src/sentry/notebooks/endpoints/serializers/notebook.py index 70724695b663..b2fb1b8a6424 100644 --- a/src/sentry/notebooks/endpoints/serializers/notebook.py +++ b/src/sentry/notebooks/endpoints/serializers/notebook.py @@ -3,12 +3,14 @@ from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime -from typing import Any, TypedDict, override +from typing import Annotated, Any, TypedDict, override from django.contrib.auth.models import AnonymousUser from django.db.models import Count, Q from sentry.api.serializers import Serializer, register, serialize +from sentry.apidocs.descriptions import ApiDescription +from sentry.apidocs.formats import JsonValue from sentry.auth.access import Access from sentry.auth.principal import acting_user_id from sentry.notebooks.endpoints.serializers.action_request import ( @@ -39,34 +41,62 @@ class NotebookCreatorSerializerResponse(TypedDict): - type: ActorType - id: str + type: Annotated[ + ActorType, ApiDescription("Whether a user or a service account created the notebook") + ] + id: Annotated[ + str, ApiDescription("The ID of the user or service account that created the notebook") + ] class NotebookTemplateSerializerResponse(TypedDict): - key: str | None - version: int | None + key: Annotated[ + str | None, ApiDescription("The key of the template the notebook started from, if any") + ] + version: Annotated[int | None, ApiDescription("The version of that template, if any")] class NotebookSourceSerializerResponseOptional(TypedDict, total=False): - snapshot: dict[str, Any] + snapshot: Annotated[ + dict[str, JsonValue], + ApiDescription( + "What Sentry knew about the source when the notebook started. Only on source-backed notebooks" + ), + ] class NotebookSourceSerializerResponse(NotebookSourceSerializerResponseOptional): - type: str - ref: dict[str, Any] - revision: int | None + type: Annotated[ + str, ApiDescription("The source type: manual, or the type of what the notebook is about") + ] + ref: Annotated[ + dict[str, JsonValue], + ApiDescription( + "The identifying fields of the source, such as an issue ID. Empty for a manual notebook" + ), + ] + revision: Annotated[ + int | None, + ApiDescription( + "The notebook's revision for its source. A new revision starts once the previous one is archived. Null for a manual notebook" + ), + ] class NotebookTitleGenerationSerializerResponse(TypedDict): - status: str | None + status: Annotated[ + str | None, + ApiDescription("The state of automatic title generation, or null if it hasn't run"), + ] class NotebookOrchestrationSerializerResponse(TypedDict): - phase: str - status: str - heartbeatAt: datetime | None - notebookRevision: int + phase: Annotated[str, ApiDescription("The phase the agent's run is in")] + status: Annotated[str, ApiDescription("The status of the agent's run")] + heartbeatAt: Annotated[datetime | None, ApiDescription("When the run last reported progress")] + notebookRevision: Annotated[ + int, ApiDescription("How many times the run has changed the notebook's report") + ] class NotebookSerializerResponse(TypedDict): @@ -76,21 +106,45 @@ class NotebookSerializerResponse(TypedDict): and dates are ISO-8601 timestamps. """ - id: str - title: str - summary: str | None - summaryDescription: str | None - status: str - sourceType: str - createdBy: str | None - creator: NotebookCreatorSerializerResponse | None - dateCreated: datetime - dateUpdated: datetime - version: int - blockCount: int - isFavorited: bool - titleGeneration: NotebookTitleGenerationSerializerResponse - orchestration: NotebookOrchestrationSerializerResponse | None + id: Annotated[str, ApiDescription("The notebook ID")] + title: Annotated[str, ApiDescription("The notebook title")] + summary: Annotated[ + str | None, ApiDescription("A one-line summary of the notebook, if one was generated") + ] + summaryDescription: Annotated[ + str | None, ApiDescription("A longer summary of the notebook, if one was generated") + ] + status: Annotated[str, ApiDescription("active or archived. Archived notebooks are read-only")] + sourceType: Annotated[ + str, ApiDescription("What the notebook is about: manual, or a source type") + ] + createdBy: Annotated[ + str | None, ApiDescription("The ID of the user who created the notebook, if a user did") + ] + creator: Annotated[ + NotebookCreatorSerializerResponse | None, + ApiDescription("The user or service account that created the notebook"), + ] + dateCreated: Annotated[datetime, ApiDescription("When the notebook was created")] + dateUpdated: Annotated[datetime, ApiDescription("When the notebook last changed")] + version: Annotated[ + int, + ApiDescription( + "The notebook's concurrency token. Send it as notebookVersion with a change" + ), + ] + blockCount: Annotated[int, ApiDescription("The number of blocks the notebook has")] + isFavorited: Annotated[ + bool, ApiDescription("Whether the requesting user favorited the notebook") + ] + titleGeneration: Annotated[ + NotebookTitleGenerationSerializerResponse, + ApiDescription("The state of automatic title generation"), + ] + orchestration: Annotated[ + NotebookOrchestrationSerializerResponse | None, + ApiDescription("The agent's run for the notebook, if it has one"), + ] class NotebookDetailsSerializerResponse(NotebookSerializerResponse): @@ -101,13 +155,28 @@ class NotebookDetailsSerializerResponse(NotebookSerializerResponse): re-checks them, newest report first, with that report's primary at the top. """ - template: NotebookTemplateSerializerResponse | None - source: NotebookSourceSerializerResponse - filters: dict[str, Any] - projectIds: list[int] - parameters: list[NotebookParameterSerializerResponse] - blocks: list[NotebookBlockSerializerResponse] - actionRequests: list[NotebookActionRequestSerializerResponse] + template: Annotated[ + NotebookTemplateSerializerResponse | None, + ApiDescription("The template the notebook started from, if any"), + ] + source: Annotated[ + NotebookSourceSerializerResponse, ApiDescription("What the notebook is about") + ] + filters: Annotated[ + dict[str, JsonValue], ApiDescription("Saved filters, stored with their original keys") + ] + projectIds: Annotated[list[int], ApiDescription("The IDs of the projects the notebook covers")] + parameters: Annotated[ + list[NotebookParameterSerializerResponse], + ApiDescription("The notebook's parameters, in order"), + ] + blocks: Annotated[ + list[NotebookBlockSerializerResponse], ApiDescription("The notebook's blocks, in order") + ] + actionRequests: Annotated[ + list[NotebookActionRequestSerializerResponse], + ApiDescription("Steps the agent suggests, newest report first"), + ] @register(Notebook) diff --git a/src/sentry/notebooks/endpoints/serializers/parameter.py b/src/sentry/notebooks/endpoints/serializers/parameter.py index 396b3c4594bb..1edd7695df6e 100644 --- a/src/sentry/notebooks/endpoints/serializers/parameter.py +++ b/src/sentry/notebooks/endpoints/serializers/parameter.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Any, TypedDict +from typing import Annotated, TypedDict + +from sentry.apidocs.descriptions import ApiDescription +from sentry.apidocs.formats import JsonValue class NotebookParameterSerializerResponse(TypedDict): @@ -9,15 +12,17 @@ class NotebookParameterSerializerResponse(TypedDict): defaultValue and savedValue may be null. Use key in the parameter-values update request. """ - id: str - key: str - label: str - description: str - type: str - required: bool - constraints: dict[str, Any] - defaultValue: Any - savedValue: Any - source: str - position: int - version: int + id: Annotated[str, ApiDescription("The parameter ID")] + key: Annotated[str, ApiDescription("The key blocks use to refer to the parameter")] + label: Annotated[str, ApiDescription("The parameter's display name")] + description: Annotated[str, ApiDescription("What the parameter is for")] + type: Annotated[str, ApiDescription("The type of value the parameter takes")] + required: Annotated[bool, ApiDescription("Whether the parameter needs a value")] + constraints: Annotated[ + dict[str, JsonValue], ApiDescription("Limits on the parameter's value, by type") + ] + defaultValue: Annotated[JsonValue, ApiDescription("The value used when none is saved")] + savedValue: Annotated[JsonValue, ApiDescription("The saved value, or null")] + source: Annotated[str, ApiDescription("Who defined the parameter")] + position: Annotated[int, ApiDescription("The parameter's position in the notebook")] + version: Annotated[int, ApiDescription("The parameter's concurrency token")] diff --git a/src/sentry/notebooks/endpoints/validators/base.py b/src/sentry/notebooks/endpoints/validators/base.py index 6e91280ae1c5..40c5dbf054e7 100644 --- a/src/sentry/notebooks/endpoints/validators/base.py +++ b/src/sentry/notebooks/endpoints/validators/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, ClassVar, TypeVar +from typing import Any, TypeVar from rest_framework import serializers from rest_framework.fields import empty @@ -60,28 +60,7 @@ def to_internal_value(self, data: Any) -> dict[str, Any]: class NotebookVersionValidator(StrictCamelSnakeValidator[InstanceT]): - """Takes the notebook version as notebookVersion, or as investigationVersion from older - clients. Validated data has it as notebook_version. - """ - - notebook_version_required: ClassVar[bool] = True - notebook_version = serializers.IntegerField( min_value=1, - required=False, help_text="Current notebook version from the latest detail response. Stale versions return 409.", ) - investigation_version = serializers.IntegerField( - min_value=1, required=False, help_text="Deprecated. Use notebookVersion." - ) - - def to_internal_value(self, data: Any) -> dict[str, Any]: - attrs = super().to_internal_value(data) - legacy = attrs.pop("investigation_version", None) - if legacy is not None and attrs.setdefault("notebook_version", legacy) != legacy: - raise serializers.ValidationError( - {"notebook_version": "notebookVersion and investigationVersion differ."} - ) - if self.notebook_version_required and "notebook_version" not in attrs: - raise serializers.ValidationError({"notebook_version": "This field is required."}) - return attrs diff --git a/src/sentry/notebooks/endpoints/validators/block.py b/src/sentry/notebooks/endpoints/validators/block.py index d9d8c1748743..e7b56afe4f6c 100644 --- a/src/sentry/notebooks/endpoints/validators/block.py +++ b/src/sentry/notebooks/endpoints/validators/block.py @@ -149,8 +149,11 @@ class BlockCreateValidator(NotebookVersionValidator[tuple[NotebookBlock, bool]]) Needs notebook, user_id and producer_writer in its context. """ - notebook_version_required = False - + notebook_version = serializers.IntegerField( + min_value=1, + required=False, + help_text="Current notebook version from the latest detail response. Stale versions return 409. Send this, or key and revision.", + ) key = serializers.CharField( max_length=128, required=False, diff --git a/src/sentry/notebooks/services/action_kinds/base.py b/src/sentry/notebooks/services/action_kinds/base.py index 85efb488f161..b63124b21d20 100644 --- a/src/sentry/notebooks/services/action_kinds/base.py +++ b/src/sentry/notebooks/services/action_kinds/base.py @@ -4,10 +4,11 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING, Any, Generic, NotRequired, TypedDict, TypeVar +from typing import TYPE_CHECKING, Annotated, Any, Generic, NotRequired, TypedDict, TypeVar from rest_framework.request import Request +from sentry.apidocs.descriptions import ApiDescription from sentry.auth.access import Access from sentry.auth.principal import RequestPrincipal from sentry.notebooks.contracts import ActionPayloadSerializer @@ -60,9 +61,14 @@ class ApplyError(TypedDict): 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]] + code: Annotated[ApplyErrorCode, ApiDescription("The error code")] + current: NotRequired[ + Annotated[ + dict[str, SettingValue], + ApiDescription("The current values of the settings the action would change"), + ] + ] + messages: NotRequired[Annotated[list[str], ApiDescription("Messages that explain the error")]] class ApplyFailed(Exception): diff --git a/tests/apidocs/test_hooks.py b/tests/apidocs/test_hooks.py index e7dc7e8c4adc..af9cfd878777 100644 --- a/tests/apidocs/test_hooks.py +++ b/tests/apidocs/test_hooks.py @@ -28,6 +28,7 @@ from sentry.core.endpoints.team_details import TeamDetailsEndpoint from sentry.investigations.apidocs import investigation_endpoints from sentry.issues.endpoints.group_details import GroupDetailsEndpoint +from sentry.notebooks.endpoints.base import OrganizationNotebooksBaseEndpoint @pytest.mark.django_db @@ -46,12 +47,38 @@ def test_investigation_schema_selects_only_private_frontend_routes() -> None: for path, _regex, _method, _callback in selected ) assert all( - callback.view_class.publish_status[method] is ApiPublishStatus.PRIVATE + not callback.view_class.publish_status[method].is_published for _path, _regex, method, callback in selected ) with patch("sentry.apidocs.hooks.__write_ownership_data"): published = custom_preprocessing_hook(endpoints) - assert not {entry[0] for entry in selected}.intersection(entry[0] for entry in published) + assert not {(entry[0], entry[2]) for entry in selected}.intersection( + (entry[0], entry[2]) for entry in published + ) + + +def test_investigation_schema_keeps_unpublished_notebook_methods() -> None: + view_class = type( + "FakeNotebookEndpoint", + (OrganizationNotebooksBaseEndpoint,), + { + "publish_status": { + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.PUBLIC_EXPERIMENTAL, + "DELETE": ApiPublishStatus.PUBLIC, + } + }, + ) + callback = type("FakeCallback", (), {"view_class": view_class}) + endpoints = [ + ("/api/0/notebooks/", "/api/0/notebooks/", method, callback) + for method in ("GET", "POST", "PUT", "DELETE") + ] + + selected = investigation_endpoints(endpoints) + + assert [method for _path, _regex, method, _callback in selected] == ["GET", "POST"] class EndpointServersTest(TestCase): diff --git a/tests/sentry/apidocs/test_extensions.py b/tests/sentry/apidocs/test_extensions.py index 2e09720f905e..9dea270875fc 100644 --- a/tests/sentry/apidocs/test_extensions.py +++ b/tests/sentry/apidocs/test_extensions.py @@ -254,9 +254,9 @@ def test_investigation_delete_schema_requires_camel_case_versions() -> None: {**request["content"]["application/json"]["schema"], "components": schema["components"]} ) assert validator.is_valid({"notebookVersion": 1, "version": 1}) - assert validator.is_valid({"investigationVersion": 1, "version": 1}) + assert not validator.is_valid({"version": 1}) assert not validator.is_valid({"notebookVersion": 1}) - assert not validator.is_valid({"notebook_version": 1, "version": 1}) + assert not validator.is_valid({"investigationVersion": 1, "version": 1}) @pytest.mark.parametrize( diff --git a/tests/sentry/investigations/services/test_orchestration_events.py b/tests/sentry/investigations/services/test_orchestration_events.py index 0f3c5533c66e..b28711645e70 100644 --- a/tests/sentry/investigations/services/test_orchestration_events.py +++ b/tests/sentry/investigations/services/test_orchestration_events.py @@ -673,7 +673,7 @@ def test_query_report_displays_use_the_editable_block_schema(self) -> None: self.investigation.refresh_from_db() validator = BlockUpdateValidator( data={ - "investigationVersion": self.investigation.version, + "notebookVersion": self.investigation.version, "version": block.version, "display": {**block.display, "queryCollapsed": False}, }, diff --git a/tests/sentry/notebooks/endpoints/test_base.py b/tests/sentry/notebooks/endpoints/test_base.py index b71e2712890f..c0b66ed828fb 100644 --- a/tests/sentry/notebooks/endpoints/test_base.py +++ b/tests/sentry/notebooks/endpoints/test_base.py @@ -98,7 +98,7 @@ def write_everything(self, client: APIClient) -> list[int]: ) block = client.post( self.url("sentry-api-0-organization-notebook-blocks", notebook_id=self.notebook.id), - {"investigationVersion": self.notebook.version, "kind": "text", "content": "a"}, + {"notebookVersion": self.notebook.version, "kind": "text", "content": "a"}, format="json", ) if block.status_code != 201: @@ -110,17 +110,17 @@ def write_everything(self, client: APIClient) -> list[int]: ) updated = client.put( block_url, - {"investigationVersion": 2, "version": 1, "content": "b"}, + {"notebookVersion": 2, "version": 1, "content": "b"}, format="json", ) ordered = client.put( self.url( "sentry-api-0-organization-notebook-block-order", notebook_id=self.notebook.id ), - {"investigationVersion": 3, "blockIds": [int(block.data["id"])]}, + {"notebookVersion": 3, "blockIds": [int(block.data["id"])]}, format="json", ) - deleted = client.delete(block_url, {"investigationVersion": 4, "version": 2}, format="json") + deleted = client.delete(block_url, {"notebookVersion": 4, "version": 2}, format="json") return [ created.status_code, block.status_code, @@ -171,20 +171,20 @@ def test_rejected_principals_cannot_change_existing_blocks(self) -> None: assert ( client.put( block_url, - {"investigationVersion": 1, "version": 1, "content": "no"}, + {"notebookVersion": 1, "version": 1, "content": "no"}, format="json", ).status_code == 403 ) assert ( client.put( - order_url, {"investigationVersion": 1, "blockIds": [block.id]}, format="json" + order_url, {"notebookVersion": 1, "blockIds": [block.id]}, format="json" ).status_code == 403 ) assert ( client.delete( - block_url, {"investigationVersion": 1, "version": 1}, format="json" + block_url, {"notebookVersion": 1, "version": 1}, format="json" ).status_code == 403 ) @@ -195,8 +195,8 @@ def test_rejected_principals_cannot_change_existing_blocks(self) -> None: def change_notebook(self, client: APIClient) -> list[int]: """Rename then archive the notebook and return the status codes.""" url = self.url("sentry-api-0-organization-notebook-details", notebook_id=self.notebook.id) - renamed = client.put(url, {"investigationVersion": 1, "title": "Renamed"}, format="json") - archived = client.delete(url, {"investigationVersion": 2}, format="json") + renamed = client.put(url, {"notebookVersion": 1, "title": "Renamed"}, format="json") + archived = client.delete(url, {"notebookVersion": 2}, format="json") return [renamed.status_code, archived.status_code] def test_users_agents_and_service_accounts_change_notebooks(self) -> None: @@ -227,7 +227,7 @@ def test_service_account_archives_a_running_investigation(self) -> None: url = self.url("sentry-api-0-organization-notebook-details", notebook_id=self.notebook.id) with self.captureOnCommitCallbacks(execute=False): - response = client.delete(url, {"investigationVersion": 1}, format="json") + response = client.delete(url, {"notebookVersion": 1}, format="json") assert response.status_code == 204, response.data self.notebook.refresh_from_db() @@ -255,29 +255,29 @@ def test_project_limited_service_account_cannot_change_other_notebooks(self) -> ) responses = [ - client.put(details_url, {"investigationVersion": 1, "title": "No"}, format="json"), + client.put(details_url, {"notebookVersion": 1, "title": "No"}, format="json"), client.put( details_url, - {"investigationVersion": 1, "projectIds": [self.project.id]}, + {"notebookVersion": 1, "projectIds": [self.project.id]}, format="json", ), - client.delete(details_url, {"investigationVersion": 1}, format="json"), + client.delete(details_url, {"notebookVersion": 1}, format="json"), client.post( self.url("sentry-api-0-organization-notebook-blocks", notebook_id=self.notebook.id), - {"investigationVersion": 1, "kind": "text", "content": "no"}, + {"notebookVersion": 1, "kind": "text", "content": "no"}, format="json", ), client.put( - block_url, {"investigationVersion": 1, "version": 1, "content": "no"}, format="json" + block_url, {"notebookVersion": 1, "version": 1, "content": "no"}, format="json" ), client.put( self.url( "sentry-api-0-organization-notebook-block-order", notebook_id=self.notebook.id ), - {"investigationVersion": 1, "blockIds": [block.id]}, + {"notebookVersion": 1, "blockIds": [block.id]}, format="json", ), - client.delete(block_url, {"investigationVersion": 1, "version": 1}, format="json"), + client.delete(block_url, {"notebookVersion": 1, "version": 1}, format="json"), ] assert [response.status_code for response in responses] == [403] * 7 @@ -314,7 +314,7 @@ def test_service_account_needs_every_project_for_an_unscoped_notebook(self) -> N ) cleared = client.put( self.url("sentry-api-0-organization-notebook-details", notebook_id=self.notebook.id), - {"investigationVersion": 1, "projectIds": []}, + {"notebookVersion": 1, "projectIds": []}, format="json", ) @@ -339,7 +339,7 @@ def test_service_account_only_attaches_its_own_projects(self) -> None: ) assert ( client.put( - details_url, {"investigationVersion": 1, "projectIds": [other.id]}, format="json" + details_url, {"notebookVersion": 1, "projectIds": [other.id]}, format="json" ).status_code == 403 ) @@ -351,7 +351,7 @@ def test_service_account_only_attaches_its_own_projects(self) -> None: ) assert created.status_code == 201, created.data updated = client.put( - details_url, {"investigationVersion": 1, "projectIds": [self.project.id]}, format="json" + details_url, {"notebookVersion": 1, "projectIds": [self.project.id]}, format="json" ) assert updated.status_code == 200, updated.data assert list( diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_details.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_details.py index 764899847c22..22554658d255 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_details.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_details.py @@ -39,7 +39,7 @@ def test_update_and_soft_delete_block(self) -> None: response = self.client.put( self.block_url(block), data={ - "investigationVersion": original_notebook_version, + "notebookVersion": original_notebook_version, "version": block.version, "content": "updated query", }, @@ -58,7 +58,7 @@ def test_update_and_soft_delete_block(self) -> None: response = self.client.delete( self.block_url(block), - data={"investigationVersion": self.notebook.version, "version": block.version}, + data={"notebookVersion": self.notebook.version, "version": block.version}, format="json", ) assert response.status_code == 204 @@ -70,7 +70,7 @@ def test_update_conflict(self) -> None: response = self.client.put( self.block_url(block), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": 99, "content": "stale", }, @@ -88,7 +88,7 @@ def test_update_rejects_version_only_payload_without_changes(self) -> None: response = self.client.put( self.block_url(block), data={ - "investigationVersion": original_notebook_version, + "notebookVersion": original_notebook_version, "version": original_block_version, }, format="json", @@ -115,7 +115,7 @@ def test_update_with_identical_values_does_not_change_versions_or_metadata(self) response = self.client.put( self.block_url(block), data={ - "investigationVersion": original_notebook_version, + "notebookVersion": original_notebook_version, "version": original_block_version, "content": "unchanged", }, @@ -145,7 +145,7 @@ def test_block_update_rejects_server_owned_and_immutable_fields(self) -> None: response = self.client.put( self.block_url(block), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": block.version, field: value, }, @@ -159,7 +159,7 @@ def test_block_update_checks_both_versions_atomically(self) -> None: response = self.client.put( self.block_url(block), data={ - "investigationVersion": 99, + "notebookVersion": 99, "version": block.version, "content": "must not be saved", }, @@ -182,7 +182,7 @@ def test_delete_rejects_an_active_block_run(self) -> None: response = self.client.delete( self.block_url(block), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": block.version, }, format="json", diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_execution_index.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_execution_index.py index 7a4b8a44cb62..9b67b32624e9 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_execution_index.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_execution_index.py @@ -72,7 +72,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: response = self.client.post( self.url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": self.block.version, }, format="json", @@ -102,7 +102,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: mock_client.return_value.start_run.side_effect = start_run body = { - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "requestId": str(uuid4()), "version": self.block.version, } @@ -129,7 +129,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: mock_client.return_value.start_run.side_effect = start_run body = { - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": self.block.version, } first = self.client.post(self.url, data={**body, "requestId": str(uuid4())}, format="json") @@ -158,7 +158,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: response = self.client.post( self.url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": self.block.version, }, format="json", @@ -189,7 +189,7 @@ def test_dispatch_failure_cancels_other_active_cells(self, mock_client: MagicMoc response = self.client.post( self.url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": self.block.version, }, format="json", @@ -288,7 +288,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: response = self.client.post( self.url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": self.block.version, }, format="json", diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_index.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_index.py index 53ea50e6ca3f..ff8206057a72 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_index.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_index.py @@ -52,7 +52,7 @@ def test_create_block(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": 1, + "notebookVersion": 1, "kind": "query", "content": "find slow transactions", "generationPrompt": "Generate the query", @@ -75,7 +75,7 @@ def test_display_validation(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "display": {"type": "line", "xAxis": "time", "yAxes": []}, }, @@ -86,7 +86,7 @@ def test_display_validation(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "text", "display": {"type": "table"}, }, @@ -97,7 +97,7 @@ def test_display_validation(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "content": "Show error volume", "display": { @@ -115,7 +115,7 @@ def test_versioned_display_rejects_invalid_field_types(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "display": {"version": 1.0, "type": "table"}, }, @@ -126,7 +126,7 @@ def test_versioned_display_rejects_invalid_field_types(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "text", "display": {"version": True, "type": "markdown"}, }, @@ -137,7 +137,7 @@ def test_versioned_display_rejects_invalid_field_types(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "display": {"version": 1, "type": []}, }, @@ -148,7 +148,7 @@ def test_versioned_display_rejects_invalid_field_types(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "display": { "version": 1, @@ -163,7 +163,7 @@ def test_versioned_display_rejects_invalid_field_types(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "query", "display": { "version": 1, @@ -179,7 +179,7 @@ def test_text_display_accepts_persisted_prompt_collapse(self) -> None: response = self.client.post( self.blocks_url(), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "kind": "text", "generationPrompt": "Summarize the context", "display": { @@ -422,7 +422,7 @@ def test_overwrites_a_user_edit(self) -> None: }, ), data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "version": block.version, "content": "Edited by a person", }, @@ -448,7 +448,7 @@ def test_brings_back_a_deleted_block(self) -> None: "block_id": block.id, }, ), - data={"investigationVersion": self.notebook.version, "version": block.version}, + data={"notebookVersion": self.notebook.version, "version": block.version}, format="json", ) assert deleted.status_code == 204 @@ -492,8 +492,8 @@ def test_needs_one_way_to_write(self) -> None: {"kind": "text"}, {"kind": "text", "key": "summary"}, {"kind": "text", "revision": 1}, - {"kind": "text", "investigationVersion": 1, "key": "summary", "revision": 1}, - {"kind": "text", "investigationVersion": 1, "position": 0}, + {"kind": "text", "notebookVersion": 1, "key": "summary", "revision": 1}, + {"kind": "text", "notebookVersion": 1, "position": 0}, {"kind": "query", "key": "summary", "revision": 1, "content": "Text"}, {"kind": "text", "key": "summary", "revision": 1}, {"kind": "text", "key": "summary", "revision": 1, "content": ""}, @@ -505,7 +505,7 @@ def test_needs_one_way_to_write(self) -> None: def test_user_blocks_have_no_producer_fields(self) -> None: response = self.client.post( - self.url, data={"investigationVersion": 1, "kind": "text"}, format="json" + self.url, data={"notebookVersion": 1, "kind": "text"}, format="json" ) assert response.status_code == 201, response.data diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_order.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_order.py index 3b8026452a4c..8d8e5e3cf17f 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_block_order.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_block_order.py @@ -38,7 +38,7 @@ def test_drag_reorder_requires_exact_permutation(self) -> None: response = self.client.put( url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "blockIds": [block.id for block in reversed(blocks)], }, format="json", @@ -52,7 +52,7 @@ def test_drag_reorder_requires_exact_permutation(self) -> None: response = self.client.put( url, data={ - "investigationVersion": version, + "notebookVersion": version, "blockIds": [blocks[0].id, blocks[0].id, blocks[1].id], }, format="json", @@ -72,7 +72,7 @@ def test_drag_reorder_rejects_missing_deleted_and_foreign_blocks_without_changes response = self.client.put( url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "blockIds": block_ids, }, format="json", @@ -87,7 +87,7 @@ def test_drag_reorder_rejects_missing_deleted_and_foreign_blocks_without_changes response = self.client.put( url, data={ - "investigationVersion": self.notebook.version, + "notebookVersion": self.notebook.version, "blockIds": [first.id, second.id], }, format="json", diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_details.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_details.py index 9e343566f126..5911ee37f9d9 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_details.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_details.py @@ -67,7 +67,7 @@ def test_archive_restore_and_list_filters(self) -> None: response = self.client.delete( detail_url, - data={"investigationVersion": first["version"]}, + data={"notebookVersion": first["version"]}, format="json", ) assert response.status_code == 204 @@ -79,7 +79,7 @@ def test_archive_restore_and_list_filters(self) -> None: archived = self.client.get(detail_url).data response = self.client.put( detail_url, - data={"investigationVersion": archived["version"], "status": "active"}, + data={"notebookVersion": archived["version"], "status": "active"}, format="json", ) assert response.status_code == 200 @@ -90,13 +90,13 @@ def test_update_takes_notebook_version(self) -> None: organization=self.organization, created_by=self.user, title="Versioned" ) - mismatched = self.client.put( + old_name = self.client.put( self.details_url(notebook), - data={"notebookVersion": 1, "investigationVersion": 2, "title": "Renamed"}, + data={"investigationVersion": 1, "title": "Renamed"}, format="json", ) - assert mismatched.status_code == 400 - assert "notebookVersion" in mismatched.data + assert old_name.status_code == 400 + assert "investigationVersion" in old_name.data renamed = self.client.put( self.details_url(notebook), @@ -138,7 +138,7 @@ def test_archive_rejects_an_active_block_run(self) -> None: response = self.client.delete( detail_url, - data={"investigationVersion": notebook.version}, + data={"notebookVersion": notebook.version}, format="json", ) @@ -175,7 +175,7 @@ def test_archiving_agentic_notebook_durably_queues_cancellation(self) -> None: ) as schedule: response = self.client.delete( self.details_url(notebook), - data={"investigationVersion": created["version"]}, + data={"notebookVersion": created["version"]}, format="json", ) @@ -200,7 +200,7 @@ def test_archiving_agentic_notebook_durably_queues_cancellation(self) -> None: restored = self.client.put( self.details_url(notebook), - data={"investigationVersion": notebook.version, "status": "active"}, + data={"notebookVersion": notebook.version, "status": "active"}, format="json", ) assert restored.status_code == 200, restored.data @@ -210,7 +210,7 @@ def test_archiving_agentic_notebook_durably_queues_cancellation(self) -> None: for _ in range(2): archived = self.client.delete( self.details_url(notebook), - data={"investigationVersion": created["version"]}, + data={"notebookVersion": created["version"]}, format="json", ) assert archived.status_code == 204, archived.data @@ -231,7 +231,7 @@ def test_agentic_title_update_marks_a_manual_override(self) -> None: response = self.client.put( self.details_url(notebook), data={ - "investigationVersion": created["version"], + "notebookVersion": created["version"], "title": "My incident title", }, format="json", @@ -248,7 +248,7 @@ def test_agentic_title_update_marks_a_manual_override(self) -> None: stale_response = self.client.put( self.details_url(notebook), data={ - "investigationVersion": created["version"], + "notebookVersion": created["version"], "title": "Overwrite from a stale editor", }, format="json", @@ -295,7 +295,7 @@ def test_detail_includes_action_requests(self) -> None: updated = self.client.put( url, - data={"investigationVersion": notebook.version, "title": "Renamed"}, + data={"notebookVersion": notebook.version, "title": "Renamed"}, format="json", ) @@ -318,7 +318,7 @@ def test_metadata_update_persists_and_stale_version_rolls_back(self) -> None: response = self.client.put( url, data={ - "investigationVersion": created["version"], + "notebookVersion": created["version"], "title": "After", "filters": {"environment": ["production"]}, "projectIds": [], @@ -332,7 +332,7 @@ def test_metadata_update_persists_and_stale_version_rolls_back(self) -> None: response = self.client.put( url, - data={"investigationVersion": created["version"], "title": "Stale write"}, + data={"notebookVersion": created["version"], "title": "Stale write"}, format="json", ) assert response.status_code == 409 @@ -352,14 +352,14 @@ def test_regular_member_can_edit_and_archive_another_users_notebook(self) -> Non response = self.client.put( url, - data={"investigationVersion": notebook.version, "title": "Updated by member"}, + data={"notebookVersion": notebook.version, "title": "Updated by member"}, format="json", ) assert response.status_code == 200, response.data response = self.client.delete( url, - data={"investigationVersion": response.data["version"]}, + data={"notebookVersion": response.data["version"]}, format="json", ) assert response.status_code == 204 @@ -391,7 +391,7 @@ def test_restoring_an_archived_notebook_cannot_also_change_projects(self) -> Non response = self.client.put( self.details_url(notebook), data={ - "investigationVersion": notebook.version, + "notebookVersion": notebook.version, "status": "active", "projectIds": [other_project.id], }, @@ -410,7 +410,7 @@ def test_restoring_an_archived_notebook_by_status_alone_is_allowed(self) -> None response = self.client.put( self.details_url(notebook), - data={"investigationVersion": notebook.version, "status": "active"}, + data={"notebookVersion": notebook.version, "status": "active"}, format="json", ) @@ -423,7 +423,7 @@ def test_archived_notebooks_reject_other_edits(self) -> None: response = self.client.put( self.details_url(notebook), - data={"investigationVersion": notebook.version, "title": "Renamed"}, + data={"notebookVersion": notebook.version, "title": "Renamed"}, format="json", ) @@ -460,7 +460,7 @@ def test_a_source_type_without_a_producer_can_be_renamed_and_archived(self) -> N renamed = self.client.put( self.details_url(notebook), - data={"investigationVersion": notebook.version, "title": "Renamed"}, + data={"notebookVersion": notebook.version, "title": "Renamed"}, format="json", ) assert renamed.status_code == 200, renamed.data @@ -469,7 +469,7 @@ def test_a_source_type_without_a_producer_can_be_renamed_and_archived(self) -> N archived = self.client.put( self.details_url(notebook), - data={"investigationVersion": notebook.version, "status": "archived"}, + data={"notebookVersion": notebook.version, "status": "archived"}, format="json", ) assert archived.status_code == 200, archived.data @@ -481,7 +481,7 @@ def test_archiving_a_source_notebook_via_put(self) -> None: response = self.client.put( self.details_url(second), - data={"investigationVersion": second.version, "status": "archived"}, + data={"notebookVersion": second.version, "status": "archived"}, format="json", ) @@ -497,7 +497,7 @@ def test_archiving_via_put_cannot_be_combined_with_other_changes(self) -> None: response = self.client.put( self.details_url(second), data={ - "investigationVersion": second.version, + "notebookVersion": second.version, "status": "archived", "title": "Renamed", }, diff --git a/tests/sentry/notebooks/endpoints/test_organization_notebook_index.py b/tests/sentry/notebooks/endpoints/test_organization_notebook_index.py index ccbc64f5b1d4..268b8eb686e2 100644 --- a/tests/sentry/notebooks/endpoints/test_organization_notebook_index.py +++ b/tests/sentry/notebooks/endpoints/test_organization_notebook_index.py @@ -17,6 +17,7 @@ from sentry.investigations.models import InvestigationOrchestrationStatus from sentry.models.organization import Organization from sentry.models.orgauthtoken import OrgAuthToken +from sentry.notebooks.endpoints.base import OrganizationNotebooksBaseEndpoint from sentry.notebooks.models import Notebook, NotebookStatus from sentry.notebooks.services.notebooks import NotebookSourceNotFound, NotebookValidationError from sentry.notebooks.services.producer import PRODUCERS @@ -41,6 +42,15 @@ FEATURE = "organizations:investigations" +def published_notebook_endpoints(endpoints: Any) -> Any: + return [ + (path, path_regex, method, callback) + for path, path_regex, method, callback in endpoints + if issubclass(callback.view_class, OrganizationNotebooksBaseEndpoint) + and callback.view_class.publish_status[method].is_published + ] + + @with_feature(FEATURE) class OrganizationNotebookIndexTest(APITestCase): def setUp(self) -> None: @@ -355,7 +365,7 @@ def test_source_lineage_lists_latest_and_keeps_historical_detail(self) -> None: assert ( self.client.delete( second_url, - data={"investigationVersion": second.version}, + data={"notebookVersion": second.version}, format="json", ).status_code == 204 @@ -563,9 +573,16 @@ def test_trigger_me_requires_a_user(self) -> None: assert response.status_code == 400, response.data - def test_private_schema_matches_frontend_responses(self) -> None: + def test_schemas_match_frontend_responses(self) -> None: with patched_settings(PRIVATE_SCHEMA_SETTINGS): - schema = CustomGenerator().get_schema(public=True) + private_schema = CustomGenerator().get_schema(public=True) + with patched_settings( + { + **PRIVATE_SCHEMA_SETTINGS, + "PREPROCESSING_HOOKS": [f"{__name__}.published_notebook_endpoints"], + } + ): + public_schema = CustomGenerator().get_schema(public=True) base = "/api/0/organizations/{organization_id_or_slug}/notebooks/" detail_path = base + "{notebook_id}/" orchestration_path = ( @@ -575,6 +592,9 @@ def test_private_schema_matches_frontend_responses(self) -> None: def validate_response(response: Response, method: str, path: str) -> None: assert 200 <= response.status_code < 300, response.data + schema = ( + private_schema if method in private_schema["paths"].get(path, {}) else public_schema + ) response_schema = schema["paths"][path][method]["responses"][str(response.status_code)][ "content" ]["application/json"]["schema"] @@ -593,7 +613,7 @@ def validate_response(response: Response, method: str, path: str) -> None: block = self.client.post( detail_url + "blocks/", data={ - "investigationVersion": created.data["version"], + "notebookVersion": created.data["version"], "kind": "text", "content": "Initial observations", }, diff --git a/tests/sentry/notebooks/endpoints/validators/test_base.py b/tests/sentry/notebooks/endpoints/validators/test_base.py index 3f24dda1d313..c4ebae217e9a 100644 --- a/tests/sentry/notebooks/endpoints/validators/test_base.py +++ b/tests/sentry/notebooks/endpoints/validators/test_base.py @@ -73,23 +73,11 @@ def test_takes_notebook_version(self) -> None: assert data == {"notebook_version": 3} - def test_takes_the_old_investigation_version_name(self) -> None: - data = assert_valid(NotebookVersionValidator(data={"investigationVersion": 3})) - - assert data == {"notebook_version": 3} - - def test_takes_both_names_with_the_same_value(self) -> None: - data = assert_valid( - NotebookVersionValidator(data={"notebookVersion": 3, "investigationVersion": 3}) - ) - - assert data == {"notebook_version": 3} - - def test_rejects_both_names_with_different_values(self) -> None: - validator = NotebookVersionValidator(data={"notebookVersion": 3, "investigationVersion": 2}) + def test_rejects_the_investigation_version_name(self) -> None: + validator = NotebookVersionValidator(data={"notebookVersion": 3, "investigationVersion": 3}) assert not validator.is_valid() - assert "notebookVersion" in validator.errors + assert "investigationVersion" in validator.errors def test_requires_a_version(self) -> None: validator = NotebookVersionValidator(data={}) diff --git a/tests/sentry/notebooks/endpoints/validators/test_block.py b/tests/sentry/notebooks/endpoints/validators/test_block.py index df9eb981876f..d6b1107ca51b 100644 --- a/tests/sentry/notebooks/endpoints/validators/test_block.py +++ b/tests/sentry/notebooks/endpoints/validators/test_block.py @@ -117,31 +117,31 @@ def test_versioned_table_display_does_not_require_axes(self) -> None: class TestBlockCreateValidator: def test_defaults_the_display_for_a_text_block(self) -> None: - data = assert_valid(BlockCreateValidator(data={"investigationVersion": 1, "kind": "text"})) + data = assert_valid(BlockCreateValidator(data={"notebookVersion": 1, "kind": "text"})) assert data["display"] == {"type": "markdown"} def test_defaults_the_display_for_a_query_block(self) -> None: - data = assert_valid(BlockCreateValidator(data={"investigationVersion": 1, "kind": "query"})) + data = assert_valid(BlockCreateValidator(data={"notebookVersion": 1, "kind": "query"})) assert data["display"] == {"version": 1, "type": "table", "defaultView": "table"} def test_rejects_an_unknown_kind(self) -> None: - validator = BlockCreateValidator(data={"investigationVersion": 1, "kind": "diagram"}) + validator = BlockCreateValidator(data={"notebookVersion": 1, "kind": "diagram"}) assert not validator.is_valid() assert "kind" in validator.errors def test_rejects_a_non_object_display(self) -> None: validator = BlockCreateValidator( - data={"investigationVersion": 1, "kind": "text", "display": "markdown"} + data={"notebookVersion": 1, "kind": "text", "display": "markdown"} ) assert not validator.is_valid() def test_rejects_a_non_object_config(self) -> None: validator = BlockCreateValidator( - data={"investigationVersion": 1, "kind": "text", "config": [1, 2]} + data={"notebookVersion": 1, "kind": "text", "config": [1, 2]} ) assert not validator.is_valid() @@ -152,21 +152,19 @@ def test_requires_an_notebook_version(self) -> None: assert not validator.is_valid() assert "notebookVersion" in validator.errors - def test_takes_the_notebook_version_under_either_name(self) -> None: - for data in ({"notebookVersion": 2}, {"investigationVersion": 2}): - validator = BlockCreateValidator(data={"kind": "text", **data}) - - assert validator.is_valid(), validator.errors - assert validator.validated_data["notebook_version"] == 2 - - def test_a_notebook_version_under_either_name_excludes_a_producer_key(self) -> None: - for name in ("notebookVersion", "investigationVersion"): - validator = BlockCreateValidator( - data={name: 1, "kind": "text", "key": "summary", "revision": 1, "content": "a"} - ) + def test_a_notebook_version_excludes_a_producer_key(self) -> None: + validator = BlockCreateValidator( + data={ + "notebookVersion": 1, + "kind": "text", + "key": "summary", + "revision": 1, + "content": "a", + } + ) - assert not validator.is_valid() - assert "detail" in validator.errors + assert not validator.is_valid() + assert "detail" in validator.errors class TestBlockUpdateValidator: @@ -175,7 +173,7 @@ class FakeBlock: kind = NotebookBlockKind.TEXT validator = BlockUpdateValidator( - data={"investigationVersion": 1, "version": 1, "display": {"type": "markdown"}}, + data={"notebookVersion": 1, "version": 1, "display": {"type": "markdown"}}, context={"block": FakeBlock()}, ) @@ -187,7 +185,7 @@ class FakeBlock: validator = BlockUpdateValidator( data={ - "investigationVersion": 1, + "notebookVersion": 1, "version": 1, "display": {"type": "line", "xAxis": "time", "yAxes": ["count()"]}, }, @@ -197,13 +195,13 @@ class FakeBlock: assert not validator.is_valid() def test_requires_both_versions(self) -> None: - validator = BlockUpdateValidator(data={"investigationVersion": 1}) + validator = BlockUpdateValidator(data={"notebookVersion": 1}) assert not validator.is_valid() assert "version" in validator.errors def test_requires_a_block_field(self) -> None: - validator = BlockUpdateValidator(data={"investigationVersion": 1, "version": 1}) + validator = BlockUpdateValidator(data={"notebookVersion": 1, "version": 1}) assert not validator.is_valid() assert "detail" in validator.errors @@ -211,14 +209,12 @@ def test_requires_a_block_field(self) -> None: class TestBlockOrderValidator: def test_accepts_block_ids(self) -> None: - data = assert_valid( - BlockOrderValidator(data={"investigationVersion": 1, "blockIds": [3, 1, 2]}) - ) + data = assert_valid(BlockOrderValidator(data={"notebookVersion": 1, "blockIds": [3, 1, 2]})) assert data["block_ids"] == [3, 1, 2] def test_rejects_non_positive_ids(self) -> None: - validator = BlockOrderValidator(data={"investigationVersion": 1, "blockIds": [0]}) + validator = BlockOrderValidator(data={"notebookVersion": 1, "blockIds": [0]}) assert not validator.is_valid() @@ -231,7 +227,7 @@ def test_reports_display_failures_under_the_display_field(self) -> None: """ validator = BlockCreateValidator( data={ - "investigationVersion": 1, + "notebookVersion": 1, "kind": "text", "display": {"type": "line", "xAxis": "time", "yAxes": ["count()"]}, } @@ -244,7 +240,7 @@ def test_reports_display_failures_under_the_display_field(self) -> None: def test_block_update_requires_the_block_in_context(self) -> None: """Without the guard this raised an uncaught KeyError, i.e. a 500.""" validator = BlockUpdateValidator( - data={"investigationVersion": 1, "version": 1, "display": {"type": "markdown"}} + data={"notebookVersion": 1, "version": 1, "display": {"type": "markdown"}} ) assert not validator.is_valid() @@ -254,7 +250,7 @@ def test_block_update_requires_the_block_in_context(self) -> None: def test_rejects_non_boolean_display_flags(self, flag: str) -> None: validator = BlockCreateValidator( data={ - "investigationVersion": 1, + "notebookVersion": 1, "kind": "query", "display": { "version": 1, @@ -273,7 +269,7 @@ def test_rejects_non_boolean_display_flags(self, flag: str) -> None: def test_accepts_boolean_display_flags(self, flag: str) -> None: validator = BlockCreateValidator( data={ - "investigationVersion": 1, + "notebookVersion": 1, "kind": "query", "display": { "version": 1, diff --git a/tests/sentry/notebooks/endpoints/validators/test_notebook.py b/tests/sentry/notebooks/endpoints/validators/test_notebook.py index 6cd865f4c126..7dc8e2f96c3f 100644 --- a/tests/sentry/notebooks/endpoints/validators/test_notebook.py +++ b/tests/sentry/notebooks/endpoints/validators/test_notebook.py @@ -101,19 +101,19 @@ def test_rejects_project_ids_with_a_source(self) -> None: class TestNotebookUpdateValidator: def test_accepts_a_status_change(self) -> None: data = assert_valid( - NotebookUpdateValidator(data={"investigationVersion": 2, "status": "archived"}) + NotebookUpdateValidator(data={"notebookVersion": 2, "status": "archived"}) ) assert data["status"] == "archived" def test_rejects_an_unknown_status(self) -> None: - validator = NotebookUpdateValidator(data={"investigationVersion": 2, "status": "deleted"}) + validator = NotebookUpdateValidator(data={"notebookVersion": 2, "status": "deleted"}) assert not validator.is_valid() assert "status" in validator.errors def test_rejects_duplicate_project_ids(self) -> None: - validator = NotebookUpdateValidator(data={"investigationVersion": 2, "projectIds": [4, 4]}) + validator = NotebookUpdateValidator(data={"notebookVersion": 2, "projectIds": [4, 4]}) assert not validator.is_valid() assert "projectIds" in validator.errors diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index 66dd609426e6..f39847134b98 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -40,6 +40,7 @@ from sentry.models.groupshare import GroupShare from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember +from sentry.notebooks.access import NOTEBOOKS_FEATURE from sentry.organizations.services.organization import organization_service from sentry.replays.lib.storage import FilestoreBlob, RecordingSegmentStorageMeta from sentry.replays.testutils import mock_replay, mock_replay_viewed @@ -1519,6 +1520,14 @@ def _resource(self, name: str) -> Any: self.project.update_option("sentry:symbol_sources", json.dumps([resource])) elif name == "workflow": resource = self.create_workflow(organization=self.org) + elif name == "notebook": + resource = self.create_notebook( + organization=self.org, created_by=self.owner, title="Permission matrix" + ) + elif name == "notebook_block": + resource = self.create_notebook_block( + notebook=self._resource("notebook"), position=0, kind="text" + ) else: raise AssertionError(f"unknown matrix resource {name!r}") @@ -1630,6 +1639,10 @@ def _path_value( return str(self._resource("service_hook").id) if placeholder == "workflow_id": return str(self._resource("workflow").id) + if placeholder == "notebook_id": + return str(self._resource("notebook").id) + if placeholder == "block_id": + return str(self._resource("notebook_block").id) if placeholder == "key": return "environment" if placeholder == "filter_id": @@ -1826,6 +1839,8 @@ def _feature_flags( flags["projects:custom-inbound-filters"] = True if "Replay" in endpoint.endpoint_name: flags["organizations:session-replay"] = True + if endpoint.endpoint_name.startswith("OrganizationNotebook"): + flags[NOTEBOOKS_FEATURE] = True return flags def _mint_agent_token( @@ -2206,6 +2221,28 @@ def _mutation_payload(self, endpoint: PublicMutationEndpoint) -> dict[str, Any]: "config": {"detectionType": AlertRuleDetectionType.STATIC.value}, "workflowIds": [self._resource("workflow").id], }, + ("OrganizationNotebookIndexEndpoint", "POST"): { + "title": "Permission matrix investigation", + "projectIds": [self.project.id], + }, + ("OrganizationNotebookBlockIndexEndpoint", "POST"): { + "notebookVersion": self._resource("notebook").version, + "kind": "text", + "content": "Permission matrix block", + }, + ("OrganizationNotebookBlockOrderEndpoint", "PUT"): { + "notebookVersion": self._resource("notebook").version, + "blockIds": [self._resource("notebook_block").id], + }, + ("OrganizationNotebookBlockDetailsEndpoint", "PUT"): { + "notebookVersion": self._resource("notebook").version, + "version": self._resource("notebook_block").version, + "content": "Updated permission matrix block", + }, + ("OrganizationNotebookBlockDetailsEndpoint", "DELETE"): { + "notebookVersion": self._resource("notebook").version, + "version": self._resource("notebook_block").version, + }, ("OrganizationMonitorIndexEndpoint", "POST"): { "project": self.project.slug, "name": "Permission Matrix Monitor",