diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a86c9a1af6ce39..990895d9266414 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -214,6 +214,9 @@ pnpm-lock.yaml @getsentry/owners-js-de /src/sentry/tasks/post_process.py @getsentry/issue-detection-backend ## Issue Detection Lower Priority +## Event components fallback so more specific rules can take precedence +/static/app/components/events/ @getsentry/issue-workflow + ## Hybrid Cloud /src/sentry/silo/ @getsentry/hybrid-cloud /src/sentry/hybridcloud/ @getsentry/hybrid-cloud @@ -347,7 +350,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing /static/app/utils/performance/ @getsentry/data-browsing -/static/app/components/events/groupingInfo @getsentry/data-browsing /static/app/components/events/interfaces/spans/ @getsentry/data-browsing /static/app/components/events/viewHierarchy/* @getsentry/data-browsing /static/app/components/searchQueryBuilder/ @getsentry/data-browsing @@ -439,7 +441,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/analyticsArea.spec.tsx @getsentry/app-frontend /static/app/components/analyticsArea.tsx @getsentry/app-frontend /static/app/components/loading/ @getsentry/app-frontend -/static/app/components/events/interfaces/ @getsentry/app-frontend /static/app/components/forms/ @getsentry/app-frontend /static/app/components/featureShowcase.mdx @getsentry/app-frontend /static/app/components/featureShowcase.spec.tsx @getsentry/app-frontend @@ -691,9 +692,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/tasks/unmerge.py @getsentry/issue-detection-backend /src/sentry/tasks/weekly_escalating_forecast.py @getsentry/issue-detection-backend /src/sentry/tasks/llm_issue_detection/ @getsentry/issue-detection-backend -/static/app/components/events/contexts/ @getsentry/issue-workflow -/static/app/components/events/eventTags/ @getsentry/issue-workflow -/static/app/components/events/highlights/ @getsentry/issue-workflow /static/app/components/issues/ @getsentry/issue-workflow /static/app/components/stackTrace/ @getsentry/issue-workflow /static/app/components/stream/supergroups/ @getsentry/issue-detection-frontend @@ -704,8 +702,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/issueDetails/ @getsentry/issue-workflow /static/app/views/nav/secondary/sections/issues/ @getsentry/issue-workflow /static/app/views/sharedGroupDetails/ @getsentry/issue-workflow -/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx @getsentry/issue-detection-frontend -/static/app/components/events/interfaces/crashContent/exception/actionableItems.tsx @getsentry/issue-workflow /tests/sentry/deletions/test_group.py @getsentry/issue-detection-backend /tests/sentry/event_manager/ @getsentry/issue-detection-backend /tests/sentry/grouping/ @getsentry/issue-detection-backend diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 7fa535924882f1..c1eeb7126c0afe 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -97,9 +97,6 @@ "/api/0/organizations/{organization_id_or_slug}/repos/": { "$ref": "paths/organizations/repos.json" }, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/files/dsyms/": { - "$ref": "paths/projects/dsyms.json" - }, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/users/": { "$ref": "paths/projects/users.json" }, diff --git a/api-docs/paths/projects/dsyms.json b/api-docs/paths/projects/dsyms.json deleted file mode 100644 index 8d3bd158b564a1..00000000000000 --- a/api-docs/paths/projects/dsyms.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "get": { - "tags": ["Projects"], - "description": "Retrieve a list of debug information files for a given project.", - "operationId": "List a Project's Debug Information Files", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the file belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to list the DIFs of.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": {} - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:read"] - } - ] - }, - "post": { - "tags": ["Projects"], - "description": "Upload a new debug information file for the given release.\n\nUnlike other API requests, files must be uploaded using the\ntraditional multipart/form-data content-type.\n\nRequests to this endpoint should use the region-specific domain eg. `us.sentry.io` or `de.sentry.io`.\n\nThe file uploaded is a zip archive of an Apple .dSYM folder which\ncontains the individual debug images. Uploading through this endpoint\nwill create different files for the contained images.", - "operationId": "Upload a New File", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the project belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to upload a file to.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": ["file"], - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary", - "description": "The multipart encoded file." - } - } - }, - "example": { - "file": "debug.zip" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Success", - "content": { - "application/json": {} - } - }, - "400": { - "description": "Bad Input" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:write"] - } - ], - "servers": [{ "url": "https://{region}.sentry.io" }] - }, - "delete": { - "tags": ["Projects"], - "description": "Delete a debug information file for a given project.", - "operationId": "Delete a Specific Project's Debug Information File", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the file belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to delete the DIF.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "query", - "description": "The ID of the DIF to delete.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Success" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:admin", "project:releases"] - } - ] - } -} diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index d02269d4e3e0d8..4ad31dbc6ebbbf 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0017_drop_old_fk_columns -sentry: 1101_remove_email_model_pending +sentry: 1102_activity_project_type_index social_auth: 0003_social_auth_json_field diff --git a/pyproject.toml b/pyproject.toml index d134a0e40b7eef..dcd98a19c7b804 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ dependencies = [ "google-cloud-storage>=2.18.0", "google-cloud-storage-transfer>=1.17.0", "google-crc32c>=1.6.0", + # Linear-time regex engine (RE2). Used for client-supplied snapshot image-name + # patterns: no catastrophic backtracking (ReDoS-safe), no backreferences/lookaround. + "google-re2>=1.1.20251105", "googleapis-common-protos>=1.63.2", "granian[pname,reload,uvloop]>=2.7", "grpc-google-iam-v1>=0.13.1", @@ -96,7 +99,7 @@ dependencies = [ "sentry-protos>=0.17.0", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.27", - "sentry-scm==0.16.0", + "sentry-scm==0.20.0", "sentry-sdk[http2]>=2.59.0", "sentry-usage-accountant>=0.0.10", # remove once there are no unmarked transitive dependencies on setuptools @@ -108,7 +111,7 @@ dependencies = [ "statsd>=3.3.0", "structlog>=22.1.0", "symbolic>=12.14.1", - "taskbroker-client>=0.16.0,<1", + "taskbroker-client>=0.17.0", "tiktoken>=0.8.0", "tokenizers>=0.22.0", "tldextract>=5.1.2", @@ -376,6 +379,7 @@ module = [ "pymemcache.*", "P4", "rb.*", + "re2.*", "statsd.*", "tokenizers.*", "u2flib_server.model.*", @@ -390,7 +394,6 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = [ "sentry.api.endpoints.organization_releases", - "sentry.db.postgres.base", "sentry.release_health.metrics_sessions_v2", "sentry.search.events.builder.errors", "sentry.search.events.builder.metrics", diff --git a/src/sentry/analytics/events/resolution_attribution.py b/src/sentry/analytics/events/resolution_attribution.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index bc923eac46bd0d..a452e6fddbfac4 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -10,6 +10,7 @@ from django.db import IntegrityError, router from django.db.models import Case, Exists, F, IntegerField, Q, QuerySet, Value, When from django.http import Http404, HttpResponse, StreamingHttpResponse +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -27,7 +28,12 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.api.serializers.models.debug_file import DebugFileSerializerResponse from sentry.api.utils import to_valid_int_id +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.dsym_examples import DebugFileExamples +from sentry.apidocs.parameters import CursorQueryParam, GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.access import Access from sentry.auth.superuser import is_active_superuser from sentry.auth.system import is_system_auth @@ -198,12 +204,47 @@ def get(self, request: Request, project: Project) -> Response: return Response({"releases": releases}) +DSYM_QUERY_PARAM = OpenApiParameter( + name="query", + location="query", + required=False, + type=str, + description="Substring filter matched against object name, debug ID, code ID, CPU name, and file headers.", +) + +DSYM_DEBUG_ID_PARAM = OpenApiParameter( + name="debug_id", + location="query", + required=False, + type=str, + description="Filter results to debug information files matching the given debug ID.", +) + +DSYM_CODE_ID_PARAM = OpenApiParameter( + name="code_id", + location="query", + required=False, + type=str, + description="Filter results to debug information files matching the given code ID.", +) + +DSYM_FILE_FORMATS_PARAM = OpenApiParameter( + name="file_formats", + location="query", + required=False, + many=True, + type=str, + description="Restrict results to one or more file formats.", +) + + +@extend_schema(tags=["Projects"]) @cell_silo_endpoint class DebugFilesEndpoint(ProjectEndpoint): owner = ApiOwner.OWNERS_INGEST publish_status = { "DELETE": ApiPublishStatus.PRIVATE, - "GET": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PUBLIC, "POST": ApiPublishStatus.PRIVATE, } permission_classes = (ProjectReleasePermission,) @@ -241,21 +282,30 @@ def download(self, debug_file_id, project: Project): except OSError: raise Http404 + @extend_schema( + operation_id="List a Project's Debug Information Files", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + DSYM_QUERY_PARAM, + DSYM_DEBUG_ID_PARAM, + DSYM_CODE_ID_PARAM, + DSYM_FILE_FORMATS_PARAM, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "ListProjectDebugFilesResponse", list[DebugFileSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=DebugFileExamples.LIST_PROJECT_DEBUG_FILES, + ) def get(self, request: Request, project: Project) -> Response: """ - List a Project's Debug Information Files - ```````````````````````````````````````` - Retrieve a list of debug information files for a given project. - - :pparam string organization_id_or_slug: the id or slug of the organization the - file belongs to. - :pparam string project_id_or_slug: the id or slug of the project to list the - DIFs of. - :qparam string query: If set, this parameter is used to locate DIFs with. - :qparam string id: If set, the specified DIF will be sent in the response. - :qparam string file_formats: If set, only DIFs with these formats will be returned. - :auth: required """ download_requested = request.GET.get("id") is not None if download_requested and has_download_permission(request, project): diff --git a/src/sentry/api/helpers/group_index/__init__.py b/src/sentry/api/helpers/group_index/__init__.py index cf19a6371caca8..9d60fd94e74fa3 100644 --- a/src/sentry/api/helpers/group_index/__init__.py +++ b/src/sentry/api/helpers/group_index/__init__.py @@ -24,11 +24,13 @@ "BULK_MUTATION_LIMIT", "SEARCH_MAX_HITS", "delete_group_list", + "get_group_list", "update_groups", ) from .delete import * # NOQA from .delete import delete_group_list from .index import * # NOQA +from .lookup import get_group_list from .update import * # NOQA from .update import update_groups diff --git a/src/sentry/api/helpers/group_index/lookup.py b/src/sentry/api/helpers/group_index/lookup.py new file mode 100644 index 00000000000000..e7111fc2b58878 --- /dev/null +++ b/src/sentry/api/helpers/group_index/lookup.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from sentry.models.group import Group +from sentry.models.project import Project + + +def get_group_list( + organization_id: int, + projects: Sequence[Project], + group_ids: Sequence[int | str], +) -> list[Group]: + """ + Gets group list based on provided filters. + + Args: + organization_id: ID of the organization + projects: Sequence of projects to filter groups by + group_ids: Sequence of specific group IDs to fetch + + Returns: List of Group objects filtered to only valid groups in the org/projects + """ + groups: list[Group] = [] + # Convert all group IDs to integers and filter out any non-integer values + group_ids_int = [int(gid) for gid in group_ids if str(gid).isdigit()] + if group_ids_int: + return list( + Group.objects.filter( + project__organization_id=organization_id, project__in=projects, id__in=group_ids_int + ).select_related("project") + ) + else: + project_ids = {p.id for p in projects} + for group_id in group_ids: + if isinstance(group_id, str): + try: + group = Group.objects.by_qualified_short_id(organization_id, group_id) + except Group.DoesNotExist: + continue + if group.project_id in project_ids: + groups.append(group) + + return groups diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 9d559a150da8bd..2b2c0787d4ae0c 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -61,6 +61,7 @@ from sentry.utils import metrics from . import ACTIVITIES_COUNT, BULK_MUTATION_LIMIT, SearchFunction, delete_group_list +from .lookup import get_group_list from .validators import GroupValidator, ValidationError logger = logging.getLogger(__name__) @@ -325,44 +326,6 @@ def validate_request( return serializer -def get_group_list( - organization_id: int, - projects: Sequence[Project], - group_ids: Sequence[int | str], -) -> list[Group]: - """ - Gets group list based on provided filters. - - Args: - organization_id: ID of the organization - projects: Sequence of projects to filter groups by - group_ids: Sequence of specific group IDs to fetch - - Returns: List of Group objects filtered to only valid groups in the org/projects - """ - groups: list[Group] = [] - # Convert all group IDs to integers and filter out any non-integer values - group_ids_int = [int(gid) for gid in group_ids if str(gid).isdigit()] - if group_ids_int: - return list( - Group.objects.filter( - project__organization_id=organization_id, project__in=projects, id__in=group_ids_int - ).select_related("project") - ) - else: - project_ids = {p.id for p in projects} - for group_id in group_ids: - if isinstance(group_id, str): - try: - group = Group.objects.by_qualified_short_id(organization_id, group_id) - except Group.DoesNotExist: - continue - if group.project_id in project_ids: - groups.append(group) - - return groups - - def handle_resolve_in_release( status: str, status_details: Mapping[str, Any], diff --git a/src/sentry/api/serializers/models/commit.py b/src/sentry/api/serializers/models/commit.py index 40b869a893fad7..e54d754188a3c4 100644 --- a/src/sentry/api/serializers/models/commit.py +++ b/src/sentry/api/serializers/models/commit.py @@ -5,8 +5,9 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.pullrequest import PullRequestSerializerResponse -from sentry.api.serializers.models.release import Author, get_users_for_authors +from sentry.api.serializers.models.release import get_users_for_authors from sentry.api.serializers.models.repository import RepositorySerializerResponse +from sentry.api.serializers.release_details_types import Author from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest diff --git a/src/sentry/api/serializers/models/eventattachment.py b/src/sentry/api/serializers/models/eventattachment.py index eccfb68e8f495f..8756285997a158 100644 --- a/src/sentry/api/serializers/models/eventattachment.py +++ b/src/sentry/api/serializers/models/eventattachment.py @@ -1,13 +1,27 @@ import mimetypes +from datetime import datetime +from typing import TypedDict from sentry.api.serializers import Serializer, register from sentry.models.eventattachment import EventAttachment from sentry.models.files.file import File +class EventAttachmentSerializerResponse(TypedDict): + id: str + event_id: str + type: str + name: str + mimetype: str | None + dateCreated: datetime + size: int + headers: dict[str, str | None] + sha1: str | None + + @register(EventAttachment) class EventAttachmentSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize(self, obj, attrs, user, **kwargs) -> EventAttachmentSerializerResponse: content_type = obj.content_type size = obj.size or 0 sha1 = obj.sha1 diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index d5c28e1df77b6a..2c495a18009039 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -24,7 +24,6 @@ TeamRoleSerializerResponse, ) from sentry.api.serializers.models.team import TeamSerializerResponse -from sentry.api.serializers.types import SerializedAvatarFields from sentry.api.utils import generate_locality_url from sentry.auth.access import Access from sentry.auth.services.auth import RpcOrganizationAuthConfig, auth_service @@ -86,6 +85,7 @@ from sentry.replays.models import OrganizationMemberReplayAccess from sentry.seer.autofix.utils import get_valid_automated_run_stopping_points from sentry.types.cell import get_locality_name_for_cell +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index 926c7f38692541..24d9d1a20b3de2 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -17,7 +17,6 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.plugin import PluginSerializer from sentry.api.serializers.models.team import get_org_roles -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.access import Access from sentry.auth.superuser import is_active_superuser @@ -49,6 +48,7 @@ from sentry.services.eventstore.models import DEFAULT_SUBJECT_TEMPLATE from sentry.snuba import discover from sentry.tempest.utils import has_tempest_access +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser diff --git a/src/sentry/api/serializers/models/pullrequest.py b/src/sentry/api/serializers/models/pullrequest.py index 0af9f0ef616399..a4d9ed0c78d295 100644 --- a/src/sentry/api/serializers/models/pullrequest.py +++ b/src/sentry/api/serializers/models/pullrequest.py @@ -2,8 +2,9 @@ from typing import TypedDict from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.models.release import Author, get_users_for_authors +from sentry.api.serializers.models.release import get_users_for_authors from sentry.api.serializers.models.repository import RepositorySerializerResponse +from sentry.api.serializers.release_details_types import Author from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py index 9cded35fc887ef..13687a0277a55b 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -3,7 +3,7 @@ import datetime from collections import defaultdict from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict, Union +from typing import Any, NotRequired, TypedDict from django.contrib.auth.models import AnonymousUser from django.core.cache import cache @@ -11,7 +11,7 @@ from sentry import release_health, tagstore from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.release_details_types import VersionInfo +from sentry.api.serializers.release_details_types import Author, NonMappableUser, VersionInfo from sentry.api.serializers.types import ( GroupEventReleaseSerializerResponse, ReleaseSerializerResponse, @@ -226,14 +226,6 @@ def _user_to_author_cache_key(organization_id: int, author: CommitAuthor) -> str return f"get_users_for_authors:{organization_id}:{author_hash}" -class NonMappableUser(TypedDict): - name: str | None - email: str - - -Author = Union[UserSerializerResponse, NonMappableUser] - - def get_author_users_by_external_actors( authors: list[CommitAuthor], organization_id: int ) -> tuple[dict[CommitAuthor, str], list[CommitAuthor]]: diff --git a/src/sentry/api/serializers/models/team.py b/src/sentry/api/serializers/models/team.py index 50125537f5f6b9..a2903f96f83095 100644 --- a/src/sentry/api/serializers/models/team.py +++ b/src/sentry/api/serializers/models/team.py @@ -11,7 +11,6 @@ from sentry import roles from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.access import ( Access, @@ -28,6 +27,7 @@ from sentry.models.projectteam import ProjectTeam from sentry.models.team import Team from sentry.roles import organization_roles, team_roles +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.utils.query import RangeQuerySetWrapper diff --git a/src/sentry/api/serializers/release_details_types.py b/src/sentry/api/serializers/release_details_types.py index fd1350f647c5fc..a69531b92a8da5 100644 --- a/src/sentry/api/serializers/release_details_types.py +++ b/src/sentry/api/serializers/release_details_types.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Any, TypedDict +from sentry.users.api.serializers.user import UserSerializerResponse + class VersionInfoOptional(TypedDict, total=False): description: str @@ -24,28 +26,12 @@ class LastDeploy(LastDeployOptional): name: str -class AuthorOptional(TypedDict, total=False): - lastLogin: str - has2fa: bool - lastActive: str - isSuperuser: bool - isStaff: bool - experiments: dict[str, str | int | float | bool | None] - emails: list[dict[str, int | str | bool]] - avatar: dict[str, str | None] +class NonMappableUser(TypedDict): + name: str | None + email: str -class Author(AuthorOptional): - id: int - name: str - username: str - email: str - avatarUrl: str - isActive: bool - isSuspended: bool - hasPasswordAuth: bool - isManaged: bool - dateJoined: str +Author = UserSerializerResponse | NonMappableUser class HealthDataOptional(TypedDict, total=False): diff --git a/src/sentry/api/serializers/types.py b/src/sentry/api/serializers/types.py index 32b2274e70bfa3..d8bd2fc70c2a51 100644 --- a/src/sentry/api/serializers/types.py +++ b/src/sentry/api/serializers/types.py @@ -4,12 +4,6 @@ from sentry.api.serializers.release_details_types import Author, LastDeploy, Project, VersionInfo -class SerializedAvatarFields(TypedDict, total=False): - avatarType: str - avatarUuid: str | None - avatarUrl: str | None - - # Reponse type for OrganizationReleaseDetailsEndpoint class ReleaseSerializerResponseOptional(TypedDict, total=False): ref: str | None diff --git a/src/sentry/apidocs/_check_response_annotation_matches_schema.py b/src/sentry/apidocs/_check_response_annotation_matches_schema.py index 0fe4bb93ae9c15..b4d0c74e121e5d 100644 --- a/src/sentry/apidocs/_check_response_annotation_matches_schema.py +++ b/src/sentry/apidocs/_check_response_annotation_matches_schema.py @@ -1,15 +1,31 @@ -"""Lint: assert decorator-T equals annotation-T for typed endpoint responses. +"""Lint: assert decorator's declared `T`s match the method's `Response[T]` annotation. For each method decorated with - @extend_schema(responses={N: inline_sentry_response_serializer("Name", T_decl)}) + @extend_schema(responses={N: inline_sentry_response_serializer("Name", T_decl), ...}) and annotated -> Response[T_annot] -this linter asserts that `T_decl == T_annot` (AST-level name equality) for the -2xx-status entry. mypy enforces body-vs-annotation; this linter enforces -decorator-vs-annotation. Together they close the schema/runtime drift gap. +or, when the endpoint exposes multiple typed response shapes, + -> Response[T_success] | Response[T_error_400] | ... -Plain `-> Response` annotations are skipped (unmigrated endpoints). Decorator -entries that are canned constants (e.g. `RESPONSE_BAD_REQUEST`) are skipped. +this linter asserts that the *set* of `T`s declared by +`inline_sentry_response_serializer(...)` entries in the decorator equals the set of +`T`s in the annotation's union arms. Names are compared verbatim — no +normalization, no convention-based pairing. mypy enforces body-vs-annotation; +this linter enforces decorator-vs-annotation. + +The status-code-to-`T` linkage is intentionally not enforced — mypy can't model +it, and broad `except APIException` catches would lose the linkage anyway. + +Skipped silently (no diagnostic): + - Plain `-> Response` annotations (the unmigrated state). + - Methods with no `@extend_schema` decorator. + - Decorator entries that aren't `inline_sentry_response_serializer(...)` — + direct serializer-class references (`MonitorSerializer`), + `OpenApiResponse(...)` wrappers, `RESPONSE_*` canned constants, `None`, + raw `dict`, etc. These don't carry a statically-resolvable `T` for the + linter to compare; either migrate them to + `inline_sentry_response_serializer(...)` or wait for the generic + `Serializer[T]` refactor. Invoke as: python -m sentry.apidocs._check_response_annotation_matches_schema [paths...] @@ -38,8 +54,6 @@ "src/sentry/uptime/endpoints", ) -SUCCESS_STATUSES = frozenset(range(200, 300)) - @dataclass(frozen=True) class Mismatch: @@ -47,14 +61,15 @@ class Mismatch: line: int cls: str method: str - status: int - decl: str - annot: str + decl: frozenset[str] + annot: frozenset[str] def __str__(self) -> str: + decl = ", ".join(sorted(self.decl)) or "" + annot = ", ".join(sorted(self.annot)) or "" return ( - f"{self.path}:{self.line} {self.cls}.{self.method} status={self.status}: " - f"decorator declares `{self.decl}`, annotation declares `{self.annot}`" + f"{self.path}:{self.line} {self.cls}.{self.method}: " + f"decorator declares {{{decl}}}, annotation declares {{{annot}}}" ) @@ -69,28 +84,51 @@ def _name_of(node: ast.expr) -> str: return ast.unparse(node) -def _extract_decorator_responses( - decorator: ast.expr, -) -> dict[int, ast.expr]: - """For `@extend_schema(responses={N: inline_sentry_response_serializer("X", T)})`, - return {N: T_expr} for entries that use `inline_sentry_response_serializer`. - Skips canned constants and non-2xx entries that don't carry a body schema. +def _is_response_subscript(node: ast.expr) -> ast.expr | None: + """If `node` is `Response[T]` (or `rest_framework.response.Response[T]`), + return the `T` expression. Otherwise return None.""" + if not isinstance(node, ast.Subscript): + return None + val = node.value + if isinstance(val, ast.Name) and val.id == "Response": + return node.slice + if isinstance(val, ast.Attribute) and val.attr == "Response": + return node.slice + return None + + +def _extract_decorator_response_Ts(decorator: ast.expr) -> list[ast.expr]: + """From `@extend_schema(responses={N: inline_sentry_response_serializer("X", T), ...})`, + return every `T` expression. Only this explicit form is recognized — it's the + one shape where the linter has a real handle on what the typed schema is. + + Skipped silently (no extractable T): + - Direct serializer-class references (e.g. `MonitorSerializer`). These + carry a typed output by sentry convention but no statically-resolvable + link to a TypedDict. Either migrate the entry to + `inline_sentry_response_serializer(...)`, or wait for the generic- + `Serializer[T]` refactor to land — both give the linter a real handle. + - Canned `RESPONSE_*` constants (no T — error responses, untyped body). + - `OpenApiResponse(...)` wrappers. + - `None`, raw `dict`, etc. + + Status code is intentionally ignored — see module docstring. """ if not isinstance(decorator, ast.Call): - return {} + return [] func = decorator.func - if not (isinstance(func, ast.Name) and func.id == "extend_schema"): - if not (isinstance(func, ast.Attribute) and func.attr == "extend_schema"): - return {} + if not ( + (isinstance(func, ast.Name) and func.id == "extend_schema") + or (isinstance(func, ast.Attribute) and func.attr == "extend_schema") + ): + return [] responses_kw = next((kw for kw in decorator.keywords if kw.arg == "responses"), None) if responses_kw is None or not isinstance(responses_kw.value, ast.Dict): - return {} - out: dict[int, ast.expr] = {} + return [] + out: list[ast.expr] = [] for key, val in zip(responses_kw.value.keys, responses_kw.value.values): if not isinstance(key, ast.Constant) or not isinstance(key.value, int): continue - if key.value not in SUCCESS_STATUSES: - continue if not isinstance(val, ast.Call): continue func_v = val.func @@ -99,25 +137,44 @@ def _extract_decorator_responses( ) or ( isinstance(func_v, ast.Attribute) and func_v.attr == "inline_sentry_response_serializer" ) - if not is_inline or len(val.args) < 2: - continue - out[key.value] = val.args[1] + if is_inline and len(val.args) >= 2: + out.append(val.args[1]) return out -def _extract_response_annotation_T(returns: ast.expr | None) -> ast.expr | None: - """If the return annotation is `Response[T]`, return the T expr. Else None - (which means: skip this method — it's either unmigrated or non-Response). +def _extract_response_annotation_Ts(returns: ast.expr | None) -> list[ast.expr] | None: + """Parse the return annotation and return the list of `T` expressions that appear + inside `Response[...]` (handling both single `Response[T]` and union + `Response[T_a] | Response[T_b]` forms). + + Returns: + - `None` if the annotation is not a `Response[T]` (or union thereof) — that's + the unmigrated state; the method is skipped. + - A non-empty list of `T` AST expressions otherwise. """ if returns is None: return None - if isinstance(returns, ast.Subscript): - val = returns.value - if isinstance(val, ast.Name) and val.id == "Response": - return returns.slice - if isinstance(val, ast.Attribute) and val.attr == "Response": - return returns.slice - return None + + # Collect every leaf of a union, then check each is `Response[T]`. + arms: list[ast.expr] = [] + pending: list[ast.expr] = [returns] + while pending: + node = pending.pop() + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + pending.append(node.left) + pending.append(node.right) + else: + arms.append(node) + + extracted: list[ast.expr] = [] + for arm in arms: + T = _is_response_subscript(arm) + if T is None: + # If any arm is `Response` (bare, unparameterized) or some other type, + # we can't meaningfully compare — treat this as unmigrated. + return None + extracted.append(T) + return extracted or None def _iter_methods(tree: ast.Module) -> Iterator[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: @@ -141,32 +198,30 @@ def check_file(path: Path) -> list[Mismatch]: mismatches: list[Mismatch] = [] for cls_name, method in _iter_methods(tree): - annot_T = _extract_response_annotation_T(method.returns) - if annot_T is None: + annot_Ts = _extract_response_annotation_Ts(method.returns) + if annot_Ts is None: continue - annot_name = _name_of(annot_T) - decl_by_status: dict[int, ast.expr] = {} + decl_Ts: list[ast.expr] = [] for dec in method.decorator_list: - decl_by_status.update(_extract_decorator_responses(dec)) + decl_Ts.extend(_extract_decorator_response_Ts(dec)) - if not decl_by_status: + if not decl_Ts: continue - for status, decl_expr in decl_by_status.items(): - decl_name = _name_of(decl_expr) - if decl_name != annot_name: - mismatches.append( - Mismatch( - path=path, - line=method.lineno, - cls=cls_name, - method=method.name, - status=status, - decl=decl_name, - annot=annot_name, - ) + decl_set = frozenset(_name_of(t) for t in decl_Ts) + annot_set = frozenset(_name_of(t) for t in annot_Ts) + if decl_set != annot_set: + mismatches.append( + Mismatch( + path=path, + line=method.lineno, + cls=cls_name, + method=method.name, + decl=decl_set, + annot=annot_set, ) + ) return mismatches @@ -189,9 +244,9 @@ def main(argv: list[str]) -> int: sys.stdout.write(f"{m}\n") if all_mismatches: sys.stderr.write( - f"\n{len(all_mismatches)} mismatch(es) — decorator's " - "`inline_sentry_response_serializer(name, T)` must match the " - "method's `-> Response[T]` annotation.\n", + f"\n{len(all_mismatches)} mismatch(es) — the set of `T`s declared by " + "`inline_sentry_response_serializer(...)` in `@extend_schema` must " + "equal the set of `T`s in the `Response[T]` (or union) annotation.\n", ) return 1 return 0 diff --git a/src/sentry/apidocs/examples/event_attachment_examples.py b/src/sentry/apidocs/examples/event_attachment_examples.py new file mode 100644 index 00000000000000..95a2a0d6496606 --- /dev/null +++ b/src/sentry/apidocs/examples/event_attachment_examples.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from drf_spectacular.utils import OpenApiExample + +from sentry.api.serializers.models.eventattachment import EventAttachmentSerializerResponse + +SCREENSHOT_ATTACHMENT: EventAttachmentSerializerResponse = { + "id": "1234", + "event_id": "9b29bbe17e9d4ee3a6d0fe9b2e8a3b1c", + "type": "event.attachment", + "name": "screenshot.png", + "mimetype": "image/png", + "dateCreated": datetime.fromisoformat("2026-04-15T18:22:31.000000Z"), + "size": 248137, + "headers": {"Content-Type": "image/png"}, + "sha1": "d3f299af02d6abbe92dd8368bab781824a9702ed", +} + +VIEW_HIERARCHY_ATTACHMENT: EventAttachmentSerializerResponse = { + "id": "1235", + "event_id": "9b29bbe17e9d4ee3a6d0fe9b2e8a3b1c", + "type": "event.view_hierarchy", + "name": "view-hierarchy.json", + "mimetype": "application/json", + "dateCreated": datetime.fromisoformat("2026-04-15T18:22:31.000000Z"), + "size": 8421, + "headers": {"Content-Type": "application/json"}, + "sha1": "fa0a5fad9e64129f6b5f60cca3a5b8c9b8a1a3a0", +} + + +class EventAttachmentExamples: + LIST_EVENT_ATTACHMENTS = [ + OpenApiExample( + "Return a list of attachments for an event", + value=[SCREENSHOT_ATTACHMENT, VIEW_HIERARCHY_ATTACHMENT], + response_only=True, + status_codes=["200"], + ) + ] + EVENT_ATTACHMENT_DETAILS = [ + OpenApiExample( + "Return a single event attachment", + value=SCREENSHOT_ATTACHMENT, + response_only=True, + status_codes=["200"], + ) + ] diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index ee5ccafc913750..f4e58286b2aef7 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -772,7 +772,7 @@ class OrganizationExamples: }, "authors": [ { - "id": 2837091, + "id": "2837091", "name": "Raj's Raspberries", "username": "rajraspberry", "email": "raj@raspberries", diff --git a/src/sentry/dashboards/on_completion_hook.py b/src/sentry/dashboards/on_completion_hook.py index 44841881bd73d4..3343b2bc1d0815 100644 --- a/src/sentry/dashboards/on_completion_hook.py +++ b/src/sentry/dashboards/on_completion_hook.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from typing import Any +import sentry_sdk from pydantic import ValidationError from sentry.api.serializers.rest_framework import DashboardSerializer @@ -87,6 +88,8 @@ def execute(cls, organization: Organization, run_id: int) -> None: if state.status != "completed": return + retry_count = cls._count_retries(state) + try: artifact = state.get_artifact("dashboard", GeneratedDashboard) except ValidationError as validation_error: @@ -98,8 +101,23 @@ def execute(cls, organization: Organization, run_id: int) -> None: }, ) - if cls._within_retry_budget(state, organization, run_id): + if retry_count < MAX_VALIDATION_RETRIES: cls._request_fix(organization, run_id, str(validation_error)) + else: + logger.info( + "dashboards.on_completion_hook.max_retries_reached", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "retry_count": retry_count, + }, + ) + cls._emit_generation_attempts_metric( + status="fail", + result="max_retries", + retry_count=retry_count, + last_layer="pydantic", + ) return if artifact is None: @@ -107,6 +125,11 @@ def execute(cls, organization: Organization, run_id: int) -> None: "dashboards.on_completion_hook.no_artifact", extra={"run_id": run_id, "organization_id": organization.id}, ) + cls._emit_generation_attempts_metric( + status="fail", + result="no_artifact", + retry_count=retry_count, + ) return serializer_errors = _validate_with_serializer(artifact, organization) @@ -120,17 +143,37 @@ def execute(cls, organization: Organization, run_id: int) -> None: }, ) - if cls._within_retry_budget(state, organization, run_id): + if retry_count < MAX_VALIDATION_RETRIES: cls._request_fix(organization, run_id, str(serializer_errors)) + else: + logger.info( + "dashboards.on_completion_hook.max_retries_reached", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "retry_count": retry_count, + }, + ) + cls._emit_generation_attempts_metric( + status="fail", + result="max_retries", + retry_count=retry_count, + last_layer="serializer", + ) return logger.info( "dashboards.on_completion_hook.validation_passed", extra={"run_id": run_id, "organization_id": organization.id}, ) + cls._emit_generation_attempts_metric( + status="pass", + result="pass", + retry_count=retry_count, + ) - @classmethod - def _within_retry_budget(cls, state: Any, organization: Organization, run_id: int) -> bool: + @staticmethod + def _count_retries(state: Any) -> int: """ Count consecutive fix requests in the current failure chain by scanning blocks in reverse. A non-fix user message (i.e. the user @@ -147,18 +190,23 @@ def _within_retry_budget(cls, state: Any, organization: Organization, run_id: in retry_count += 1 elif block.message.role == "user": break - - if retry_count >= MAX_VALIDATION_RETRIES: - logger.info( - "dashboards.on_completion_hook.max_retries_reached", - extra={ - "organization_id": organization.id, - "run_id": run_id, - "retry_count": retry_count, - }, - ) - return False - return True + return retry_count + + @staticmethod + def _emit_generation_attempts_metric( + status: str, + result: str, + retry_count: int, + last_layer: str | None = None, + ) -> None: + attributes = {"status": status, "result": result} + if last_layer is not None: + attributes["last_layer"] = last_layer + sentry_sdk.metrics.distribution( + "dashboards.on_completion_hook.generation_attempts", + retry_count + 1, + attributes=attributes, + ) @classmethod def _request_fix(cls, organization: Organization, run_id: int, error: str) -> None: diff --git a/src/sentry/db/postgres/base.py b/src/sentry/db/postgres/base.py index ddd9f52c9fe07a..8b340f8075a311 100644 --- a/src/sentry/db/postgres/base.py +++ b/src/sentry/db/postgres/base.py @@ -101,7 +101,7 @@ def executemany(self, sql, paramlist=()): class DatabaseWrapper(DjangoDatabaseWrapper): - SchemaEditorClass = DatabaseSchemaEditorProxy + SchemaEditorClass = DatabaseSchemaEditorProxy # type: ignore[assignment] queries_limit = 15000 def __init__(self, *args, **kwargs): @@ -111,7 +111,7 @@ def __init__(self, *args, **kwargs): @auto_reconnect_connection def _cursor(self, *args, **kwargs): - return super()._cursor() + return super()._cursor() # type: ignore[misc] # We're overriding this internal method that's present in Django 1.11+, because # things were shuffled around since 1.10 resulting in not constructing a django CursorWrapper @@ -119,7 +119,7 @@ def _cursor(self, *args, **kwargs): # not the other way around since then we'll lose things like __enter__ due to the way this # wrapper is working (getattr on self.cursor). def _prepare_cursor(self, cursor): - cursor = super()._prepare_cursor(CursorWrapper(self, cursor)) + cursor = super()._prepare_cursor(CursorWrapper(self, cursor)) # type: ignore[misc] return cursor def close(self, reconnect=False): diff --git a/src/sentry/eventstream/item_helpers.py b/src/sentry/eventstream/item_helpers.py index 3264ab736a40df..dc9b4b6999e62c 100644 --- a/src/sentry/eventstream/item_helpers.py +++ b/src/sentry/eventstream/item_helpers.py @@ -347,7 +347,9 @@ def _extract_exception( ) -> Mapping[str, int | list[str | int | bool | None]]: out: dict[str, int | list[Any]] = {} - exceptions = event_data.get("exception", {}).get("values", []) + exceptions = [ + exc for exc in event_data.get("exception", {}).get("values", []) or [] if exc is not None + ] # So, logically, each exception here is basically a mapping of data. # Most notable in that mapping is frames, which is itself a mapping of frame data. # NOW! EAP currently doesn't support mappings. So what we do here is instead build diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index cb94a3b9a5f780..18618f44f19b4c 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -192,8 +192,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:performance-discover-get-custom-measurements-reduced-range", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Detect performance issues in the new standalone spans pipeline instead of on transactions manager.add("organizations:performance-issues-spans", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) - # Enable AI and MCP module dashboards on dashboards platform - manager.add("organizations:insights-ai-and-mcp-dashboard-migration", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable all registered prebuilt dashboards to be synced to the database manager.add("organizations:dashboards-sync-all-registered-prebuilt-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Seer Suggestions for Web Vitals Module @@ -250,6 +248,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:sdk-crash-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Seer PR code review for GitHub Enterprise Server organizations manager.add("organizations:seer-code-review-github-enterprise", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable Seer MR code review for GitLab organizations + manager.add("organizations:seer-code-review-gitlab", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the Seer Config Reminder in the primary nav manager.add("organizations:seer-config-reminder", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Agent panel for AI-powered data exploration @@ -286,8 +286,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:root-cause-stopping-point", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the Seer Wizard and related prompts/links/banners manager.add("organizations:seer-wizard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable the Seer issues view - manager.add("organizations:seer-issue-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Workflows in Slack (released, kept until overrides are removed) @@ -296,8 +294,9 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers manager.add("organizations:seer-run-id-in-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Enable Seer activity events in the issue activity timeline - manager.add("organizations:seer-activity-timeline", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Gate display of Seer action events in the issue activity timeline + # https://linear.app/getsentry/project/add-seer-actions-to-issue-activityaction-log-0e641e1f5dac/overview + manager.add("organizations:display-seer-actions-as-issue-activities", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Gate outbox-based mirroring of SeerRun records to Seer manager.add("organizations:seer-run-mirror", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Gate outbox-based mirroring for autofix writes diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index e29e45d21d677d..44fb19f0d60821 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -228,6 +228,17 @@ class is just a descriptor for how that object functions, and what behavior can_add = True """whether or not the integration installation be initiated from Sentry""" + can_add_externally = False + """ + Marks providers whose install is initiated from the third party's app + directory or marketplace (e.g. Discord's App Directory, the GitHub App + listing, the Teams Marketplace) and completed through the pipeline modal. + + For providers that also set `can_add = False`, hiding the in-app install + button because the install can only start from the third party, this is + what lets the pipeline endpoint accept the externally-initiated install. + """ + allow_multiple = True """whether multiple installations of this integration are allowed per organization""" diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 807c0be881c073..76a50c95a3e899 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -6,14 +6,8 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key -from django import forms -from django.core.validators import URLValidator -from django.http import HttpResponseRedirect from django.http.request import HttpRequest -from django.http.response import HttpResponseBase -from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt from rest_framework import serializers from rest_framework.fields import BooleanField, CharField, URLField @@ -47,7 +41,6 @@ from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity -from sentry.web.helpers import render_to_response from .client import BitbucketServerClient, BitbucketServerSetupClient from .repository import BitbucketServerRepositoryProvider @@ -106,161 +99,6 @@ ) -class InstallationForm(forms.Form): - url = forms.CharField( - label=_("Bitbucket URL"), - help_text=_( - "The base URL for your Bitbucket Server instance, including the host and protocol." - ), - widget=forms.TextInput(attrs={"placeholder": "https://bitbucket.example.com"}), - validators=[URLValidator()], - ) - verify_ssl = forms.BooleanField( - label=_("Verify SSL"), - help_text=_( - "By default, we verify SSL certificates " - "when making requests to your Bitbucket instance." - ), - widget=forms.CheckboxInput(), - required=False, - initial=True, - ) - consumer_key = forms.CharField( - label=_("Bitbucket Consumer Key"), - widget=forms.TextInput(attrs={"placeholder": "sentry-consumer-key"}), - ) - private_key = forms.CharField( - label=_("Bitbucket Consumer Private Key"), - widget=forms.Textarea( - attrs={ - "placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" - } - ), - ) - - def clean_url(self): - """Strip off trailing / as they cause invalid URLs downstream""" - return self.cleaned_data["url"].rstrip("/") - - def clean_private_key(self): - data = self.cleaned_data["private_key"] - - try: - load_pem_private_key(data.encode("utf-8"), None, default_backend()) - except Exception: - raise forms.ValidationError( - "Private key must be a valid SSH private key encoded in a PEM format." - ) - return data - - def clean_consumer_key(self): - data = self.cleaned_data["consumer_key"] - if len(data) > 200: - raise forms.ValidationError("Consumer key is limited to 200 characters.") - return data - - -class InstallationConfigView: - """ - Collect the OAuth client credentials from the user. - """ - - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - if request.method == "POST": - form = InstallationForm(request.POST) - if form.is_valid(): - form_data = form.cleaned_data - - pipeline.bind_state("installation_data", form_data) - return pipeline.next_step() - else: - form = InstallationForm() - - return render_to_response( - template="sentry/integrations/bitbucket-server-config.html", - context={"form": form}, - request=request, - ) - - -class OAuthLoginView: - """ - Start the OAuth dance by creating a request token - and redirecting the user to approve it. - """ - - @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - with IntegrationPipelineViewEvent( - IntegrationPipelineViewType.OAUTH_LOGIN, - IntegrationDomain.SOURCE_CODE_MANAGEMENT, - BitbucketServerIntegrationProvider.key, - ).capture() as lifecycle: - if "oauth_token" in request.GET: - return pipeline.next_step() - - config = pipeline.fetch_state("installation_data") - assert config is not None - client = BitbucketServerSetupClient( - config.get("url"), - config.get("consumer_key"), - config.get("private_key"), - config.get("verify_ssl"), - ) - - try: - request_token = client.get_request_token() - except ApiError as error: - lifecycle.record_failure(str(error), extra={"url": config.get("url")}) - return pipeline.error(f"Could not fetch a request token from Bitbucket. {error}") - - pipeline.bind_state("request_token", request_token) - if not request_token.get("oauth_token"): - lifecycle.record_failure("missing oauth_token", extra={"url": config.get("url")}) - return pipeline.error("Missing oauth_token") - - authorize_url = client.get_authorize_url(request_token) - - return HttpResponseRedirect(authorize_url) - - -class OAuthCallbackView: - """ - Complete the OAuth dance by exchanging our request token - into an access token. - """ - - @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - with IntegrationPipelineViewEvent( - IntegrationPipelineViewType.OAUTH_CALLBACK, - IntegrationDomain.SOURCE_CODE_MANAGEMENT, - BitbucketServerIntegrationProvider.key, - ).capture() as lifecycle: - config = pipeline.fetch_state("installation_data") - assert config is not None - client = BitbucketServerSetupClient( - config.get("url"), - config.get("consumer_key"), - config.get("private_key"), - config.get("verify_ssl"), - ) - - try: - access_token = client.get_access_token( - pipeline.fetch_state("request_token"), request.GET["oauth_token"] - ) - - pipeline.bind_state("access_token", access_token) - - return pipeline.next_step() - except ApiError as error: - lifecycle.record_failure(str(error)) - return pipeline.error( - f"Could not fetch an access token from Bitbucket. {str(error)}" - ) - - class InstallationConfigData(TypedDict): url: str consumer_key: str @@ -544,7 +382,7 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): setup_dialog_config = {"width": 1030, "height": 1000} def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: - return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()] + return [] def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: return [InstallationConfigApiStep(), OAuthApiStep()] diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index 0acb6959223f6a..defaa6fae97053 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -227,6 +227,7 @@ def handle_post( class DiscordIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.DISCORD.value name = "Discord" + can_add_externally = True metadata = metadata integration_cls = DiscordIntegration features = frozenset([IntegrationFeatures.CHAT_UNFURL, IntegrationFeatures.ALERT_RULE]) diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 621ad648e7615b..8a72da4e5f2b73 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -693,6 +693,7 @@ def process_api_error(e: ApiError) -> list[dict[str, Any]] | None: class GitHubIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.GITHUB.value name = "GitHub" + can_add_externally = True metadata = metadata integration_cls: type[IntegrationInstallation] = GitHubIntegration features = frozenset( diff --git a/src/sentry/integrations/gitlab/webhooks.py b/src/sentry/integrations/gitlab/webhooks.py index 34135b50902fb8..ef34e32f00b656 100644 --- a/src/sentry/integrations/gitlab/webhooks.py +++ b/src/sentry/integrations/gitlab/webhooks.py @@ -1,12 +1,14 @@ from __future__ import annotations +import inspect import logging from abc import ABC from collections.abc import Mapping from datetime import timezone -from typing import Any +from typing import Any, Protocol import orjson +import sentry_sdk from dateutil.parser import parse as parse_date from django.db import IntegrityError, router, transaction from django.http import Http404, HttpRequest, HttpResponse @@ -36,11 +38,25 @@ from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider +from sentry.seer.code_review.webhooks.merge_request import handle_merge_request_event +from sentry.utils import metrics logger = logging.getLogger("sentry.webhooks") PROVIDER_NAME = "integrations:gitlab" -GITHUB_WEBHOOK_SECRET_INVALID_ERROR = """Gitlab's webhook secret does not match. Refresh token (or re-install the integration) by following this https://docs.sentry.io/organization/integrations/integration-platform/public-integration/#refreshing-tokens.""" +GITLAB_WEBHOOK_SECRET_INVALID_ERROR = """Gitlab's webhook secret does not match. Refresh token (or re-install the integration) by following this https://docs.sentry.io/organization/integrations/integration-platform/public-integration/#refreshing-tokens.""" + + +class WebhookProcessor(Protocol): + def __call__( + self, + *, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + integration: RpcIntegration | None = None, + **kwargs: Any, + ) -> None: ... def get_gitlab_external_id(request, extra) -> tuple[str, str] | HttpResponse: @@ -69,10 +85,48 @@ def get_gitlab_external_id(request, extra) -> tuple[str, str] | HttpResponse: class GitlabWebhook(SCMWebhook, ABC): + EVENT_TYPE: IntegrationWebhookEventType + WEBHOOK_EVENT_PROCESSORS: tuple[WebhookProcessor, ...] = () + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if not inspect.isabstract(cls) and not hasattr(cls, "EVENT_TYPE"): + raise TypeError(f"{cls.__name__} must define EVENT_TYPE class attribute") + + @property + def event_type(self) -> IntegrationWebhookEventType: + return self.EVENT_TYPE + @property def provider(self) -> str: return IntegrationProviderSlug.GITLAB.value + def _handle( + self, + integration: RpcIntegration, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + **kwargs: Any, + ) -> None: + for processor in self.WEBHOOK_EVENT_PROCESSORS: + try: + processor( + event=event, + integration=integration, + organization=organization, + repo=repo, + **kwargs, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + metrics.incr( + "gitlab.webhook.processor.error", + tags={"event_type": self.event_type.value}, + sample_rate=1.0, + ) + continue + def get_repo( self, integration: RpcIntegration, organization: RpcOrganization, event: Mapping[str, Any] ): @@ -125,9 +179,7 @@ class IssuesEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#issue-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.INBOUND_SYNC + EVENT_TYPE = IntegrationWebhookEventType.INBOUND_SYNC def __call__(self, event: Mapping[str, Any], **kwargs): if not (integration := kwargs.get("integration")): @@ -292,9 +344,8 @@ class MergeEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.MERGE_REQUEST + EVENT_TYPE = IntegrationWebhookEventType.MERGE_REQUEST + WEBHOOK_EVENT_PROCESSORS = (handle_merge_request_event,) def __call__(self, event: Mapping[str, Any], **kwargs): if not ( @@ -326,9 +377,11 @@ def __call__(self, event: Mapping[str, Any], **kwargs): except KeyError as e: logger.warning( "gitlab.webhook.invalid-merge-data", - extra={"integration_id": integration.id, "error": str(e)}, + extra={ + "integration_id": integration.id if integration else None, + "error": str(e), + }, ) - # TODO(mgaeta): This try/catch is full of reportUnboundVariable errors. return if not author_email: @@ -352,10 +405,16 @@ def __call__(self, event: Mapping[str, Any], **kwargs): "date_added": parse_date(created_at).astimezone(timezone.utc), }, ) - except IntegrityError: pass + self._handle( + integration=integration, + event=event, + organization=organization, + repo=repo, + ) + class PushEventWebhook(GitlabWebhook): """ @@ -364,9 +423,7 @@ class PushEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#push-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.PUSH + EVENT_TYPE = IntegrationWebhookEventType.PUSH def __call__(self, event: Mapping[str, Any], **kwargs): if not ( @@ -420,6 +477,13 @@ def __call__(self, event: Mapping[str, Any], **kwargs): except IntegrityError: pass + self._handle( + integration=integration, + event=event, + organization=organization, + repo=repo, + ) + @cell_silo_endpoint class GitlabWebhookEndpoint(Endpoint): @@ -487,9 +551,9 @@ def post(self, request: HttpRequest) -> HttpResponse: if not constant_time_compare(secret, integration.metadata["webhook_secret"]): # Summary and potential workaround mentioned here: # https://github.com/getsentry/sentry/issues/34903#issuecomment-1262754478 - extra["reason"] = GITHUB_WEBHOOK_SECRET_INVALID_ERROR + extra["reason"] = GITLAB_WEBHOOK_SECRET_INVALID_ERROR logger.info("gitlab.webhook.invalid-token-secret", extra=extra) - return HttpResponse(status=409, reason=GITHUB_WEBHOOK_SECRET_INVALID_ERROR) + return HttpResponse(status=409, reason=GITLAB_WEBHOOK_SECRET_INVALID_ERROR) try: event = orjson.loads(request.body) diff --git a/src/sentry/integrations/msteams/integration.py b/src/sentry/integrations/msteams/integration.py index 756e5111882f64..f43bff9aada15b 100644 --- a/src/sentry/integrations/msteams/integration.py +++ b/src/sentry/integrations/msteams/integration.py @@ -2,13 +2,17 @@ import logging from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, TypedDict +from django.core.signing import BadSignature, SignatureExpired from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.fields import CharField from sentry import options +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -19,6 +23,7 @@ ) from sentry.integrations.models.integration import Integration from sentry.integrations.msteams.card_builder.block import AdaptiveCard +from sentry.integrations.msteams.constants import SALT from sentry.integrations.msteams.metrics import translate_msteams_api_error from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.types import IntegrationProviderSlug @@ -28,8 +33,10 @@ ) from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError +from sentry.utils.signing import unsign from .card_builder.installation import ( build_personal_installation_confirmation_message, @@ -37,6 +44,9 @@ ) from .client import MsTeamsClient, get_token_data +# 24 hours to finish installation +INSTALL_EXPIRATION_TIME = 60 * 60 * 24 + logger = logging.getLogger("sentry.integrations.msteams") DESCRIPTION = ( @@ -105,10 +115,76 @@ def send_notification_with_threading( raise NotImplementedError("Threading is not supported for Microsoft Teams") +class MsTeamsInstallParams(TypedDict): + """The payload the Sentry-Teams bot signs into `signed_params`.""" + + external_id: str + external_name: str + service_url: str + user_id: str + conversation_id: str + tenant_id: str + installation_type: str + + +class MsTeamsInitialDataSerializer(CamelSnakeSerializer): + """Initial pipeline data for Microsoft Teams installs. + + The Sentry bot in Teams renders a card with a single `signed_params` blob + (see MsTeamsInstallParams). We unsign it here so each field is bound to + top-level pipeline state individually. + """ + + signed_params = CharField(required=True) + + def validate(self, attrs: dict[str, Any]) -> MsTeamsInstallParams: + try: + return unsign(attrs["signed_params"], max_age=INSTALL_EXPIRATION_TIME, salt=SALT) + except SignatureExpired: + raise serializers.ValidationError("Installation link expired") + except BadSignature: + raise serializers.ValidationError("Invalid installation link") + + +class MsTeamsAdvanceSerializer(CamelSnakeSerializer): + state = CharField(required=True) + + +class MsTeamsApiStep: + """Install step for Microsoft Teams. + + All install data arrives bound to pipeline state via initialData, so this + step has no UI of its own. It signals the frontend to auto-advance, which + triggers `build_integration` to run on the already-bound state. + """ + + step_name = "msteams_install" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return { + "appDirectoryInstall": True, + "state": pipeline.signature, + } + + def get_serializer_cls(self) -> type: + return MsTeamsAdvanceSerializer + + def handle_post( + self, + validated_data: dict[str, str], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + if validated_data["state"] != pipeline.signature: + return PipelineStepResult.error("An error occurred while validating your request.") + return PipelineStepResult.advance() + + class MsTeamsIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.MSTEAMS.value name = "Microsoft Teams" can_add = False + can_add_externally = True metadata = metadata integration_cls = MsTeamsIntegration features = frozenset([IntegrationFeatures.CHAT_UNFURL, IntegrationFeatures.ALERT_RULE]) @@ -116,8 +192,19 @@ class MsTeamsIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: return [MsTeamsPipelineView()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [MsTeamsApiStep()] + + def get_initial_data_serializer_cls(self) -> type[MsTeamsInitialDataSerializer]: + return MsTeamsInitialDataSerializer + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: - data = state[self.key] + # Legacy installs (server-rendered configure view) bind everything under + # `state["msteams"]`; the API pipeline binds each field top-level. Read + # the nested blob if present, else fall back to top-level state. + # TODO: drop the `state[self.key]` fallback once the legacy configure + # view is removed. + data = state.get(self.key) or state external_id = data["external_id"] external_name = data["external_name"] service_url = data["service_url"] diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index 3bf3b955bb4984..71dda7a648ffd4 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -96,7 +96,7 @@ def initialize_integration_pipeline( % "\n".join(is_feature_enabled) ) - if not pipeline.provider.can_add: + if not pipeline.provider.can_add and not pipeline.provider.can_add_externally: raise IntegrationPipelineError("Integration cannot be added.", not_found=True) pipeline.initialize() diff --git a/src/sentry/issues/escalating/escalating.py b/src/sentry/issues/escalating/escalating.py index c0f7d3aa31b010..96bafb9cad1212 100644 --- a/src/sentry/issues/escalating/escalating.py +++ b/src/sentry/issues/escalating/escalating.py @@ -517,13 +517,13 @@ def manage_issue_states( snooze_details: InboxReasonDetails | None = None, activity_data: Mapping[str, Any] | None = None, ) -> None: - from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs - """ Handles the downstream changes to the status/substatus of GroupInbox and Group for each GroupInboxReason `activity_data`: Additional activity data, such as escalating forecast """ + from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs + data: dict[str, str | Mapping[str, Any]] | None = ( {"event_id": event.event_id} if event else None ) @@ -547,15 +547,13 @@ def manage_issue_states( kwargs={"project_id": group.project_id, "group_id": group.id} ) - has_forecast = ( - True if data and activity_data and "forecast" in activity_data.keys() else False - ) + has_forecast = bool(data and activity_data and "forecast" in activity_data) issue_escalating.send_robust( project=group.project, group=group, event=event, sender=manage_issue_states, - was_until_escalating=True if has_forecast else False, + was_until_escalating=has_forecast, new_substatus=GroupSubStatus.ESCALATING, ) if data and activity_data and has_forecast: # Redundant checks needed for typing diff --git a/src/sentry/migrations/1102_activity_project_type_index.py b/src/sentry/migrations/1102_activity_project_type_index.py new file mode 100644 index 00000000000000..2b28408afe5d0a --- /dev/null +++ b/src/sentry/migrations/1102_activity_project_type_index.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.14 on 2026-05-29 19:53 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.special import SafeRunSQL + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = True + + dependencies = [ + ("sentry", "1101_remove_email_model_pending"), + ] + + operations = [ + # Drop a legacy, unused index that was created manually outside of Django's + # migration state, so there is no corresponding model/state change. + SafeRunSQL( + sql="DROP INDEX CONCURRENTLY IF EXISTS sentry_activity_weekly_report_jtcunning;", + reverse_sql=migrations.RunSQL.noop, + hints={"tables": ["sentry_activity"]}, + use_statement_timeout=False, + ), + migrations.AddIndex( + model_name="activity", + index=models.Index(fields=["project", "type"], name="sentry_acti_project_4b71f8_idx"), + ), + ] diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py index 58359960145df4..b3d73066444c7a 100644 --- a/src/sentry/models/activity.py +++ b/src/sentry/models/activity.py @@ -125,7 +125,10 @@ class Activity(Model): class Meta: app_label = "sentry" db_table = "sentry_activity" - indexes = (models.Index(fields=("project", "datetime")),) + indexes = ( + models.Index(fields=("project", "datetime")), + models.Index(fields=("project", "type")), + ) __repr__ = sane_repr("project_id", "group_id", "event_id", "user_id", "type", "ident") diff --git a/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py b/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py index dcd00cdf309490..4e2bf77c6f89f3 100644 --- a/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py +++ b/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py @@ -1,10 +1,16 @@ +import logging from typing import override +from sentry import features from sentry.notifications.notification_action.utils import execute_via_group_type_registry +from sentry.sentry_apps.services.legacy_webhook.service import send_legacy_webhooks_for_invocation +from sentry.services.eventstore.models import GroupEvent from sentry.workflow_engine.models import Action from sentry.workflow_engine.registry import action_handler_registry from sentry.workflow_engine.types import ActionHandler, ActionInvocation, ConfigTransformer +logger = logging.getLogger(__name__) + @action_handler_registry.register(Action.Type.PLUGIN) class PluginActionHandler(ActionHandler): @@ -36,4 +42,25 @@ def get_config_transformer() -> ConfigTransformer | None: @staticmethod @override def execute(invocation: ActionInvocation) -> None: - execute_via_group_type_registry(invocation) + if not isinstance(invocation.event_data.event, GroupEvent): + return + + organization = invocation.detector.project.organization + new_path = features.has("organizations:legacy-webhook-new-path", organization) + + try: + execute_via_group_type_registry(invocation) + except Exception: + logger.exception( + "plugin_action_handler.old_path_error", + extra={"invocation": invocation}, + ) + + if new_path: + try: + send_legacy_webhooks_for_invocation(invocation) + except Exception: + logger.exception( + "plugin_action_handler.new_path_error", + extra={"invocation": invocation}, + ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index c5cdf86356500a..99d7d4b2666f20 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -46,7 +46,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) register("system.secret-key", flags=FLAG_CREDENTIAL | FLAG_NOSTORE) -register("system.root-api-key", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) register("system.logging-format", default=LoggingFormat.HUMAN, flags=FLAG_NOSTORE) # This is used for the chunk upload endpoint register("system.upload-url-prefix", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) @@ -91,12 +90,6 @@ ) # The region that this instance is currently running in. register("system.region", flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_NOSTORE) -# Enable date-util parsing for timestamps -register( - "system.use-date-util-timestamps", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Organization register( @@ -127,7 +120,6 @@ default={"default": {"hosts": {0: {"host": "127.0.0.1", "port": 6379}}}}, flags=FLAG_NOSTORE | FLAG_IMMUTABLE, ) -register("redis.options", type=Dict, flags=FLAG_NOSTORE) # Processing worker caches register( @@ -142,12 +134,6 @@ default="/tmp/sentry-releasefile-cache", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "releasefile.cache-limit", - type=Int, - default=10 * 1024 * 1024, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # Mail @@ -322,27 +308,6 @@ flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) -# API Tokens -register( - "apitoken.auto-add-last-chars", - default=True, - type=Bool, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "apitoken.save-hash-on-create", - default=True, - type=Bool, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Controls the rate of using the hashed value of User API tokens for lookups when logging in -# and also updates tokens which are not hashed -register( - "apitoken.use-and-update-hash-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "api.rate-limit.org-create", @@ -408,10 +373,6 @@ register("filestore.control.backend", default="", flags=FLAG_NOSTORE) register("filestore.control.options", default={}, flags=FLAG_NOSTORE) -# Whether to use a redis lock on fileblob uploads and deletes -register("fileblob.upload.use_lock", default=True, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Whether to use redis to cache `FileBlob.id` lookups -register("fileblob.upload.use_blobid_cache", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) # New `objectstore` service configuration. Additional supported options and # their defaults: @@ -499,18 +460,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Configuration Options -register( - "configurations.storage.backend", - default=None, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "configurations.storage.options", - type=Dict, - default=None, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # Flag Options register( @@ -568,13 +517,6 @@ default=0.0, flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Enables profiling for replay recording ingestion. -register( - "replay.consumer.recording.profiling.enabled", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Enable new msgspec-based recording parser. register( "replay.consumer.msgspec_recording_parser", @@ -631,15 +573,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Ingest only a random fraction of logs sent to relay. Used to roll out ourlogs ingestion. -# -# NOTE: Any value below 1.0 will cause customer data to not appear and can break the product. Do not override in production. -register( - "relay.ourlogs-ingestion.sample-rate", - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - # Allow the Relay to skip normalization of spans for certain hosts. register( @@ -755,7 +688,6 @@ # Codecov Integration register("codecov.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) -register("codecov.base-url", default="https://api.codecov.io") register("codecov.api-bridge-signing-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) # GitHub Integration @@ -838,16 +770,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) register("github-login.organization", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) -register( - "github-extension.enabled", - default=False, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "github-extension.enabled-orgs", - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) # VSTS Integration register("vsts.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) @@ -930,16 +852,6 @@ default=False, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "snuba.search.pre-snuba-candidates-percentage", - default=0.2, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "snuba.search.project-group-count-cache-time", - default=24 * 60 * 60, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "snuba.search.min-pre-snuba-candidates", default=500, @@ -989,7 +901,6 @@ default={7001: 0.15}, flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register("snuba.track-outcomes-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) # The percentage of tagkeys that we want to cache. Set to 1.0 in order to cache everything, <=0.0 to stop caching register( @@ -1006,27 +917,7 @@ type=Int, ) -# Kafka Publisher -register( - "kafka-publisher.raw-event-sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Enable multiple topics for eventstream. It allows specific event types to be sent -# to specific topic. -register( - "store.eventstream-per-type-topic", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Query and supply Bundle Indexes to Symbolicator SourceMap processing -register( - "symbolicator.sourcemaps-bundle-index-sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Refresh Bundle Indexes reported as used by symbolicator register( "symbolicator.sourcemaps-bundle-index-refresh-sample-rate", @@ -1042,18 +933,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Enables setting a sampling rate when producing the tag facet. -register( - "discover2.tags_facet_enable_sampling", - default=True, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Enable use of symbolic-sourcemapcache for JavaScript Source Maps processing. -# Set this value of the fraction of projects that you want to use it for. -register( - "processing.sourcemapcache-processor", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE -) # unused # Killswitch for sending internal errors to the internal project or # `SENTRY_SDK_CONFIG.relay_dsn`. Set to `0` to only send to @@ -1068,23 +947,10 @@ # Sample rate for transaction/span data sent to S4S upstream (1.0 = keep all, 0.05 = keep 5%) register("store.s4s-transaction-sample-rate", default=1.0, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Mock out integrations and services for tests -register("mocks.jira", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) - -# Record statistics about event payloads and their compressibility -register( - "store.nodestore-stats-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE -) # unused # Killswitch to stop storing any reprocessing payloads. register("store.reprocessing-force-disable", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Enable calling the severity modeling API on group creation -register( - "processing.calculate-severity-on-group-creation", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Enable sending the flag to the microservice to tell it to purposefully take longer than our # timeout, to see the effect on the overall error event processing backlog @@ -1102,12 +968,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "issues.severity.first-event-severity-calculation-projects-allowlist", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "issues.severity.seer-project-rate-limit", @@ -1137,20 +997,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "issues.priority.projects-allowlist", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Killswitch for issue priority -register( - "issues.priority.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Killswitch for batched nodestore fetches in group events endpoint. # When disabled, falls back to lazy per-event nodestore fetches. @@ -1189,12 +1035,6 @@ type=Bool, flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.similarity-embeddings-grouping-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) register( "seer.similarity-embeddings-delete-by-hash-killswitch.enabled", default=False, @@ -1219,30 +1059,6 @@ default=1, flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.severity-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.breakpoint-detection-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.autofix-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.anomaly-detection-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Agent context engine indexing options register( @@ -1339,37 +1155,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# seer nearest neighbour endpoint timeout -register( - "embeddings-grouping.seer.nearest-neighbour-timeout", - type=Float, - default=0.1, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings record update endpoint timeout -register( - "embeddings-grouping.seer.embeddings-record-update-timeout", - type=Float, - default=0.05, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings record delete endpoint timeout -register( - "embeddings-grouping.seer.embeddings-record-delete-timeout", - type=Float, - default=0.1, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings ratelimit in percentage that is allowed -register( - "embeddings-grouping.seer.ratelimit", - type=Int, - default=0, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "embeddings-grouping.seer.delete-record-batch-size", @@ -1486,12 +1271,6 @@ default=[], flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "store.save-event-highcpu-platforms", - type=Sequence, - default=[], - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "post_process.get-autoassign-owners", type=Sequence, @@ -1533,9 +1312,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Minimum number of files in an archive. Archives with fewer files are extracted and have their -# contents stored as separate release files. -register("processing.release-archive-min-files", default=10, flags=FLAG_AUTOMATOR_MODIFIABLE) # All Relay options (statically authenticated Relays can be registered here) register("relay.static_auth", default={}, flags=FLAG_NOSTORE) @@ -1582,15 +1358,6 @@ # Subscription queries sampling rate register("subscriptions-query.sample-rate", default=0.01, flags=FLAG_AUTOMATOR_MODIFIABLE) -# The ratio of symbolication requests for which metrics will be submitted to redis. -# -# This is to allow gradual rollout of metrics collection for symbolication requests and can be -# removed once it is fully rolled out. -register( - "symbolicate-event.low-priority.metrics.submission-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # The ratio of events for which we emit verbose apple symbol stats. # @@ -1733,13 +1500,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "organization-abuse-quota.custom-metric-bucket-limit", - type=Int, - default=0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - for mabq in build_metric_abuse_quotas(): register( @@ -1992,11 +1752,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "sentry-metrics.synchronize-kafka-rebalances", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "sentry-metrics.synchronized-rebalance-delay", @@ -2233,60 +1988,12 @@ default=False, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.traces.trace-explorer-buffer-hours", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-max-trace-ids-per-chunk", - type=Int, - default=2500, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-skip-floating-spans", - type=Bool, - default=True, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-block-size-hours", - type=Int, - default=8, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-batches", - type=Int, - default=7, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-execution-seconds", - type=Int, - default=30, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-parallel-queries", - type=Int, - default=3, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.traces.trace-explorer-skip-recent-seconds", type=Int, default=0, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.traces.span_query_minimum_spans", - type=Int, - default=10000, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.traces.check_span_extraction_date", type=Bool, @@ -2312,24 +2019,12 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.spans-tags-key.sample-rate", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.spans-tags-key.max", type=Int, default=1000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.spans-tags-value.sample-rate", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.spans-tags-values.max", type=Int, @@ -2349,14 +2044,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# In Single Tenant with 100% DS, we may need to reverse the UI change made by dynamic-sampling -# if metrics extraction isn't ready. -register( - "performance.hide-metrics-ui", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Used for the z-score when calculating the margin of error in performance register( @@ -2466,8 +2153,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Flagpole Configuration (used in getsentry) -register("flagpole.debounce_reporting_seconds", default=0, flags=FLAG_AUTOMATOR_MODIFIABLE) # Feature flagging error capture rate. # When feature flagging has faults, it can become very high volume and we can overwhelm sentry. @@ -2477,7 +2162,6 @@ register("hybridcloud.regionsiloclient.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) register("hybridcloud.rpc.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) register("hybridcloud.integrationproxy.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) -register("hybridcloud.endpoint_flag_logging", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) register( "hybridcloud.rpc.method_retry_overrides", default={}, @@ -2706,18 +2390,6 @@ default=False, flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "statistical_detectors.enable.projects.performance", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "statistical_detectors.enable.projects.profiling", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) register( "statistical_detectors.query.batch_size", type=Int, @@ -2804,11 +2476,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "metric_extraction.max_span_attribute_specs", - default=100, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "metric_alerts.extended_max_subscriptions", @@ -2947,12 +2614,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# The flag disables the file io on main thread detector -register( - "performance_issues.file_io_main_thread.disabled", - default=False, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Enables on-demand metric extraction for Dashboard Widgets. register( @@ -3134,33 +2795,6 @@ ) -# Switch to read assemble status from Redis instead of memcache -register("assemble.read_from_redis", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) - -# Sampling rates for testing Rust-based grouping enhancers - -# Rate at which to run the Rust implementation of `assemble_stacktrace_component` -# and compare the results -register( - "grouping.rust_enhancers.compare_components", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Rate at which to prefer the Rust implementation of `assemble_stacktrace_component`. -register( - "grouping.rust_enhancers.prefer_rust_components", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "metrics.sample-list.sample-rate", - type=Float, - default=100_000.0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - - # TODO: For now, only a small number of projects are going through a grouping config transition at # any given time, so we're sampling at 100% in order to be able to get good signal. Once we've fully # transitioned to the optimized logic, and before the next config change, we probably either want to @@ -3194,20 +2828,6 @@ ) -# Sample rate for double writing to experimental dsn -register( - "store.experimental-dsn-double-write.sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# temporary option for logging canonical key fallback stacktraces -register( - "canonical-fallback.send-error-to-sentry", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - # SPAN BUFFER # Span buffer killswitch register( @@ -3402,30 +3022,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "indexed-spans.agg-span-waterfall.enable", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "traces.sample-list.sample-rate", - type=Float, - default=0.0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "discover.saved-query-dataset-split.enable", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "discover.saved-query-dataset-split.organization-id-allowlist", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) register( "feedback.filter_garbage_messages", @@ -3479,38 +3075,6 @@ ) # Notification Options - End -# List of organizations with increased rate limits for organization_events API -register( - "api.organization_events.rate-limit-increased.orgs", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) -# Increased rate limits for organization_events API for the orgs above -register( - "api.organization_events.rate-limit-increased.limits", - type=Dict, - default={"limit": 50, "window": 1, "concurrent_limit": 50}, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) -# Reduced rate limits for organization_events API for the orgs in LA/EA/GA rollout -# Once GA'd, this will be the default rate limit for all orgs not on the increase list -register( - "api.organization_events.rate-limit-reduced.limits", - type=Dict, - default={"limit": 1000, "window": 300, "concurrent_limit": 15}, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - - -# TODO: remove once removed from options -register( - "issue_platform.use_kafka_partition_key", - type=Bool, - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - register( "sentry.save-event-attachments.project-per-5-minute-limit", @@ -3540,21 +3104,7 @@ default=250, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Limits the total duration of profile chunks to aggregate in flamegraphs -register( - "profiling.continuous-profiling.flamegraph.max-seconds", - type=Int, - default=10 * 60, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Enable orjson in the occurrence_consumer.process_[message|batch] -register( - "issues.occurrence_consumer.use_orjson", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Controls the rate of using the sentry api shared secret for communicating to sentry. # DEPRECATED: will be removed after the shared secret is confirmed to always be set. @@ -3564,11 +3114,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "similarity.backfill_project_cohort_size", - default=1000, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "similarity.new_project_seer_grouping.enabled", default=False, @@ -3579,12 +3124,6 @@ default=10000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "delayed_processing.emit_logs", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "delayed_workflow.rollout", type=Bool, @@ -3697,17 +3236,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -# Disables specific uptime checker regions. This is a list of region slugs -# which must match regions available in the settings.UPTIME_REGIONS list. -# -# Useful to remove a region from check rotation if there is some kind of -# problem with the region. -register( - "uptime.disabled-checker-regions", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "uptime.checker-regions-mode-override", type=Dict, @@ -3952,17 +3480,6 @@ register("objectstore.enable_for.attachments", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) -# option used to enable/disable tracking -# rate of potential functions metrics to -# be written into EAP -register( - "profiling.track_functions_metrics_write_rate.eap.enabled", - default=False, - type=Bool, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - - register( "sentry.send_onboarding_task_metrics", type=Bool, @@ -4145,12 +3662,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.scanner_no_consent.rollout_rate", - type=Float, - default=0.0, - flags=FLAG_MODIFIABLE_RATE | FLAG_AUTOMATOR_MODIFIABLE, -) # Enabled Prebuilt Dashboard IDs register( diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 8ca58acbc051b4..554eaca43467b4 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -183,7 +183,6 @@ class OptionsManager: def __init__(self, store: OptionsStore): self.store = store self.registry: dict[str, Key] = {} - self._seen: set[str] = set() def set(self, key: str, value, coerce=True, channel: UpdateChannel = UpdateChannel.UNKNOWN): """ @@ -284,15 +283,6 @@ def is_set_on_disk(self, key: str) -> bool: """ return key in settings.SENTRY_OPTIONS - def _record_seen(self, key: str) -> None: - """Emit one log line per key per process lifetime so reads can be - audited in GCP. Logs before adding to _seen so a logging failure - doesn't permanently suppress the event. In debug mode, mark keys as - seen without logging to keep local tooling output clean.""" - if not settings.DEBUG: - logger.info("option.seen", extra={"option_key": key}) - self._seen.add(key) - def get(self, key: str, silent=False): """ Get the value of an option, falling back to the local configuration. @@ -316,12 +306,6 @@ def get(self, key: str, silent=False): sample_rate=0.01, ) as tags: opt = self.lookup_key(key) - if key not in self._seen: - try: - self._record_seen(key) - except Exception: - # Tracking is best-effort. Never let it affect option reads. - pass # First check if the option should exist on disk, and if it actually # has a value set, let's use that one instead without even attempting diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index 0e189ebf12f240..079a203975527c 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Collection from typing import Any import jsonschema @@ -62,8 +63,10 @@ from sentry.preprod.snapshots.manifest import ( ComparisonManifest, ImageMetadata, + InvalidImageNamePattern, SnapshotManifest, image_metadata_extras, + make_image_name_matcher, ) from sentry.preprod.snapshots.models import ( PreprodSnapshotComparison, @@ -99,6 +102,13 @@ "items": {"type": "string"}, "maxItems": 50000, }, + # Sanity bounds for client-supplied patterns; ReDoS-safety comes from RE2's + # linear-time matching (see make_image_name_matcher in manifest.py). + "all_image_file_names_as_regex": { + "type": "array", + "items": {"type": "string", "maxLength": 500}, + "maxItems": 100, + }, **VCS_SCHEMA_PROPERTIES, }, "required": ["app_id", "images"], @@ -110,6 +120,7 @@ "images": "The images field is required and must be an object mapping image names to image metadata.", "selective": "The selective field must be a boolean.", "all_image_file_names": "The all_image_file_names field must be an array of strings with at most 50000 entries.", + "all_image_file_names_as_regex": "The all_image_file_names_as_regex field must be an array of regex pattern strings (each at most 500 characters) with at most 100 entries.", **VCS_ERROR_MESSAGES, } @@ -174,6 +185,35 @@ def _format_validation_error(e: jsonschema.ValidationError) -> str: return e.message +def _validate_image_name_coverage( + image_names: Collection[str], + all_image_file_names: list[str] | None, + all_image_file_names_as_regex: list[str] | None, +) -> str | None: + """ + Ensure every uploaded image name is covered by the head build's declared set + of image names (a literal name list or a list of regex patterns). Returns an + error detail string, or None when valid. + """ + if all_image_file_names is not None: + if not all_image_file_names: + return "all_image_file_names must not be empty." + if set(image_names) - set(all_image_file_names): + return "Every image name must appear in all_image_file_names." + + if all_image_file_names_as_regex is not None: + if not all_image_file_names_as_regex: + return "all_image_file_names_as_regex must not be empty." + try: + matches = make_image_name_matcher(all_image_file_names_as_regex) + except InvalidImageNamePattern as e: + return f"all_image_file_names_as_regex contains an invalid regex pattern: {e.pattern}" + if any(not matches(name) for name in image_names): + return "Every image name must match a pattern in all_image_file_names_as_regex." + + return None + + def _format_pydantic_error(e: pydantic.ValidationError) -> str: err = e.errors()[0] loc = err.get("loc", ()) @@ -673,10 +713,23 @@ def post(self, request: Request, project: Project) -> Response: selective = data.get("selective", False) all_image_file_names = data.get("all_image_file_names") + all_image_file_names_as_regex = data.get("all_image_file_names_as_regex") - if all_image_file_names is not None and not selective: + if all_image_file_names is not None and all_image_file_names_as_regex is not None: return Response( - {"detail": "all_image_file_names requires selective to be true."}, + { + "detail": "all_image_file_names and all_image_file_names_as_regex are mutually exclusive." + }, + status=400, + ) + + if ( + all_image_file_names is not None or all_image_file_names_as_regex is not None + ) and not selective: + return Response( + { + "detail": "all_image_file_names and all_image_file_names_as_regex require selective to be true." + }, status=400, ) @@ -686,19 +739,11 @@ def post(self, request: Request, project: Project) -> Response: status=400, ) - if all_image_file_names is not None: - if not all_image_file_names: - return Response( - {"detail": "all_image_file_names must not be empty."}, - status=400, - ) - all_image_file_names_set = set(all_image_file_names) - missing = set(images.keys()) - all_image_file_names_set - if missing: - return Response( - {"detail": "Every image name must appear in all_image_file_names."}, - status=400, - ) + coverage_error = _validate_image_name_coverage( + images.keys(), all_image_file_names, all_image_file_names_as_regex + ) + if coverage_error: + return Response({"detail": coverage_error}, status=400) # Validate before entering the transaction so invalid data never creates # orphaned DB records. @@ -708,6 +753,7 @@ def post(self, request: Request, project: Project) -> Response: diff_threshold=diff_threshold, selective=selective, all_image_file_names=all_image_file_names, + all_image_file_names_as_regex=all_image_file_names_as_regex, ) except pydantic.ValidationError as e: return Response( diff --git a/src/sentry/preprod/snapshots/manifest.py b/src/sentry/preprod/snapshots/manifest.py index b7eb4680d29d59..75cb699119abae 100644 --- a/src/sentry/preprod/snapshots/manifest.py +++ b/src/sentry/preprod/snapshots/manifest.py @@ -1,9 +1,38 @@ from __future__ import annotations -from collections.abc import Set +from collections.abc import Callable, Sequence, Set from typing import Any, Literal -from pydantic import BaseModel, Field, validator +import re2 +from pydantic import BaseModel, Field, root_validator, validator + +# Invalid patterns are client input we surface as a 400, not a server error worth logging. +_RE2_OPTIONS = re2.Options() +_RE2_OPTIONS.log_errors = False + + +class InvalidImageNamePattern(ValueError): + def __init__(self, pattern: str) -> None: + super().__init__(pattern) + self.pattern = pattern + + +def make_image_name_matcher(patterns: Sequence[str]) -> Callable[[str], bool]: + """ + Build a predicate testing whether a name fully matches any of `patterns`. + + Patterns compile with RE2, a linear-time engine: matching cannot catastrophically + backtrack, so no time budget is needed. RE2 rejects unsupported constructs + (backreferences, lookaround) at compile time; we raise InvalidImageNamePattern + carrying the offending pattern. + """ + compiled: list[Any] = [] + for pattern in patterns: + try: + compiled.append(re2.compile(pattern, _RE2_OPTIONS)) + except re2.error as e: + raise InvalidImageNamePattern(pattern) from e + return lambda name: any(compiled_pattern.fullmatch(name) for compiled_pattern in compiled) class ImageMetadata(BaseModel): @@ -57,6 +86,30 @@ class SnapshotManifest(BaseModel): diff_threshold: float | None = Field(default=None, ge=0.0, lt=1.0) selective: bool = False all_image_file_names: list[str] | None = None + all_image_file_names_as_regex: list[str] | None = None + + @root_validator(skip_on_failure=True) + def _all_image_file_names_mutually_exclusive(cls, values: dict[str, Any]) -> dict[str, Any]: + if ( + values.get("all_image_file_names") is not None + and values.get("all_image_file_names_as_regex") is not None + ): + raise ValueError( + "all_image_file_names and all_image_file_names_as_regex are mutually exclusive" + ) + return values + + def head_image_name_matcher(self) -> Callable[[str], bool] | None: + """ + Return a check for whether a name is in the head's declared image set, or + None if the head didn't declare one. Distinguishes removed images (not in the + set) from skipped ones (in the set but not re-uploaded). + """ + if self.all_image_file_names is not None: + return set(self.all_image_file_names).__contains__ + if self.all_image_file_names_as_regex is not None: + return make_image_name_matcher(self.all_image_file_names_as_regex) + return None class ComparisonImageResult(BaseModel): diff --git a/src/sentry/preprod/snapshots/tasks.py b/src/sentry/preprod/snapshots/tasks.py index cc9d17a56c257b..52e3d3d888617a 100644 --- a/src/sentry/preprod/snapshots/tasks.py +++ b/src/sentry/preprod/snapshots/tasks.py @@ -85,15 +85,15 @@ def categorize_image_diff( head_by_name = {key: meta.content_hash for key, meta in head_manifest.images.items()} base_by_name = {key: meta.content_hash for key, meta in base_manifest.images.items()} - all_image_file_names = head_manifest.all_image_file_names + matches_head_image = head_manifest.head_image_name_matcher() matched = head_by_name.keys() & base_by_name.keys() added = head_by_name.keys() - base_by_name.keys() - if all_image_file_names is not None: - all_names_set = set(all_image_file_names) - removed = base_by_name.keys() - all_names_set - skipped = (all_names_set - head_by_name.keys()) & base_by_name.keys() + if matches_head_image is not None: + base_in_head = {name for name in base_by_name.keys() if matches_head_image(name)} + removed = base_by_name.keys() - base_in_head + skipped = base_in_head - head_by_name.keys() elif head_manifest.selective: removed = set() skipped = base_by_name.keys() - head_by_name.keys() diff --git a/src/sentry/rules/actions/notify_event.py b/src/sentry/rules/actions/notify_event.py index 0a73015ea99796..d6698b0d9c9e85 100644 --- a/src/sentry/rules/actions/notify_event.py +++ b/src/sentry/rules/actions/notify_event.py @@ -1,5 +1,6 @@ from collections.abc import Generator, Sequence +from sentry import features from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import LegacyPluginService @@ -19,10 +20,16 @@ class NotifyEventAction(EventAction): def get_plugins(self) -> Sequence[LegacyPluginService]: from sentry.plugins.bases.notify import NotificationPlugin + skip_webhooks = features.has( + "organizations:legacy-webhook-disable-old-path", self.project.organization + ) + results = [] for plugin in plugins.for_project(self.project, version=1): if not isinstance(plugin, NotificationPlugin): continue + if skip_webhooks and plugin.slug == "webhooks": + continue results.append(LegacyPluginService(plugin)) return results diff --git a/src/sentry/search/eap/spans/attributes.py b/src/sentry/search/eap/spans/attributes.py index 3e18eedffb9626..1d2fe0072a50fc 100644 --- a/src/sentry/search/eap/spans/attributes.py +++ b/src/sentry/search/eap/spans/attributes.py @@ -533,7 +533,7 @@ def _update_attribute_definitions_with_deprecations( if ( deprecation is None or deprecation.replacement is None - or deprecation.status != DeprecationStatus.BACKFILL + or deprecation.status not in (DeprecationStatus.BACKFILL, DeprecationStatus.NORMALIZE) ): continue diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 7a9d7d2f49daf4..285d0ca4eb6e93 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import re from collections.abc import Callable from enum import StrEnum from typing import TYPE_CHECKING, Any, Literal @@ -43,6 +44,7 @@ from sentry.seer.models import SeerRepoDefinition from sentry.seer.models.seer_api_models import SeerPermissionError from sentry.sentry_apps.metrics import SentryAppEventType +from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.utils import metrics @@ -634,9 +636,7 @@ def trigger_push_changes( client.push_changes( run_id, repo_name=repo_name, - pr_description_suffix=( - f"Fixes {group.qualified_short_id}" if group.qualified_short_id else None - ), + pr_description_suffix=build_pr_description_suffix(group), blocking=False, ) @@ -644,3 +644,31 @@ def trigger_push_changes( "autofix.explorer.trigger", tags={"step": "open_pr", "referrer": referrer.value}, ) + + +def build_pr_description_suffix(group: Group) -> str | None: + lines = [] + + if group.qualified_short_id: + lines.append(f"Fixes {group.qualified_short_id}") + + for external_issue in PlatformExternalIssue.objects.filter(group_id=group.id): + if external_issue.service_type == "linear": + is_valid = bool(re.match(r"^[A-Z0-9]+#\d+$", external_issue.display_name)) + if not is_valid: + logger.warning( + "autofix.linear.unknown-id", + extra={ + "group": group.id, + "project": group.project_id, + "linear_id": external_issue.display_name, + }, + ) + continue + linear_id = external_issue.display_name.replace("#", "-") + lines.append(f"Fixes [{linear_id}]({external_issue.web_url})") + + if lines: + return "\n".join(lines) + + return None diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 34a7443dd3b38c..801b0c882f541b 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict import orjson import sentry_sdk @@ -419,33 +419,6 @@ def deduplicate_repositories( return deduplicated -def _write_preference_project_options(project: Project, preference: SeerProjectPreference) -> None: - stopping_point = preference.automated_run_stopping_point - if stopping_point and stopping_point != SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT: - project.update_option("sentry:seer_automated_run_stopping_point", stopping_point) - else: - project.delete_option("sentry:seer_automated_run_stopping_point") - - handoff = preference.automation_handoff - if handoff is not None: - project.update_option("sentry:seer_automation_handoff_point", handoff.handoff_point) - project.update_option("sentry:seer_automation_handoff_target", handoff.target) - project.update_option( - "sentry:seer_automation_handoff_integration_id", handoff.integration_id - ) - if handoff.auto_create_pr: - project.update_option( - "sentry:seer_automation_handoff_auto_create_pr", handoff.auto_create_pr - ) - else: - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") - else: - project.delete_option("sentry:seer_automation_handoff_point") - project.delete_option("sentry:seer_automation_handoff_target") - project.delete_option("sentry:seer_automation_handoff_integration_id") - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") - - def _write_preferences_to_sentry_db( project_preferences: list[tuple[Project, SeerProjectPreference]], ) -> None: @@ -459,9 +432,6 @@ def _write_preferences_to_sentry_db( with transaction.atomic(using=router.db_for_write(SeerProjectRepository)): project_ids = {project.id for project, _ in project_preferences} - # Lock project rows to serialize concurrent preference writes. - list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) - # Only delete SeerProjectRepository for active repos. SeerProjectRepository.objects.filter( project_repository__project_id__in=project_ids, @@ -549,10 +519,22 @@ def _write_preferences_to_sentry_db( ) SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) - # Write ProjectOptions last so cache updates only happen after all DB writes succeed + # Write ProjectOptions last so cache updates happen after repo DB writes succeed # (cache cannot be rolled back by the transaction). for project, pref in project_preferences: - _write_preference_project_options(project, pref) + update = SeerProjectSettingsUpdate() + + if pref.automated_run_stopping_point is not None: + update["stopping_point"] = pref.automated_run_stopping_point + + if pref.automation_handoff is not None: + update["agent"] = AutomationCodingAgent(pref.automation_handoff.target) + update["integration_id"] = pref.automation_handoff.integration_id + update["auto_create_pr"] = pref.automation_handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + + update_seer_project_settings([project.id], update) def write_preference_to_sentry_db(project: Project, preference: SeerProjectPreference) -> None: @@ -584,15 +566,15 @@ def bulk_write_preferences_to_sentry_db( def clear_preference_automation_handoff(project: Project) -> None: - """Atomically clear automation_handoff from a project's Seer preferences in Sentry DB.""" - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent preference writes. - list(Project.objects.select_for_update().filter(id=project.id)) - - project.delete_option("sentry:seer_automation_handoff_point") - project.delete_option("sentry:seer_automation_handoff_target") - project.delete_option("sentry:seer_automation_handoff_integration_id") - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") + """Atomically clear a project's automation handoff settings.""" + ProjectOption.objects.filter( + project=project, + key__in=[ + "sentry:seer_automation_handoff_point", + "sentry:seer_automation_handoff_target", + "sentry:seer_automation_handoff_integration_id", + ], + ).delete() def build_repo_definition_from_project_repo( @@ -728,16 +710,19 @@ def _get_project_option(key: str) -> Any: class SeerProjectSettingsUpdate(TypedDict, total=False): agent: AutomationCodingAgent - integrationId: int - stoppingPoint: AutofixStoppingPoint | Literal["off"] - scannerAutomation: bool + integration_id: int + stopping_point: str + automation_tuning: str + scanner_automation: bool + auto_create_pr: bool + +def update_seer_project_settings(project_ids: list[int], data: SeerProjectSettingsUpdate) -> None: + """Apply Seer project settings to one or more projects. + For any ProjectOptions, delete the row if we're setting that field to its default.""" + if not project_ids or not data: + return -def _get_seer_project_options_to_update( - data: SeerProjectSettingsUpdate, -) -> tuple[dict[str, Any], list[str]]: - """Return (options_to_set, options_to_clear) for the given Seer project settings update. - Clear the option if it's the default; otherwise, set it.""" options_to_set: dict[str, Any] = {} options_to_clear: list[str] = [] @@ -756,84 +741,44 @@ def _set_or_clear(key: str, value: Any, default: Any) -> None: "sentry:seer_automation_handoff_integration_id", ] else: - integration_id = data.get("integrationId") + integration_id = data.get("integration_id") if integration_id is None: raise ValueError("integrationId is required for external coding agents") options_to_set["sentry:seer_automation_handoff_point"] = AutofixHandoffPoint.ROOT_CAUSE options_to_set["sentry:seer_automation_handoff_target"] = agent - options_to_set["sentry:seer_automation_handoff_integration_id"] = integration_id + options_to_set["sentry:seer_automation_handoff_integration_id"] = data["integration_id"] - if "scannerAutomation" in data: - _set_or_clear("sentry:seer_scanner_automation", data["scannerAutomation"], default=True) + if "scanner_automation" in data: + _set_or_clear("sentry:seer_scanner_automation", data["scanner_automation"], default=True) - if "stoppingPoint" not in data: - return options_to_set, options_to_clear - elif data["stoppingPoint"] == "off": - # Disable automation and leave stopping point and handoff_auto_create_pr unchanged - # so that reenabling restores the prior state. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.OFF, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) - else: - # Enable automation and set the stopping point. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.MEDIUM, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) + if "stopping_point" in data: _set_or_clear( "sentry:seer_automated_run_stopping_point", - data["stoppingPoint"], + data["stopping_point"], default=SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ) - if data["stoppingPoint"] == AutofixStoppingPoint.OPEN_PR: - # Safe to set even if no external handoff is configured - # since we'll only read it if the other handoff options are all non-null. - options_to_set["sentry:seer_automation_handoff_auto_create_pr"] = True - else: - options_to_clear.append("sentry:seer_automation_handoff_auto_create_pr") - return options_to_set, options_to_clear - - -def update_seer_project_settings(project: Project, data: SeerProjectSettingsUpdate) -> None: - """Apply high-level Seer settings to a single project.""" - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - Project.objects.select_for_update().filter(id=project.id).first() - - for key in options_to_delete: - project.delete_option(key) - for key, value in options_to_set.items(): - project.update_option(key, value) - + if "auto_create_pr" in data: + _set_or_clear( + "sentry:seer_automation_handoff_auto_create_pr", data["auto_create_pr"], default=False + ) -def bulk_update_seer_project_settings( - projects: list[Project], data: SeerProjectSettingsUpdate -) -> None: - """Apply high-level Seer settings to multiple projects in bulk.""" - if not projects: - return + if "automation_tuning" in data: + _set_or_clear( + "sentry:autofix_automation_tuning", + data["automation_tuning"], + default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, + ) - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - if not options_to_set and not options_to_delete: + if not options_to_set and not options_to_clear: return - project_ids = [p.id for p in projects] - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) - - if options_to_delete: + if options_to_clear: # Use _raw_delete to skip per-row post_delete signals that each trigger reload_cache. # For efficiency, we reload once per project after the transaction instead. ProjectOption.objects.filter( - project_id__in=project_ids, key__in=options_to_delete + project_id__in=project_ids, key__in=options_to_clear )._raw_delete(using=router.db_for_write(ProjectOption)) if options_to_set: diff --git a/src/sentry/seer/code_review/metrics.py b/src/sentry/seer/code_review/metrics.py index f36c220d923af2..41504fc43c98e3 100644 --- a/src/sentry/seer/code_review/metrics.py +++ b/src/sentry/seer/code_review/metrics.py @@ -48,13 +48,16 @@ class CodeReviewErrorType(StrEnum): def _build_webhook_tags( - github_event: GithubWebhookType, github_event_action: str + github_event: GithubWebhookType | str, github_event_action: str ) -> dict[str, str]: - return {"github_event": github_event.value, "github_event_action": github_event_action} + event_value = ( + github_event.value if isinstance(github_event, GithubWebhookType) else github_event + ) + return {"github_event": event_value, "github_event_action": github_event_action} def record_webhook_received( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, ) -> None: """ @@ -63,7 +66,7 @@ def record_webhook_received( This is the entry point metric for the processing funnel. Args: - github_event: The GitHub webhook event type (e.g., check_run, issue_comment) + github_event: The webhook event type (e.g., check_run, issue_comment, merge_request) github_event_action: The webhook action (e.g., created, rerequested, synchronize) """ metrics.incr( @@ -74,7 +77,7 @@ def record_webhook_received( def record_webhook_filtered( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, reason: CodeReviewFilteredReason, ) -> None: @@ -85,7 +88,7 @@ def record_webhook_filtered( not enabled, not a review command, wrong action type). Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) reason: Why the webhook was filtered """ @@ -97,7 +100,7 @@ def record_webhook_filtered( def record_webhook_enqueued( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, ) -> None: """ @@ -107,7 +110,7 @@ def record_webhook_enqueued( was created to process it. Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) """ metrics.incr( @@ -118,7 +121,7 @@ def record_webhook_enqueued( def record_webhook_handler_error( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, error_type: CodeReviewErrorType, ) -> None: @@ -126,7 +129,7 @@ def record_webhook_handler_error( Record an error in the webhook handler stage. Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) error_type: Specific error identifier from CodeReviewErrorType enum """ diff --git a/src/sentry/seer/code_review/utils.py b/src/sentry/seer/code_review/utils.py index 6e05ce10c95822..22520ca217d7d2 100644 --- a/src/sentry/seer/code_review/utils.py +++ b/src/sentry/seer/code_review/utils.py @@ -16,6 +16,7 @@ from sentry.integrations.github.utils import is_github_rate_limit_sensitive from sentry.integrations.github.webhook_types import GithubWebhookType from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization from sentry.models.repository import Repository from sentry.net.http import connection_from_url @@ -75,6 +76,11 @@ class SeerEndpoint(StrEnum): PR_REVIEW_RERUN = "/v1/code_review/check/rerun" CODE_REVIEW_REVIEW_REQUEST = "/v1/code_review/review-request" CODE_REVIEW_PR_CLOSED = "/v1/code_review/pr-closed" + # The scm_code_review endpoints route every SCM operation through the + # scm-platform RPC instead of direct PyGithub calls, so they are the + # provider-agnostic path required for non-GitHub providers like GitLab. + SCM_CODE_REVIEW_REVIEW_REQUEST = "/v1/scm_code_review/review-request" + SCM_CODE_REVIEW_PR_CLOSED = "/v1/scm_code_review/pr-closed" REPOSITORY_OFFBOARD = "/v1/offboarding/repository" @@ -354,22 +360,42 @@ def _build_repo_definition( ) -> dict[str, Any]: """ Build the repository definition for code review requests. - """ - # Extract owner and repo name from full repository name (format: "owner/repo") - repo_name_sections = repo.name.split("/") - if len(repo_name_sections) < 2: - raise ValueError(f"Invalid repository name format: {repo.name}") + GitHub and GitLab expose repo identity and visibility differently, so each + provider has its own builder. Anything unrecognized falls back to GitHub. + """ # repo.provider uses the "integrations:" format; Seer expects the bare slug provider = repo.provider.removeprefix("integrations:") if repo.provider else "github" + + if provider == IntegrationProviderSlug.GITLAB.value: + return _build_gitlab_repo_definition(repo, provider, target_commit_sha, event_payload) + return _build_github_repo_definition(repo, provider, target_commit_sha, event_payload) + + +def _split_full_name(full_name: str) -> tuple[str, str]: + """Split a "owner/repo" (or "group/subgroup/repo") slug into (owner, name).""" + sections = full_name.split("/") + if len(sections) < 2: + raise ValueError(f"Invalid repository name format: {full_name}") + return sections[0], "/".join(sections[1:]) + + +def _base_repo_definition( + repo: Repository, + provider: str, + owner: str, + name: str, + target_commit_sha: str, + is_private: bool | None, +) -> dict[str, Any]: repo_definition = { "provider": provider, - "owner": repo_name_sections[0], - "name": "/".join(repo_name_sections[1:]), + "owner": owner, + "name": name, "external_id": repo.external_id, "base_commit_sha": target_commit_sha, "organization_id": repo.organization_id, - "is_private": event_payload.get("repository", {}).get("private"), + "is_private": is_private, } # add integration_id which is used in pr_closed_step for product metrics dashboarding only @@ -379,6 +405,42 @@ def _build_repo_definition( return repo_definition +def _build_github_repo_definition( + repo: Repository, + provider: str, + target_commit_sha: str, + event_payload: Mapping[str, Any], +) -> dict[str, Any]: + # GitHub stores Repository.name as the "owner/repo" slug. + owner, name = _split_full_name(repo.name) + is_private = event_payload.get("repository", {}).get("private") + return _base_repo_definition(repo, provider, owner, name, target_commit_sha, is_private) + + +def _build_gitlab_repo_definition( + repo: Repository, + provider: str, + target_commit_sha: str, + event_payload: Mapping[str, Any], +) -> dict[str, Any]: + # GitLab stores Repository.name as the display "name_with_namespace" + # (e.g. "Cool Group / Sentry"), which is not a valid URL slug. The slug + # ("cool-group/sentry") lives in config["path"], kept current by the webhook's + # update_repo_data(), and is the only valid source for owner/name. Falling back + # to repo.name would silently produce slugs with spaces, so require the path. + path = repo.config.get("path") + if not path: + raise ValueError(f"GitLab repository {repo.id} is missing config['path']") + owner, name = _split_full_name(path) + + # GitLab has no repository.private; visibility lives in project.visibility_level + # (0 = private, 10 = internal, 20 = public). Leave as None when absent. + visibility_level = event_payload.get("project", {}).get("visibility_level") + is_private = visibility_level != 20 if visibility_level is not None else None + + return _base_repo_definition(repo, provider, owner, name, target_commit_sha, is_private) + + def get_pr_author_id(event: Mapping[str, Any]) -> str | None: """ Extract the PR author's GitHub user ID from the webhook payload. diff --git a/src/sentry/seer/code_review/webhooks/merge_request.py b/src/sentry/seer/code_review/webhooks/merge_request.py new file mode 100644 index 00000000000000..58f79200be4f87 --- /dev/null +++ b/src/sentry/seer/code_review/webhooks/merge_request.py @@ -0,0 +1,385 @@ +""" +Handler for GitLab merge_request webhook events. +https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events + +Known limitations +----------------- + +Code review does not fire in production yet: GitLab contributors are never seeded. +``handle_merge_request_event`` runs ``CodeReviewPreflightService``, whose +``_check_billing`` looks up ``OrganizationContributors`` by +``(organization_id, integration_id, external_identifier=str(author_id))`` and +returns ``ORG_CONTRIBUTOR_NOT_FOUND`` (before the beta exemption) when the row is +missing. GitHub creates that row via ``track_contributor_seat`` in +``PullRequestEventWebhook._handle`` on PR creation; the GitLab merge-request path +(PR persistence inline in ``MergeEventWebhook.__call__``) does not, and nothing +else seeds GitLab contributors. Until contributor seeding is added, every GitLab MR +is filtered with ``ORG_CONTRIBUTOR_NOT_FOUND``. The handler tests pass only because +they seed the row manually. + +The code-review tests seed OrganizationContributors manually; consider a test that +omits it to lock in the intended production behavior (related to Issue 1). + +GitLab has no dedicated "ready_for_review" action: un-drafting an MR arrives as an +"update" whose top-level ``changes`` flips draft/work_in_progress to false, which is +treated as an ON_READY_FOR_REVIEW trigger (see ``_resolve_review_trigger``). +""" + +from __future__ import annotations + +import enum +import logging +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + +from pydantic import ValidationError + +from sentry import features +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.models.organization import Organization +from sentry.models.repository import Repository +from sentry.models.repositorysettings import CodeReviewTrigger +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.code_review.models import ( + SeerCodeReviewTaskRequestForPrClosed, + SeerCodeReviewTaskRequestForPrReview, + SeerCodeReviewTrigger, +) +from sentry.utils import json +from sentry.utils.redis import redis_clusters + +from ..metrics import ( + WebhookFilteredReason, + record_webhook_enqueued, + record_webhook_filtered, + record_webhook_received, +) +from ..preflight import CodeReviewPreflightService +from ..utils import SeerEndpoint, _common_codegen_request_payload +from .task import process_github_webhook_event + +logger = logging.getLogger(__name__) + +GITLAB_WEBHOOK_EVENT = "merge_request" + +# GitLab redelivers webhooks (e.g. when our response times out), and the endpoint +# dispatches the same payload once per installed organization. Either can enqueue +# duplicate Seer review requests, so we skip a delivery already seen within this +# window. The key is scoped per organization/repo to keep distinct installs isolated. +WEBHOOK_SEEN_TTL_SECONDS = 20 +WEBHOOK_SEEN_KEY_PREFIX = "webhook:gitlab:merge_request:" + + +def _is_duplicate_delivery(seen_key: str) -> bool: + """ + Return True if this delivery was already processed within the TTL window. + + On Redis errors we return False (process anyway) since processing twice is + preferable to never processing. + """ + try: + cluster = redis_clusters.get("default") + is_first_time_seen = cluster.set(seen_key, "1", ex=WEBHOOK_SEEN_TTL_SECONDS, nx=True) + except Exception: + logger.warning("gitlab.webhook.merge_request.mark_seen_failed") + return False + return not is_first_time_seen + + +class MergeRequestAction(enum.StrEnum): + """ + GitLab merge request webhook actions. + https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events + """ + + OPEN = "open" + CLOSE = "close" + REOPEN = "reopen" + UPDATE = "update" + MERGE = "merge" + APPROVED = "approved" + UNAPPROVED = "unapproved" + + +WHITELISTED_ACTIONS = { + MergeRequestAction.CLOSE, + MergeRequestAction.MERGE, + MergeRequestAction.OPEN, + MergeRequestAction.UPDATE, +} + +CLOSE_ACTIONS = {MergeRequestAction.CLOSE, MergeRequestAction.MERGE} + +# Map the repo trigger that gated a review to the trigger value reported to Seer. +CODE_REVIEW_TO_SEER_TRIGGER: dict[CodeReviewTrigger, SeerCodeReviewTrigger] = { + CodeReviewTrigger.ON_READY_FOR_REVIEW: SeerCodeReviewTrigger.ON_READY_FOR_REVIEW, + CodeReviewTrigger.ON_NEW_COMMIT: SeerCodeReviewTrigger.ON_NEW_COMMIT, +} + + +def _is_undraft_update(changes: Mapping[str, Any]) -> bool: + """ + True when an "update" event marks a draft MR ready for review. + + GitLab has no dedicated "ready_for_review" action (unlike GitHub); un-drafting + arrives as an "update" whose ``changes`` shows draft/work_in_progress flipping + from true to false. ``changes`` is a top-level payload field, not part of + ``object_attributes``. + """ + for field in ("draft", "work_in_progress"): + change = changes.get(field) or {} + if change.get("previous") is True and change.get("current") is False: + return True + return False + + +def _resolve_review_trigger( + action: MergeRequestAction, event: Mapping[str, Any] +) -> CodeReviewTrigger | None: + """ + Map a non-close MR action to the repo trigger that gates a review, or None when + the event should not start one. + + "open" is a ready-for-review trigger unless the MR is opened as a draft. "update" + is ambiguous because GitLab fires it for any edit, so it triggers a review only + when it brings new commits (ON_NEW_COMMIT) or marks the MR ready for review + (ON_READY_FOR_REVIEW). + """ + if action == MergeRequestAction.OPEN: + # An MR opened as a draft is not ready for review. GitLab sets + # object_attributes.draft (legacy: work_in_progress) from the "Draft:" + # title prefix; un-drafting later arrives as an "update" (_is_undraft_update). + object_attributes = event.get("object_attributes") or {} + if ( + object_attributes.get("draft") is True + or object_attributes.get("work_in_progress") is True + ): + return None + return CodeReviewTrigger.ON_READY_FOR_REVIEW + if action == MergeRequestAction.UPDATE: + # GitLab puts "changes" at the top level of the payload, while "oldrev" + # (present only when commits were pushed) lives in "object_attributes". + if _is_undraft_update(event.get("changes") or {}): + return CodeReviewTrigger.ON_READY_FOR_REVIEW + if "oldrev" in (event.get("object_attributes") or {}): + return CodeReviewTrigger.ON_NEW_COMMIT + return None + + +def handle_merge_request_event( + *, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + integration: RpcIntegration | None = None, + **kwargs: Any, +) -> None: + """Handle GitLab merge request webhook events for code review.""" + if integration is None: + return + + if not features.has("organizations:seer-code-review-gitlab", organization): + return + + object_attributes = event.get("object_attributes", {}) + action_value = object_attributes.get("action") + if not action_value or not isinstance(action_value, str): + return + + record_webhook_received(GITLAB_WEBHOOK_EVENT, action_value) + + try: + action = MergeRequestAction(action_value) + except ValueError: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + if action not in WHITELISTED_ACTIONS: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + # GitLab fires "update" for any MR edit (title, labels, assignee, etc.), so a + # non-close action only starts a review when it maps to a repo trigger: a new + # commit (ON_NEW_COMMIT) or the MR being opened / marked ready (ON_READY_FOR_REVIEW). + review_trigger: CodeReviewTrigger | None = None + if action not in CLOSE_ACTIONS: + review_trigger = _resolve_review_trigger(action, event) + if review_trigger is None: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + try: + org = Organization.objects.get_from_cache(id=organization.id) + except Organization.DoesNotExist: + return + + author_id = object_attributes.get("author_id") + preflight = CodeReviewPreflightService( + organization=org, + repo=repo, + integration_id=integration.id, + pr_author_external_id=str(author_id) if author_id else None, + ).check() + + if not preflight.allowed: + if preflight.denial_reason: + record_webhook_filtered(GITLAB_WEBHOOK_EVENT, action_value, preflight.denial_reason) + return + + org_code_review_settings = preflight.settings + + if review_trigger is not None and ( + org_code_review_settings is None or review_trigger not in org_code_review_settings.triggers + ): + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.TRIGGER_DISABLED + ) + return + + if action in CLOSE_ACTIONS and ( + org_code_review_settings is None or not org_code_review_settings.triggers + ): + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.TRIGGER_DISABLED + ) + return + + if action not in CLOSE_ACTIONS: + if ( + object_attributes.get("draft") is True + or object_attributes.get("work_in_progress") is True + ): + return + + last_commit = object_attributes.get("last_commit") or {} + target_commit_sha = last_commit.get("id") + if not target_commit_sha: + return + + seen_key = ( + f"{WEBHOOK_SEEN_KEY_PREFIX}{org.id}:{repo.id}:" + f"{object_attributes.get('iid')}:{action_value}:{target_commit_sha}" + ) + if _is_duplicate_delivery(seen_key): + logger.warning("gitlab.webhook.merge_request.duplicate_delivery_skipped") + return + + _schedule_task( + action=action, + action_value=action_value, + event=event, + organization=org, + repo=repo, + target_commit_sha=target_commit_sha, + review_trigger=review_trigger, + ) + + +def _get_trigger_metadata(event: Mapping[str, Any]) -> dict[str, Any]: + user = event.get("user", {}) + object_attributes = event.get("object_attributes", {}) + trigger_at = ( + object_attributes.get("updated_at") + or object_attributes.get("created_at") + or datetime.now(timezone.utc).isoformat() + ) + return { + "trigger_user": user.get("username"), + "trigger_user_id": user.get("id"), + "trigger_comment_id": None, + "trigger_comment_type": None, + "trigger_at": trigger_at, + } + + +def _build_payload( + action: MergeRequestAction, + event: Mapping[str, Any], + organization: Organization, + repo: Repository, + target_commit_sha: str, + review_trigger: CodeReviewTrigger | None, +) -> dict[str, Any]: + is_close = action in CLOSE_ACTIONS + payload = _common_codegen_request_payload( + add_experiment_enabled=not is_close, + repo=repo, + target_commit_sha=target_commit_sha, + organization=organization, + event_payload=event, + ) + + object_attributes = event.get("object_attributes", {}) + payload["data"]["pr_id"] = object_attributes.get("iid") + + config = payload["data"]["config"] + trigger_metadata = _get_trigger_metadata(event) + seer_trigger = ( + CODE_REVIEW_TO_SEER_TRIGGER[review_trigger] + if review_trigger is not None + else SeerCodeReviewTrigger.UNKNOWN + ) + config["trigger"] = seer_trigger.value + config["trigger_user"] = trigger_metadata["trigger_user"] + config["trigger_user_id"] = trigger_metadata["trigger_user_id"] + config["trigger_comment_id"] = trigger_metadata["trigger_comment_id"] + config["trigger_comment_type"] = trigger_metadata["trigger_comment_type"] + config["trigger_at"] = trigger_metadata["trigger_at"] + config["sentry_received_trigger_at"] = datetime.now(timezone.utc).isoformat() + + return payload + + +def _schedule_task( + *, + action: MergeRequestAction, + action_value: str, + event: Mapping[str, Any], + organization: Organization, + repo: Repository, + target_commit_sha: str, + review_trigger: CodeReviewTrigger | None, +) -> None: + payload = _build_payload(action, event, organization, repo, target_commit_sha, review_trigger) + + # GitLab is not supported by the direct-PyGithub /v1/code_review/* endpoints; + # it must use the scm-platform RPC counterparts at /v1/scm_code_review/*. + is_closed = action in CLOSE_ACTIONS + seer_path = ( + SeerEndpoint.SCM_CODE_REVIEW_PR_CLOSED.value + if is_closed + else SeerEndpoint.SCM_CODE_REVIEW_REVIEW_REQUEST.value + ) + + try: + validated: SeerCodeReviewTaskRequestForPrClosed | SeerCodeReviewTaskRequestForPrReview + if is_closed: + validated = SeerCodeReviewTaskRequestForPrClosed.parse_obj(payload) + else: + validated = SeerCodeReviewTaskRequestForPrReview.parse_obj(payload) + serialized_payload = json.loads(validated.json()) + except ValidationError: + logger.warning("gitlab.webhook.merge_request.validation_failed") + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.INVALID_PAYLOAD + ) + return + + process_github_webhook_event.delay( + seer_path=seer_path, + event_payload=serialized_payload, + tags={ + "sentry_organization_id": str(organization.id), + "sentry_organization_slug": organization.slug, + "sentry_integration_id": str(repo.integration_id) if repo.integration_id else "", + "scm_provider": "gitlab", + }, + ) + record_webhook_enqueued(GITLAB_WEBHOOK_EVENT, action_value) diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py index 9b13fdb3e3707a..8b0370fca7786f 100644 --- a/src/sentry/seer/endpoints/project_seer_settings.py +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -19,6 +19,7 @@ from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter from sentry.api.event_search import parse_search_query as base_parse_search_query from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.constants import ( AUTOFIX_AUTOMATION_TUNING_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, @@ -36,9 +37,9 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, AutomationCodingAgent, - bulk_update_seer_project_settings, get_automation_handoff, get_valid_automated_run_stopping_points, + is_seer_seat_based_tier_enabled, update_seer_project_settings, ) from sentry.seer.models.project_repository import SeerProjectRepository @@ -80,6 +81,8 @@ class SeerProjectSettingsResponse(TypedDict): agent: str integrationId: str | None stoppingPoint: str + autoCreatePr: bool | None + automationTuning: str scannerAutomation: bool reposCount: int @@ -154,9 +157,11 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe # No configured external handoff means use Seer agent. agent: str = "seer" integration_id: str | None = None + auto_create_pr: bool | None = None else: agent = handoff.target integration_id = str(handoff.integration_id) + auto_create_pr = handoff.auto_create_pr return SeerProjectSettingsResponse( projectId=str(project.id), @@ -164,6 +169,8 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe agent=agent, integrationId=integration_id, stoppingPoint=stopping_point, + autoCreatePr=auto_create_pr, + automationTuning=settings["automation_tuning"], scannerAutomation=settings["scanner_automation"], reposCount=settings["repos_count"], ) @@ -307,22 +314,19 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -class ProjectSettingsUpdateSerializer(serializers.Serializer): +class _BaseProjectSettingsUpdateSerializer(CamelSnakeSerializer): agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False) - integrationId = serializers.IntegerField(required=False) - stoppingPoint = serializers.ChoiceField(choices=["off", *AutofixStoppingPoint], required=False) - scannerAutomation = serializers.BooleanField(required=False) + integration_id = serializers.IntegerField(required=False) + stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False) + scanner_automation = serializers.BooleanField(required=False) + automation_tuning = serializers.ChoiceField( + choices=[*AutofixAutomationTuningSettings], required=False + ) - def validate_stoppingPoint(self, value: str) -> str: - if value == "off": - return value + def _update_fields(self) -> set[str]: + return {"agent", "stopping_point", "scanner_automation", "automation_tuning"} - organization = self.context["organization"] - if value not in get_valid_automated_run_stopping_points(organization): - raise serializers.ValidationError(f'"{value}" is not a valid choice.') - return value - - def validate_integrationId(self, value: int) -> int: + def validate_integration_id(self, value: int) -> int: organization = self.context["organization"] org_integrations = integration_service.get_organization_integrations( organization_id=organization.id, integration_id=value @@ -332,28 +336,59 @@ def validate_integrationId(self, value: int) -> int: return value def validate(self, data): - if "agent" in data and data["agent"] != "seer" and "integrationId" not in data: + if "agent" in data and data["agent"] != "seer" and "integration_id" not in data: raise serializers.ValidationError( - {"integrationId": "Required when agent is an external coding agent."} + {"integration_id": "Required when agent is an external coding agent."} ) - if "integrationId" in data: + if "integration_id" in data: if "agent" not in data: raise serializers.ValidationError( - {"agent": "Required when integrationId is provided."} + {"agent": "Required when integration_id is provided."} ) elif data["agent"] == "seer": raise serializers.ValidationError( - {"agent": "Must be an external coding agent when integrationId is provided."} + {"agent": "Must be an external coding agent when integration_id is provided."} ) - has_update = any(k in data for k in ("agent", "stoppingPoint", "scannerAutomation")) - if not has_update: + if not any(k in data for k in self._update_fields()): raise serializers.ValidationError("At least one update field must be provided.") return data +class ProjectSettingsUpdateSerializer(_BaseProjectSettingsUpdateSerializer): + """Seat-based (new) Seer: restricted tuning choices, stopping point sync.""" + + automation_tuning = serializers.ChoiceField( + choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM], + required=False, + ) + + def validate_stopping_point(self, value: str) -> str: + if value not in get_valid_automated_run_stopping_points(self.context["organization"]): + raise serializers.ValidationError(f'"{value}" is not a valid choice.') + return value + + def validate(self, data): + data = super().validate(data) + + # Keep stopping point in sync with handoff auto_create_pr. + if "stopping_point" in data and "auto_create_pr" not in data: + data["auto_create_pr"] = data["stopping_point"] == AutofixStoppingPoint.OPEN_PR + + return data + + +class LegacyProjectSettingsUpdateSerializer(_BaseProjectSettingsUpdateSerializer): + """Legacy Seer: accepts auto_create_pr and all tuning/stopping point values.""" + + auto_create_pr = serializers.BooleanField(required=False) + + def _update_fields(self) -> set[str]: + return super()._update_fields() | {"auto_create_pr"} + + @cell_silo_endpoint class ProjectSeerSettingsEndpoint(ProjectEndpoint): owner = ApiOwner.ML_AI @@ -367,26 +402,26 @@ def get(self, request: Request, project: Project) -> Response: return Response(serialize_project(project)) def put(self, request: Request, project: Project) -> Response: - serializer = ProjectSettingsUpdateSerializer( + serializer_cls = ( + ProjectSettingsUpdateSerializer + if is_seer_seat_based_tier_enabled(project.organization) + else LegacyProjectSettingsUpdateSerializer + ) + serializer = serializer_cls( data=request.data, context={"organization": project.organization} ) if not serializer.is_valid(): return Response(serializer.errors, status=400) - update_seer_project_settings(project, serializer.validated_data) + data = serializer.validated_data + update_seer_project_settings([project.id], data) self.create_audit_entry( request=request, organization=project.organization, target_object=project.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_id": project.id, - "agent": serializer.validated_data.get("agent"), - "integration_id": serializer.validated_data.get("integrationId"), - "stopping_point": serializer.validated_data.get("stoppingPoint"), - "scanner_automation": serializer.validated_data.get("scannerAutomation"), - }, + data={"project_id": project.id, **data}, ) return Response(serialize_project(project)) @@ -396,6 +431,10 @@ class BulkProjectSettingsUpdateSerializer(ProjectSettingsUpdateSerializer): query = serializers.CharField(required=False, default="") +class LegacyBulkProjectSettingsUpdateSerializer(LegacyProjectSettingsUpdateSerializer): + query = serializers.CharField(required=False, default="") + + @cell_silo_endpoint class OrganizationSeerProjectSettingsEndpoint(OrganizationEndpoint): owner = ApiOwner.ML_AI @@ -433,9 +472,12 @@ def get(self, request: Request, organization: Organization) -> Response: ) def put(self, request: Request, organization: Organization) -> Response: - serializer = BulkProjectSettingsUpdateSerializer( - data=request.data, context={"organization": organization} + serializer_cls = ( + BulkProjectSettingsUpdateSerializer + if is_seer_seat_based_tier_enabled(organization) + else LegacyBulkProjectSettingsUpdateSerializer ) + serializer = serializer_cls(data=request.data, context={"organization": organization}) if not serializer.is_valid(): return Response(serializer.errors, status=400) @@ -455,21 +497,15 @@ def put(self, request: Request, organization: Organization) -> Response: return Response({"detail": "Invalid search query"}, status=400) projects = list(queryset) - bulk_update_seer_project_settings(projects, data) + if projects: + update_seer_project_settings([p.id for p in projects], data) self.create_audit_entry( request=request, organization=organization, target_object=organization.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_count": len(projects), - "project_ids": [p.id for p in projects], - "agent": data.get("agent"), - "integration_id": data.get("integrationId"), - "stopping_point": data.get("stoppingPoint"), - "scanner_automation": data.get("scannerAutomation"), - }, + data={"project_count": len(projects), "project_ids": [p.id for p in projects], **data}, ) return Response(status=204) diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 40a02faeff9f1a..72e1171b14f9b0 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -20,12 +20,12 @@ from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + AutomationCodingAgent, + SeerProjectSettingsUpdate, get_org_default_seer_automation_handoff, is_seer_seat_based_tier_enabled, - write_preference_to_sentry_db, -) -from sentry.seer.models import ( - SeerProjectPreference, + update_seer_project_settings, ) from sentry.seer.similarity.types import GroupingVersion from sentry.services.eventstore.models import Event, GroupEvent @@ -575,16 +575,17 @@ def set_default_project_seer_preferences(organization: Organization, project: Pr stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization) - preference = SeerProjectPreference( - organization_id=organization.id, - project_id=project.id, - repositories=[], - automated_run_stopping_point=stopping_point, - automation_handoff=automation_handoff, - ) + update = SeerProjectSettingsUpdate(stopping_point=stopping_point) + if automation_handoff is not None: + update["agent"] = AutomationCodingAgent(automation_handoff.target) + update["integration_id"] = automation_handoff.integration_id + update["auto_create_pr"] = automation_handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + update["auto_create_pr"] = stopping_point == AutofixStoppingPoint.OPEN_PR try: - write_preference_to_sentry_db(project, preference) + update_seer_project_settings([project.id], update) except Exception as e: sentry_sdk.capture_exception(e) diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index 4f490296f4ccfd..566bd5a5f4bdb3 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -12,9 +12,10 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.helpers.group_index import get_group_list from sentry.api.serializers import serialize from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse -from sentry.models.group import STATUS_QUERY_CHOICES, Group +from sentry.models.group import STATUS_QUERY_CHOICES from sentry.models.groupassignee import GroupAssignee from sentry.models.organization import Organization from sentry.models.team import Team @@ -72,12 +73,8 @@ def get(self, request: Request, organization: Organization) -> Response: status=status_codes.HTTP_400_BAD_REQUEST, ) - valid_group_ids = set( - Group.objects.filter( - id__in=group_ids, - project__organization=organization, - ).values_list("id", flat=True) - ) + projects = self.get_projects(request, organization) + valid_group_ids = {g.id for g in get_group_list(organization.id, projects, group_ids)} group_ids = [gid for gid in group_ids if gid in valid_group_ids] if not group_ids: @@ -98,13 +95,11 @@ def get(self, request: Request, organization: Organization) -> Response: return Response({"data": data["data"], "meta": {"estimated": True}}) if status_param: - matching_ids = set( - Group.objects.filter( - id__in=all_response_group_ids, - project__organization=organization, - status=STATUS_QUERY_CHOICES[status_param], - ).values_list("id", flat=True) - ) + matching_ids = { + g.id + for g in get_group_list(organization.id, projects, list(all_response_group_ids)) + if g.status == STATUS_QUERY_CHOICES[status_param] + } for sg in data["data"]: sg["group_ids"] = [gid for gid in sg["group_ids"] if gid in matching_ids] diff --git a/src/sentry/sentry_apps/services/legacy_webhook/service.py b/src/sentry/sentry_apps/services/legacy_webhook/service.py index c551d5ed182cd5..f83bd77d55bebd 100644 --- a/src/sentry/sentry_apps/services/legacy_webhook/service.py +++ b/src/sentry/sentry_apps/services/legacy_webhook/service.py @@ -135,6 +135,10 @@ def send_legacy_webhooks_for_invocation(invocation: ActionInvocation) -> None: from sentry.sentry_apps.services.legacy_webhook.tasks import send_legacy_webhook_task project = invocation.detector.project + enabled = ProjectOption.objects.get_value(project, "webhooks:enabled", default=False) + if not enabled: + return + urls_raw = ProjectOption.objects.get_value(project, "webhooks:urls", default="") urls = split_urls(urls_raw) if not urls: diff --git a/src/sentry/spans/consumers/process_segments/enrichment.py b/src/sentry/spans/consumers/process_segments/enrichment.py index 56569e7781fa7f..d41f20cdd761eb 100644 --- a/src/sentry/spans/consumers/process_segments/enrichment.py +++ b/src/sentry/spans/consumers/process_segments/enrichment.py @@ -17,16 +17,6 @@ # is taken from `extract_shared_tags` in Relay. SHARED_SENTRY_ATTRIBUTES = ( ATTRIBUTE_NAMES.SENTRY_RELEASE, - "sentry.user", - "sentry.user.id", - "sentry.user.ip", - "sentry.user.username", - "sentry.user.email", - "sentry.user.geo.city", - "sentry.user.geo.country_code", - "sentry.user.geo.region", - "sentry.user.geo.subdivision", - "sentry.user.geo.subregion", ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT, ATTRIBUTE_NAMES.SENTRY_TRANSACTION, "sentry.transaction.method", @@ -42,6 +32,28 @@ ATTRIBUTE_NAMES.SENTRY_PLATFORM, "sentry.thread.id", "sentry.thread.name", + # Current user attributes + ATTRIBUTE_NAMES.USER_EMAIL, + ATTRIBUTE_NAMES.USER_GEO_CITY, + ATTRIBUTE_NAMES.USER_GEO_COUNTRY_CODE, + ATTRIBUTE_NAMES.USER_GEO_REGION, + ATTRIBUTE_NAMES.USER_GEO_SUBDIVISION, + ATTRIBUTE_NAMES.USER_ID, + ATTRIBUTE_NAMES.USER_IP_ADDRESS, + ATTRIBUTE_NAMES.USER_NAME, + # Legacy user attributes, taken from sentry_tags. + # TODO(mjq): Remove these once everything is switched over to the new + # conventional attribute names. See BROWSE-535. + "sentry.user", + "sentry.user.id", + "sentry.user.ip", + "sentry.user.username", + "sentry.user.email", + "sentry.user.geo.city", + "sentry.user.geo.country_code", + "sentry.user.geo.region", + "sentry.user.geo.subdivision", + "sentry.user.geo.subregion", ) # The name of the main thread used to infer the `main_thread` flag in spans from diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 508047eaeecab1..2f658906c24f5d 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -20,14 +20,16 @@ SeerAutomationSource, ) from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + AutomationCodingAgent, + SeerProjectSettingsUpdate, bulk_read_preferences_from_sentry_db, - bulk_write_preferences_to_sentry_db, get_autofix_state, get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, get_valid_automated_run_stopping_points, + update_seer_project_settings, ) -from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import ingest_errors_tasks, issues_tasks from sentry.utils import metrics @@ -246,16 +248,13 @@ def configure_seer_for_existing_org(organization_id: int) -> None: preferences = bulk_read_preferences_from_sentry_db(organization_id, project_ids) # Determine which projects need updates - preferences_to_set: list[SeerProjectPreference] = [] - for project_id in project_ids: + preferences_set = 0 + for project in projects: stopping_point = default_stopping_point handoff = default_handoff - repositories: list[SeerRepoDefinition] = [] - existing_pref = preferences.get(project_id) + existing_pref = preferences.get(project.id) if existing_pref: - repositories = existing_pref.repositories - existing_stopping_point = existing_pref.automated_run_stopping_point existing_handoff = existing_pref.automation_handoff @@ -271,18 +270,17 @@ def configure_seer_for_existing_org(organization_id: int) -> None: if existing_handoff: handoff = existing_handoff - preferences_to_set.append( - SeerProjectPreference( - organization_id=organization_id, - project_id=project_id, - repositories=repositories, - automated_run_stopping_point=stopping_point, - automation_handoff=handoff, - ) - ) + update = SeerProjectSettingsUpdate(stopping_point=stopping_point) + if handoff is not None: + update["agent"] = AutomationCodingAgent(handoff.target) + update["integration_id"] = handoff.integration_id + update["auto_create_pr"] = handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + update["auto_create_pr"] = stopping_point == AutofixStoppingPoint.OPEN_PR - if len(preferences_to_set) > 0: - bulk_write_preferences_to_sentry_db(projects, preferences_to_set) + update_seer_project_settings([project.id], update) + preferences_set += 1 # Invalidate existing cache entry and set cache to True to prevent race conditions where another # request re-caches False before the billing flag has fully propagated @@ -294,6 +292,6 @@ def configure_seer_for_existing_org(organization_id: int) -> None: "org_id": organization.id, "org_slug": organization.slug, "projects_configured": len(project_ids), - "preferences_set": len(preferences_to_set), + "preferences_set": preferences_set, }, ) diff --git a/src/sentry/tasks/seer/night_shift/agentic_triage.py b/src/sentry/tasks/seer/night_shift/agentic_triage.py index 0930721e73afe3..be2d7141121010 100644 --- a/src/sentry/tasks/seer/night_shift/agentic_triage.py +++ b/src/sentry/tasks/seer/night_shift/agentic_triage.py @@ -314,18 +314,46 @@ def _build_triage_prompt( When evaluating each issue, consider whether an AI coding agent with full codebase access could fix the ROOT CAUSE of the issue — not just add try/except or defensive - checks around it. Use these criteria: - - Clearly fixable in code (-> autofix): - - The bug is a clear mistake in application logic (wrong key, off-by-one, - missing None check on app data) - - Root cause is visible in application code within a connected repository - - Straightforward change to business logic - - Worth investigating but not auto-fixable (-> root_cause_only): - - Likely fixable but requires non-trivial investigation or cross-cutting changes - - Error originates in third-party libraries, vendor code, or framework internals - - Root cause is outside the code (filesystem, external services, environment) + checks around it. + + The verdicts form a ladder of increasing caution. Default to the LEAST + aggressive verdict that fits, and only step up to `autofix` when you have + cleared a high bar. When you are torn between two verdicts, always pick + the more conservative one (`root_cause_only` over `autofix`, `skip` over + `root_cause_only`). + + Autofix actually opens a code change with no human in the loop before it + is written, so reserve it for issues you are CONVINCED can be fixed + correctly and automatically. Choose `autofix` ONLY when ALL of the + following hold: + - You have pinpointed the exact root cause in application code (specific + file and function), confirmed by reading the code — not a hypothesis. + - There is exactly ONE clearly-correct fix. If several plausible fixes + exist and choosing between them depends on product intent or domain + knowledge you don't have, it is NOT an autofix. + - The fix is small and localized. It does not require a redesign, a new + API or abstraction, a schema/data migration, or coordinated changes + across many files or systems. + - Applying the fix cannot plausibly change intended behavior or make + things worse, and needs no human judgment to confirm it is correct. + - The change is not in a high-blast-radius area — authentication, + permissions/access control, billing or payments, security, money + math, concurrency/locking, or data deletion/migration — unless the fix + is truly trivial and obviously correct. + - You would be comfortable shipping this fix without a human reviewing + the approach first. + If you cannot honestly check every box, do NOT autofix. + + Worth investigating but not safe to auto-fix (-> root_cause_only): + - Likely fixable, but you are not fully confident the fix is correct, or + it needs human judgment, or it touches a high-blast-radius area. + - The fix requires non-trivial investigation, design decisions, or + cross-cutting changes. + - Error originates in third-party libraries, vendor code, or framework internals. + - Root cause is outside the code (filesystem, external services, environment). + This is the default home for anything fixable that doesn't clear the + autofix bar — investigating the root cause is still valuable, and a human + decides what to do with it. Not worth processing (-> skip): - The issue is vague with no actionable stacktrace @@ -337,7 +365,8 @@ def _build_triage_prompt( the issue is to be fixable (0.0 = not fixable, 1.0 = very fixable). Issues marked "not scored" have not been evaluated yet — treat them neutrally rather than assuming they are unfixable. Use the score as a signal but verify with - your own investigation. + your own investigation. A high fixability score is NOT on its own a reason + to autofix — it only means investigation is likely worthwhile. For each verdict, fill the `reason` field. For `autofix` and `root_cause_only` verdicts, the `reason` is handed off as context to the downstream autofix agent diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index d7a7636749ba59..4e822ef8e04e18 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -343,7 +343,6 @@ def run_night_shift_execution( results = _run_autofix_for_candidates( run=run, candidates=candidates, - options=resolved_options, stopping_point_by_project_id=stopping_point_by_project_id, log_extra=log_extra, ) @@ -493,7 +492,6 @@ def _get_eligible_projects( def _run_autofix_for_candidates( run: SeerNightShiftRun, candidates: Sequence[TriageResult], - options: SeerNightShiftRunOptions, stopping_point_by_project_id: Mapping[int, AutofixStoppingPoint], log_extra: dict[str, object], ) -> list[SeerNightShiftRunResult]: @@ -534,8 +532,6 @@ def _run_autofix_for_candidates( step=AutofixStep.ROOT_CAUSE, referrer=referrer, stopping_point=stopping_point, - intelligence_level=options["intelligence_level"], - reasoning_effort=options["reasoning_effort"], user_context=user_context, ) except Exception: diff --git a/src/sentry/templates/sentry/integrations/bitbucket-server-config.html b/src/sentry/templates/sentry/integrations/bitbucket-server-config.html deleted file mode 100644 index d2e8936089f5e2..00000000000000 --- a/src/sentry/templates/sentry/integrations/bitbucket-server-config.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "sentry/bases/modal.html" %} -{% load crispy_forms_tags %} -{% load sentry_assets %} -{% load i18n %} - -{% block css %} - -{% endblock %} - -{% block wrapperclass %} narrow auth {% endblock %} -{% block modal_header_signout %} {% endblock %} - -{% block title %} {% trans "Bitbucket-Server Setup" %} | {{ block.super }} {% endblock %} - -{% block main %} -

{% trans "Connect Sentry with your App" %}

-

{% trans "Add your Bitbucket Server App credentials to Sentry." %}

-

- - - {% blocktrans %} - You must complete the required steps - - in Bitbucket Server before attempting to connect with Sentry. - {% endblocktrans %} - -

-
- {% csrf_token %} - - - {{ form|as_crispy_errors }} - - {% for field in form %} - {{ field|as_crispy_field }} - {% endfor %} - -
-
- -
-
-
-{% endblock %} diff --git a/src/sentry/users/api/serializers/user.py b/src/sentry/users/api/serializers/user.py index 6bbb5c2553dcff..36bf5f5d2d119c 100644 --- a/src/sentry/users/api/serializers/user.py +++ b/src/sentry/users/api/serializers/user.py @@ -12,7 +12,6 @@ from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.elevated_mode import has_elevated_mode from sentry.hybridcloud.services.organization_mapping import organization_mapping_service @@ -34,6 +33,12 @@ from sentry.utils.serializers import manytoone_to_dict +class SerializedAvatarFields(TypedDict, total=False): + avatarType: str + avatarUuid: str | None + avatarUrl: str | None + + class _UserEmails(TypedDict): id: str email: str diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index 7ac7b3d8b4c687..97e45cf7bdf780 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -12,7 +12,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.commit import CommitSerializer, get_users_for_commits -from sentry.api.serializers.models.release import Author, NonMappableUser +from sentry.api.serializers.release_details_types import Author, NonMappableUser from sentry.models.commit import Commit from sentry.models.commitfilechange import CommitFileChange from sentry.models.group import Group diff --git a/src/sentry/utils/signing.py b/src/sentry/utils/signing.py index ae6667991585e8..4a776cedc16934 100644 --- a/src/sentry/utils/signing.py +++ b/src/sentry/utils/signing.py @@ -24,9 +24,12 @@ def sign(*, salt: str = SALT, **kwargs: object) -> str: ) -def unsign(data: str | bytes, salt: str = SALT, max_age: int = 60 * 60 * 24 * 2) -> dict[str, Any]: +def unsign(data: str | bytes, salt: str = SALT, max_age: int = 60 * 60 * 24 * 2) -> Any: """ - Unsign a signed base64 string. Accepts the base64 value as a string or bytes + Unsign a signed base64 string. Accepts the base64 value as a string or bytes. + + Returns the decoded payload. Typed as ``Any`` (like ``json.loads``) so + callers can annotate the concrete shape they expect without casting. """ return loads( TimestampSigner(salt=salt).unsign(urlsafe_b64decode(data).decode(), max_age=max_age) diff --git a/static/app/components/assistant/getGuidesContent.tsx b/static/app/components/assistant/getGuidesContent.tsx index 39b7e0ff26afe0..b69bfc63a7d940 100644 --- a/static/app/components/assistant/getGuidesContent.tsx +++ b/static/app/components/assistant/getGuidesContent.tsx @@ -10,67 +10,6 @@ export function getGuidesContent(): GuidesContent { return getDemoModeGuides(); } return [ - { - guide: 'issue', - requiredTargets: ['issue_header_stats', 'breadcrumbs', 'issue_sidebar_owners'], - steps: [ - { - title: t('How bad is it?'), - target: 'issue_header_stats', - description: t( - `You have Issues and that's fine. - Understand impact at a glance by viewing total issue frequency and affected users.` - ), - }, - { - title: t('Find problematic releases'), - target: 'issue_sidebar_releases', - description: t( - 'See which release introduced the issue and which release it last appeared in.' - ), - }, - { - title: t('Not your typical stack trace'), - target: 'stacktrace', - description: t( - `Sentry can show your source code in the stack trace. - See the exact sequence of function calls leading to the error in question.` - ), - }, - { - // TODO(streamline-ui): Remove from guides on GA, tag sidebar is gone - title: t('Pinpoint hotspots'), - target: 'issue_sidebar_tags', - description: t( - 'Tags are key/value string pairs that are automatically indexed and searchable in Sentry.' - ), - }, - { - title: t('Retrace Your Steps'), - target: 'breadcrumbs', - description: t( - `Not sure how you got here? Sentry automatically captures breadcrumbs for - events your user and app took that led to the error.` - ), - }, - { - title: t('Annoy the Right People'), - target: 'issue_sidebar_owners', - description: t( - `Automatically assign issues to the person who introduced the commit, - notify them over notification tools like Slack, - and triage through issue management tools like Jira. ` - ), - }, - { - title: t('Onboarding'), - target: 'onboarding_sidebar', - description: t( - 'Walk through this guide to get the most out of Sentry right away.' - ), - }, - ], - }, { guide: 'issue_stream', requiredTargets: ['issue_stream'], diff --git a/static/app/components/assistant/guideAnchor.spec.tsx b/static/app/components/assistant/guideAnchor.spec.tsx index c6fa62504ec1ec..893ba4681e0d95 100644 --- a/static/app/components/assistant/guideAnchor.spec.tsx +++ b/static/app/components/assistant/guideAnchor.spec.tsx @@ -10,11 +10,11 @@ import {GuideStore} from 'sentry/stores/guideStore'; describe('GuideAnchor', () => { const serverGuide = [ { - guide: 'issue', + guide: 'trace_view', seen: false, }, ]; - const firstGuideHeader = 'How bad is it?'; + const firstGuideHeader = 'Event Breakdown'; beforeEach(() => { ConfigStore.loadInitialData( @@ -30,29 +30,25 @@ describe('GuideAnchor', () => { it('renders, async advances, async and finishes', async () => { render(
- - - + + +
); act(() => GuideStore.fetchSucceeded(serverGuide)); expect(await screen.findByText(firstGuideHeader)).toBeInTheDocument(); - // XXX(epurkhiser): Skip pointer event checks due to a bug with how Popper - // renders the hovercard with pointer-events: none. See [0] - // - // [0]: https://github.com/testing-library/user-event/issues/639 - // - // NOTE(epurkhiser): We may be able to remove the skipPointerEventsCheck - // when we're on popper >= 1. await userEvent.click(screen.getByLabelText('Next')); - expect(await screen.findByText('Retrace Your Steps')).toBeInTheDocument(); + expect(await screen.findByText('Events')).toBeInTheDocument(); expect(screen.queryByText(firstGuideHeader)).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Next')); + expect(await screen.findByText('Event Details')).toBeInTheDocument(); + expect(screen.queryByText('Events')).not.toBeInTheDocument(); + // Clicking on the button in the last step should finish the guide. const finishMock = MockApiClient.addMockResponse({ method: 'PUT', @@ -66,7 +62,7 @@ describe('GuideAnchor', () => { expect.objectContaining({ method: 'PUT', data: { - guide: 'issue', + guide: 'trace_view', status: 'viewed', }, }) @@ -76,9 +72,9 @@ describe('GuideAnchor', () => { it('dismisses', async () => { render(
- - - + + +
); @@ -97,7 +93,7 @@ describe('GuideAnchor', () => { expect.objectContaining({ method: 'PUT', data: { - guide: 'issue', + guide: 'trace_view', status: 'dismissed', }, }) @@ -131,9 +127,9 @@ describe('GuideAnchor', () => { it('if forceHide is true, async do not render guide', async () => { render(
- - - + + +
); diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 7dd26ef2680367..1bbf8d0c622922 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -345,7 +345,11 @@ export function GlobalCommandPaletteActions() { }} limit={4}> - + {organization.features.includes('ourlogs-enabled') && ( )} @@ -365,17 +369,20 @@ export function GlobalCommandPaletteActions() { {organization.features.includes('profiling') && ( )} {organization.features.includes('session-replay-ui') && ( )} {organization.features.includes('gen-ai-conversations') && ( @@ -529,6 +536,7 @@ export function GlobalCommandPaletteActions() { )} @@ -766,7 +774,7 @@ export function GlobalCommandPaletteActions() { /> }} - keywords={[t('add alert')]} + keywords={[t('add alert'), t('alert rules'), t('issue alert')]} to={`${prefix}/issues/alerts/wizard/`} /> - + { expect(await screen.findByText('Failed to save')).toBeInTheDocument(); }); + it('shows detail message from RequestError', async () => { + function TestComponent() { + return ( + { + const error = new RequestError('POST', '/test/', new Error('test')); + error.responseJSON = {detail: 'Organization is suspended'}; + throw error; + }, + }} + > + {field => ( + + + + + )} + + ); + } + + render(); + + const input = screen.getByRole('textbox', {name: 'Name'}); + await userEvent.clear(input); + await userEvent.type(input, 'new value'); + await userEvent.tab(); + + expect(await screen.findByText('Organization is suspended')).toBeInTheDocument(); + }); + it('shows generic error when RequestError has no responseJSON', async () => { function TestComponent() { return ( diff --git a/static/app/components/core/form/autoSaveForm.tsx b/static/app/components/core/form/autoSaveForm.tsx index 89828d7fbf9d42..5c39a01551fbe8 100644 --- a/static/app/components/core/form/autoSaveForm.tsx +++ b/static/app/components/core/form/autoSaveForm.tsx @@ -13,6 +13,7 @@ import { import {openConfirmModal} from 'sentry/components/confirm'; import {t} from 'sentry/locale'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; import {RequestError} from 'sentry/utils/requestError/requestError'; /** @@ -202,11 +203,14 @@ export function AutoSaveForm< if (resetOnErrorRef.current) { formApi.reset(); } - const hasBackendErrors = - error instanceof RequestError ? setFieldErrors(formApi, error) : false; + const isRequestError = error instanceof RequestError; + const hasBackendErrors = isRequestError ? setFieldErrors(formApi, error) : false; if (!hasBackendErrors) { + const message = isRequestError + ? getRequestErrorUserMessage(error, t('Failed to save')) + : t('Failed to save'); setFieldErrors(formApi, { - [name]: {message: t('Failed to save')}, + [name]: {message}, } as never); } }; diff --git a/static/app/components/events/eventTagsAndScreenshot/tags.tsx b/static/app/components/events/eventTagsAndScreenshot/tags.tsx index f5a8fdd7ed1fda..733428033fce3a 100644 --- a/static/app/components/events/eventTagsAndScreenshot/tags.tsx +++ b/static/app/components/events/eventTagsAndScreenshot/tags.tsx @@ -3,7 +3,6 @@ import {useMemo, useState} from 'react'; import {Grid} from '@sentry/scraps/layout'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; -import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; import {EventTags} from 'sentry/components/events/eventTags'; import { associateTagsWithMeta, @@ -83,11 +82,7 @@ export function EventTagsDataSection({ return ( - {t('Tags')} - - } + title={t('Tags')} actions={actions} sectionKey={SectionKey.TAGS} ref={ref} diff --git a/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx index b0b7e9a9512af1..889c55884ceda5 100644 --- a/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx @@ -14,7 +14,7 @@ import {StacktraceBanners} from './stacktraceBanners'; describe('StacktraceBanners', () => { const org = OrganizationFixture({ - features: ['codecov-integration'], + features: ['dashboards-basic'], }); const project = ProjectFixture(); diff --git a/static/app/components/keyValueData/index.stories.tsx b/static/app/components/keyValueData/index.stories.tsx index fdbbe0aa23109e..f3403a75033a4c 100644 --- a/static/app/components/keyValueData/index.stories.tsx +++ b/static/app/components/keyValueData/index.stories.tsx @@ -9,7 +9,7 @@ import { KeyValueData, type KeyValueDataContentProps, } from 'sentry/components/keyValueData'; -import {IconCodecov, IconEdit, IconSentry, IconSettings} from 'sentry/icons'; +import {IconEdit, IconSentry, IconSettings} from 'sentry/icons'; import * as Storybook from 'sentry/stories'; export default Storybook.story('KeyValueData', story => { @@ -268,7 +268,7 @@ function generateContentItems(theme: Theme): KeyValueDataContentProps[] { ), value: ( - Custom Value Node + Custom Value Node ), }, diff --git a/static/app/components/onboarding/useCreateProject.spec.tsx b/static/app/components/onboarding/useCreateProject.spec.tsx new file mode 100644 index 00000000000000..3f094c676cf244 --- /dev/null +++ b/static/app/components/onboarding/useCreateProject.spec.tsx @@ -0,0 +1,70 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useCreateProject} from 'sentry/components/onboarding/useCreateProject'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; + +const platform: OnboardingSelectedSDK = { + key: 'javascript-nextjs', + language: 'javascript', + link: 'https://docs.sentry.io/platforms/javascript/guides/nextjs/', + name: 'Next.js', + type: 'framework', + category: 'browser', +}; + +describe('useCreateProject', () => { + const organization = OrganizationFixture(); + const project = ProjectFixture({slug: 'my-project'}); + + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [], + }); + jest.spyOn(ProjectsStore, 'onCreateSuccess'); + }); + + it('POSTs to /organizations/{org}/projects/ when no team slug is given', async () => { + const mockCreate = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + method: 'POST', + body: project, + }); + + const {result} = renderHookWithProviders(() => useCreateProject(), {organization}); + + result.current.mutate({platform, name: 'my-project'}); + + await waitFor(() => expect(mockCreate).toHaveBeenCalled()); + expect(ProjectsStore.onCreateSuccess).toHaveBeenCalledWith( + project, + organization.slug + ); + }); + + it('POSTs to /teams/{org}/{team}/projects/ when a team slug is provided', async () => { + const mockCreate = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/my-team/projects/`, + method: 'POST', + body: project, + }); + + const {result} = renderHookWithProviders(() => useCreateProject(), {organization}); + + result.current.mutate({platform, name: 'my-project', firstTeamSlug: 'my-team'}); + + await waitFor(() => expect(mockCreate).toHaveBeenCalled()); + }); +}); diff --git a/static/app/components/onboarding/useCreateProject.ts b/static/app/components/onboarding/useCreateProject.ts index b396a3100668be..95c91c127e0f3a 100644 --- a/static/app/components/onboarding/useCreateProject.ts +++ b/static/app/components/onboarding/useCreateProject.ts @@ -22,7 +22,7 @@ export function useCreateProject() { return api.requestPromise( firstTeamSlug ? `/teams/${organization.slug}/${firstTeamSlug}/projects/` - : `/organizations/${organization.slug}/experimental/projects/`, + : `/organizations/${organization.slug}/projects/`, { method: 'POST', data: { diff --git a/static/app/components/pipeline/integrationMsTeams/index.spec.tsx b/static/app/components/pipeline/integrationMsTeams/index.spec.tsx new file mode 100644 index 00000000000000..6802911298be00 --- /dev/null +++ b/static/app/components/pipeline/integrationMsTeams/index.spec.tsx @@ -0,0 +1,36 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {createMakeStepProps} from 'sentry/components/pipeline/testUtils'; + +import {msTeamsIntegrationPipeline} from '.'; + +const MsTeamsInstallStep = msTeamsIntegrationPipeline.steps[0].component; + +const makeStepProps = createMakeStepProps({totalSteps: 1}); + +describe('MsTeamsInstallStep', () => { + it('auto-advances with the pipeline state and shows a finishing message', () => { + const advance = jest.fn(); + render( + + ); + + expect(advance).toHaveBeenCalledWith({state: 'pipeline-sig'}); + expect(advance).toHaveBeenCalledTimes(1); + expect( + screen.getByText('Finishing up Microsoft Teams integration installation...') + ).toBeInTheDocument(); + }); + + it('does not advance until stepData is available', () => { + const advance = jest.fn(); + render(); + + expect(advance).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/components/pipeline/integrationMsTeams/index.tsx b/static/app/components/pipeline/integrationMsTeams/index.tsx new file mode 100644 index 00000000000000..c1d19427e101b1 --- /dev/null +++ b/static/app/components/pipeline/integrationMsTeams/index.tsx @@ -0,0 +1,52 @@ +import {useEffect, useRef} from 'react'; + +import {Text} from '@sentry/scraps/text'; + +import type { + PipelineDefinition, + PipelineStepProps, +} from 'sentry/components/pipeline/types'; +import {pipelineComplete} from 'sentry/components/pipeline/types'; +import {t} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +type MsTeamsStepData = { + appDirectoryInstall: true; + state: string; +}; + +function MsTeamsInstallStep({ + stepData, + advance, +}: PipelineStepProps) { + // MS Teams installs are initiated from the Teams Marketplace, so by the time + // the pipeline modal opens all the install data is already bound to pipeline + // state. The backend signals this with `appDirectoryInstall` and we advance + // immediately with no user interaction. The ref guards against React strict + // mode double-firing the effect. + const hasAutoAdvanced = useRef(false); + useEffect(() => { + if (!stepData?.appDirectoryInstall || hasAutoAdvanced.current) { + return; + } + hasAutoAdvanced.current = true; + advance({state: stepData.state}); + }, [stepData, advance]); + + return {t('Finishing up Microsoft Teams integration installation...')}; +} + +export const msTeamsIntegrationPipeline = { + type: 'integration', + provider: 'msteams', + actionTitle: t('Installing Microsoft Teams Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'msteams_install', + shortDescription: t('Finishing installation'), + component: MsTeamsInstallStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index f6f1691abacd85..ed074af1384c4c 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -8,6 +8,7 @@ import {discordIntegrationPipeline} from './integrationDiscord'; import {githubIntegrationPipeline} from './integrationGitHub'; import {githubEnterpriseIntegrationPipeline} from './integrationGitHubEnterprise'; import {gitlabIntegrationPipeline} from './integrationGitLab'; +import {msTeamsIntegrationPipeline} from './integrationMsTeams'; import {opsgenieIntegrationPipeline} from './integrationOpsgenie'; import {pagerDutyIntegrationPipeline} from './integrationPagerDuty'; import {perforceIntegrationPipeline} from './integrationPerforce'; @@ -32,6 +33,7 @@ export const PIPELINE_REGISTRY = [ githubIntegrationPipeline, githubEnterpriseIntegrationPipeline, gitlabIntegrationPipeline, + msTeamsIntegrationPipeline, opsgenieIntegrationPipeline, pagerDutyIntegrationPipeline, perforceIntegrationPipeline, diff --git a/static/app/icons/iconCodecov.tsx b/static/app/icons/iconCodecov.tsx deleted file mode 100644 index 6c8976429bd533..00000000000000 --- a/static/app/icons/iconCodecov.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type {SVGIconProps} from './svgIcon'; -import {SvgIcon} from './svgIcon'; - -export function IconCodecov(props: SVGIconProps) { - return ( - - - - ); -} diff --git a/static/app/icons/icons.stories.tsx b/static/app/icons/icons.stories.tsx index 773b28396f552d..9f09104a82e64d 100644 --- a/static/app/icons/icons.stories.tsx +++ b/static/app/icons/icons.stories.tsx @@ -245,13 +245,6 @@ const SECTIONS: TSection[] = [ name: 'SentryPrideLogo', defaultProps: {}, }, - { - id: 'codecov', - groups: ['logo'], - keywords: ['coverage', 'testing', 'code'], - name: 'Codecov', - defaultProps: {}, - }, { id: 'bitbucket', groups: ['logo'], diff --git a/static/app/icons/index.tsx b/static/app/icons/index.tsx index 775b725c114299..6fdeb971a25720 100644 --- a/static/app/icons/index.tsx +++ b/static/app/icons/index.tsx @@ -22,7 +22,6 @@ export {IconCircleFill} from './iconCircleFill'; export {IconClock} from './iconClock'; export {IconClose} from './iconClose'; export {IconCode} from './iconCode'; -export {IconCodecov} from './iconCodecov'; export {IconCommand} from './iconCommand'; export {IconCommit} from './iconCommit'; export {IconCompass} from './iconCompass'; diff --git a/static/app/stores/guideStore.spec.tsx b/static/app/stores/guideStore.spec.tsx index c76eef35e3c67d..d06a3d82741e6e 100644 --- a/static/app/stores/guideStore.spec.tsx +++ b/static/app/stores/guideStore.spec.tsx @@ -25,15 +25,14 @@ describe('GuideStore', () => { GuideStore.init(); data = [ { - guide: 'issue', + guide: 'trace_view', seen: false, }, {guide: 'issue_stream', seen: true}, ]; - GuideStore.registerAnchor('issue_header_stats'); - GuideStore.registerAnchor('issue_sidebar_owners'); - GuideStore.registerAnchor('breadcrumbs'); GuideStore.registerAnchor('issue_stream'); + GuideStore.registerAnchor('trace_view_guide_row'); + GuideStore.registerAnchor('trace_view_guide_row_details'); }); afterEach(() => { @@ -44,14 +43,12 @@ describe('GuideStore', () => { GuideStore.fetchSucceeded(data); // Should pick the first non-seen guide in alphabetic order. expect(GuideStore.getState().currentStep).toBe(0); - expect(GuideStore.getState().currentGuide?.guide).toBe('issue'); + expect(GuideStore.getState().currentGuide?.guide).toBe('trace_view'); // Should prune steps that don't have anchors. - expect(GuideStore.getState().currentGuide?.steps).toHaveLength(3); + expect(GuideStore.getState().currentGuide?.steps).toHaveLength(2); GuideStore.nextStep(); expect(GuideStore.getState().currentStep).toBe(1); - GuideStore.nextStep(); - expect(GuideStore.getState().currentStep).toBe(2); GuideStore.closeGuide(); expect(GuideStore.getState().currentGuide).toBeNull(); }); @@ -59,18 +56,18 @@ describe('GuideStore', () => { it('should force show a guide with #assistant', () => { data = [ { - guide: 'issue', + guide: 'issue_stream', seen: true, }, - {guide: 'issue_stream', seen: false}, + {guide: 'trace_view', seen: false}, ]; GuideStore.fetchSucceeded(data); window.location.hash = '#assistant'; window.dispatchEvent(new Event('load')); - expect(GuideStore.getState().currentGuide?.guide).toBe('issue'); - GuideStore.closeGuide(); expect(GuideStore.getState().currentGuide?.guide).toBe('issue_stream'); + GuideStore.closeGuide(); + expect(GuideStore.getState().currentGuide?.guide).toBe('trace_view'); window.location.hash = ''; }); @@ -85,10 +82,10 @@ describe('GuideStore', () => { it('should record analytics events when guide is cued', () => { const spy = jest.spyOn(GuideStore, 'recordCue'); GuideStore.fetchSucceeded(data); - expect(spy).toHaveBeenCalledWith('issue'); + expect(spy).toHaveBeenCalledWith('trace_view'); expect(trackAnalytics).toHaveBeenCalledWith('assistant.guide_cued', { - guide: 'issue', + guide: 'trace_view', organization: null, }); @@ -105,7 +102,7 @@ describe('GuideStore', () => { it('only shows guides with server data and content', () => { data = [ { - guide: 'issue', + guide: 'issue_stream', seen: true, }, { diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index f9d1d8c2666fbe..021736144b3ce2 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -21,7 +21,6 @@ import type {User} from './user'; */ export interface OrganizationSummary { avatar: Avatar; - codecovAccess: boolean; dateCreated: string; hideAiFeatures: boolean; id: string; diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx index 8ec0ad31da6ca6..050a6936cafd0f 100644 --- a/static/app/types/overrides.tsx +++ b/static/app/types/overrides.tsx @@ -147,10 +147,6 @@ type AttemptCloseAttemptProps = { organizationSlugs: string[]; }; -type CodecovLinkProps = { - organization: Organization; -}; - type GuideUpdateCallback = (nextGuide: Guide | null, opts: {dismissed?: boolean}) => void; type MonitorCreatedCallback = (organization: Organization) => void; @@ -187,7 +183,6 @@ type DashboardLimitProviderProps = { type ComponentOverrides = { 'component:ai-configure-seer-quota-sidebar': () => React.ComponentType; 'component:ai-setup-data-consent': () => React.ComponentType | null; - 'component:codecov-integration-settings-link': () => React.ComponentType; 'component:confirm-account-close': () => React.ComponentType; 'component:continuous-profiling-billing-requirement-banner': () => React.ComponentType; 'component:crons-list-page-header': () => React.ComponentType; @@ -259,7 +254,6 @@ type AnalyticsOverrides = { export type FeatureDisabledOverrides = { 'feature-disabled:alert-wizard-performance': FeatureDisabledOverride; 'feature-disabled:alerts-page': FeatureDisabledOverride; - 'feature-disabled:codecov-integration-setting': FeatureDisabledOverride; 'feature-disabled:custom-inbound-filters': FeatureDisabledOverride; 'feature-disabled:dashboards-edit': FeatureDisabledOverride; 'feature-disabled:dashboards-page': FeatureDisabledOverride; diff --git a/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx b/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx index f9ee4faed52122..67576e796e5fe6 100644 --- a/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx +++ b/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx @@ -37,10 +37,6 @@ export type EcosystemEventParameters = { provider: string; view: StackTraceView; } & BaseEventAnalyticsParams; - 'integrations.stacktrace_codecov_link_clicked': { - group_id: number; - view: StackTraceView; - } & BaseEventAnalyticsParams; 'integrations.stacktrace_complete_setup': { provider: string; setup_type: SetupType; @@ -100,8 +96,6 @@ export const ecosystemEventMap: Record = { 'Integrations: Stacktrace Manual Option Clicked', 'integrations.stacktrace_start_setup': 'Integrations: Stacktrace Start Setup', 'integrations.stacktrace_submit_config': 'Integrations: Stacktrace Submit Config', - 'integrations.stacktrace_codecov_link_clicked': - 'Integrations: Stacktrace Codecov Link Clicked', 'integrations.non_inapp_stacktrace_link_clicked': 'Integrations: Non-InApp Stacktrace Link Clicked', }; diff --git a/static/app/utils/analytics/settingsAnalyticsEvents.tsx b/static/app/utils/analytics/settingsAnalyticsEvents.tsx index fcc53d95e99eef..08c6d4135c9ff6 100644 --- a/static/app/utils/analytics/settingsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/settingsAnalyticsEvents.tsx @@ -7,7 +7,6 @@ export type SettingsEventParameters = { notification_type: string; tuning_field_type: string; }; - 'organization_settings.codecov_access_updated': {has_access: boolean}; 'sidebar.item_clicked': { dest: string; project_id?: string; @@ -22,7 +21,5 @@ export const settingsEventMap: Record = { 'notification_settings.tuning_page_viewed': 'Notification Settings: Tuning Page Viewed', 'notification_settings.updated_tuning_setting': 'Notification Settings: Updated Tuning Setting', - 'organization_settings.codecov_access_updated': - 'Organization Settings: Codecov Access Updated', 'sidebar.item_clicked': 'Sidebar: Item Clicked', }; diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index 0edc21392b9095..6c257abd172d96 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -59,7 +59,6 @@ export type KnownGetsentryApiUrls = | '/invoices/$invoiceId/close/' | '/invoices/$invoiceId/effective-at/' | '/invoices/$invoiceId/retry-payment/' - | '/organizations/$organizationIdOrSlug/codecov-jwt/' | '/organizations/$organizationIdOrSlug/console-sdk-invites/' | '/organizations/$organizationIdOrSlug/data-consent/' | '/organizations/$organizationIdOrSlug/issues/force-auto-assignment/' diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index f80f30e39d2d37..e29eb3401a0f7a 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -263,7 +263,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/events/' | '/organizations/$organizationIdOrSlug/events/$projectIdOrSlug:$eventId/' | '/organizations/$organizationIdOrSlug/events/anomalies/' - | '/organizations/$organizationIdOrSlug/experimental/projects/' | '/organizations/$organizationIdOrSlug/explore/saved/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/starred/' diff --git a/static/app/utils/integrationUtil.tsx b/static/app/utils/integrationUtil.tsx index 53bd81d920409f..abb6d95ad06cd1 100644 --- a/static/app/utils/integrationUtil.tsx +++ b/static/app/utils/integrationUtil.tsx @@ -3,7 +3,6 @@ import * as qs from 'query-string'; import { IconAsana, IconBitbucket, - IconCodecov, IconGeneric, IconGithub, IconGitlab, @@ -230,8 +229,6 @@ export const getIntegrationIcon = ( return ; case 'vsts': return ; - case 'codecov': - return ; default: return ; } @@ -257,8 +254,6 @@ export const getIntegrationDisplayName = (integrationType?: string) => { return 'Perforce'; case 'vsts': return 'Azure DevOps'; - case 'codecov': - return 'Codeov'; default: return ''; } diff --git a/static/app/utils/integrations/useAddIntegration.tsx b/static/app/utils/integrations/useAddIntegration.tsx index 2f1c207575d634..a7477841eaf12a 100644 --- a/static/app/utils/integrations/useAddIntegration.tsx +++ b/static/app/utils/integrations/useAddIntegration.tsx @@ -53,6 +53,7 @@ const UNCONDITIONAL_API_PIPELINE_PROVIDERS = [ 'github', 'github_enterprise', 'gitlab', + 'msteams', 'opsgenie', 'pagerduty', 'perforce', diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx index 954b5d0161c86a..246917830bc797 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx @@ -1,6 +1,8 @@ +import type {OnUrlUpdateFunction} from 'nuqs/adapters/testing'; import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; +import {SentryNuqsTestingAdapter} from 'sentry-test/nuqsTestingAdapter'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {setWindowLocation} from 'sentry-test/utils'; @@ -150,4 +152,40 @@ describe('useActiveReplayTab', () => { }); }); }); + + it('should update the tab query parameter shallowly', async () => { + const onUrlUpdate = jest.fn< + ReturnType, + Parameters + >(); + + const {result, router} = renderHookWithProviders(useActiveReplayTab, { + initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {}}, + }, + organization: OrganizationFixture({features: []}), + additionalWrapper: ({children}) => ( + + {children} + + ), + }); + + act(() => result.current.setActiveTab('network')); + + await waitFor(() => { + expect(router.location.query.t_main).toBe('network'); + }); + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?t_main=network', + options: expect.objectContaining({shallow: true}), + }) + ); + }); }); diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.tsx index 0379b28ebc12d6..1919a073328e9d 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.tsx @@ -58,7 +58,9 @@ export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boo const [tabParam, setTabParam] = useQueryState( 't_main', - tabKeyParser.withDefault(defaultTab).withOptions({clearOnDefault: false}) + tabKeyParser + .withDefault(defaultTab) + .withOptions({clearOnDefault: false, shallow: true}) ); return { diff --git a/static/app/views/dashboards/filtersBar.spec.tsx b/static/app/views/dashboards/filtersBar.spec.tsx index 653c3ca5d9f7c8..6d32ab62e01abd 100644 --- a/static/app/views/dashboards/filtersBar.spec.tsx +++ b/static/app/views/dashboards/filtersBar.spec.tsx @@ -5,12 +5,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ReleaseFixture} from 'sentry-fixture/release'; import {TagsFixture} from 'sentry-fixture/tags'; -import { - render, - screen, - waitFor, - waitForElementToBeRemoved, -} from 'sentry-test/reactTestingLibrary'; +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import type {Organization} from 'sentry/types/organization'; import {FieldKind} from 'sentry/utils/fields'; @@ -63,9 +58,8 @@ describe('FiltersBar', () => { }, }); renderFilterBar({location: newLocation}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); }); @@ -80,8 +74,7 @@ describe('FiltersBar', () => { }, }); renderFilterBar({location: newLocation, hasUnsavedChanges: true}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); - expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + expect(await screen.findByRole('button', {name: 'Save'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); }); @@ -98,9 +91,8 @@ describe('FiltersBar', () => { }); renderFilterBar({location: newLocation}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); @@ -215,9 +207,8 @@ describe('FiltersBar', () => { hasUnsavedChanges: true, prebuiltDashboardId: PrebuiltDashboardId.FRONTEND_SESSION_HEALTH, }); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Save for Everyone'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); diff --git a/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx index 448259dfd665c5..2f4c5be2dcf9a7 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx @@ -38,7 +38,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); @@ -58,7 +58,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); @@ -90,7 +90,7 @@ describe('FilterSelector', () => { /> ); - const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ' :'}); + const button = screen.getByRole('button', {name: /^browser :/}); await userEvent.click(button); expect(screen.getByRole('checkbox', {name: 'Select firefox'})).toBeChecked(); @@ -108,7 +108,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); await userEvent.click(screen.getByRole('button', {name: 'Remove Filter'})); @@ -209,7 +209,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${SpanFields.USER_GEO_SUBREGION} contains`, + name: `${SpanFields.USER_GEO_SUBREGION} contains All`, }); await userEvent.click(button); @@ -240,7 +240,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx index ccf727c10e195d..0bf9a24589d4cc 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -16,6 +16,7 @@ import {Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {useStagedCompactSelect} from 'sentry/components/pageFilters/useStagedCompactSelect'; import { @@ -342,10 +343,15 @@ export function FilterSelector({ activeFilterValues={filterValues} operator={stagedOperator} options={translatedOptions} - queryResult={queryResult} /> ); + const loadingFooter = isFetching ? ( + + + + ) : null; + if (!canSelectMultipleValues) { return ( { setStagedFilterValues([]); }} + menuFooter={loadingFooter} menuTitle={ {t('%s Filter', getDatasetLabel(globalFilter.dataset))} @@ -421,17 +428,22 @@ export function FilterSelector({ isFetching ? t('Loading filter values...') : t('No filter values found') } menuFooter={ - hasStagedChanges ? ( - - dispatch({type: 'remove staged'})} - /> - { - dispatch({type: 'remove staged'}); - handleChange(stagedSelect.value); - }} - /> + hasStagedChanges || isFetching ? ( + + {loadingFooter} + {hasStagedChanges && ( + + dispatch({type: 'remove staged'})} + /> + { + dispatch({type: 'remove staged'}); + handleChange(stagedSelect.value); + }} + /> + + )} ) : null } @@ -517,6 +529,12 @@ export const MenuTitleWrapper = styled('span')` padding-bottom: ${p => p.theme.space.xs}; `; +const FooterLoadingIndicator = styled(LoadingIndicator)` + && { + margin: 0; + } +`; + const OperatorFlex = styled(Flex)` margin-left: -${p => p.theme.space.sm}; `; diff --git a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx index e53dab57a1b736..32960f5711cfe5 100644 --- a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx @@ -1,12 +1,8 @@ -import styled from '@emotion/styled'; -import type {UseQueryResult} from '@tanstack/react-query'; - import {Badge} from '@sentry/scraps/badge'; import type {SelectOption} from '@sentry/scraps/compactSelect'; import {Container, Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {OP_LABELS} from 'sentry/components/searchQueryBuilder/tokens/filter/utils'; import {TermOperator} from 'sentry/components/searchSyntax/parser'; import {t} from 'sentry/locale'; @@ -19,7 +15,6 @@ type FilterSelectorTriggerProps = { globalFilter: GlobalFilter; operator: TermOperator; options: Array>; - queryResult: UseQueryResult; }; export function FilterSelectorTrigger({ @@ -27,12 +22,10 @@ export function FilterSelectorTrigger({ activeFilterValues, operator, options, - queryResult, }: FilterSelectorTriggerProps) { - const {isFetching} = queryResult; const {tag} = globalFilter; - const shouldShowBadge = !isFetching && activeFilterValues.length > 1; + const shouldShowBadge = activeFilterValues.length > 1; // "All" means no filter is applied (empty selection). We intentionally avoid // comparing against options.length because when tag values fail to load, @@ -64,20 +57,17 @@ export function FilterSelectorTrigger({ {opLabel} - {!isFetching && - (isAllSelected ? ( - - {t('All')} + {isAllSelected ? ( + + {t('All')} + + ) : ( + + + {label} - ) : ( - - - {label} - - - ))} - - {isFetching && } + + )} {shouldShowBadge && ( @@ -87,10 +77,3 @@ export function FilterSelectorTrigger({ ); } - -const InlineLoadingIndicator = styled(LoadingIndicator)` - && { - margin: 0; - margin-left: ${p => p.theme.space.xs}; - } -`; diff --git a/static/app/views/discover/table/index.tsx b/static/app/views/discover/table/index.tsx index 91d25606734ae6..b90407efaad1cd 100644 --- a/static/app/views/discover/table/index.tsx +++ b/static/app/views/discover/table/index.tsx @@ -161,6 +161,7 @@ class Table extends PureComponent { // Note: Event ID or 'id' is added to the fields in the API payload response by default for all non-aggregate queries. if (!eventView.hasAggregateField() || apiPayload.field.includes('id')) { apiPayload.field.push('trace'); + apiPayload.field.push('issue.id'); // We need to include the event.type field because we want to // route to issue details for error and default event types. diff --git a/static/app/views/discover/table/tableView.spec.tsx b/static/app/views/discover/table/tableView.spec.tsx index bf93622896bdbd..374b3241d5aa7c 100644 --- a/static/app/views/discover/table/tableView.spec.tsx +++ b/static/app/views/discover/table/tableView.spec.tsx @@ -54,7 +54,11 @@ describe('TableView > CellActions', () => { const eventView = EventView.fromLocation(location); - function renderComponent(tableData: TableData, view: EventView) { + function renderComponent( + tableData: TableData, + view: EventView, + queryDataset = SavedQueryDatasets.TRANSACTIONS + ) { return render( CellActions', () => { measurementKeys={null} showTags={false} title="" - queryDataset={SavedQueryDatasets.TRANSACTIONS} + queryDataset={queryDataset} />, { organization, @@ -490,6 +494,31 @@ describe('TableView > CellActions', () => { ); }); + it('renders issue event id links directly to the issue event', () => { + const view = EventView.fromLocation( + LocationFixture({ + query: {...locationQuery, field: ['id', 'title']}, + }) + ); + rows.meta = {...rows.meta, id: 'string', 'issue.id': 'integer'}; + rows.data[0] = { + ...rows.data[0]!, + id: 'deadbeef', + 'issue.id': 123, + 'project.name': 'project-slug', + }; + + renderComponent(rows, view, SavedQueryDatasets.ERRORS); + + const firstRow = screen.getAllByRole('row')[1]!; + const link = within(firstRow).getByTestId('view-event'); + + expect(link).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/123/events/deadbeef/?referrer=discover-events-table' + ); + }); + it('handles go to release', async () => { const {router} = renderComponent(rows, eventView); await openContextMenu(5); diff --git a/static/app/views/discover/table/tableView.tsx b/static/app/views/discover/table/tableView.tsx index d7eabdd8306a5f..f0c0157b76fcf3 100644 --- a/static/app/views/discover/table/tableView.tsx +++ b/static/app/views/discover/table/tableView.tsx @@ -3,7 +3,7 @@ import {useMatches} from 'react-router-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; -import type {Location, LocationDescriptorObject} from 'history'; +import type {Location, LocationDescriptor, LocationDescriptorObject} from 'history'; import {Link} from '@sentry/scraps/link'; import {useModal} from '@sentry/scraps/modal'; @@ -184,37 +184,17 @@ export function TableView(props: TableViewProps) { value = fieldRenderer(dataRow, {navigate, organization, location, theme}); } - let target: any; - - if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { - const project = dataRow.project || dataRow['project.name']; - target = { - // Redirects to the issue group event page via ProjectEventRedirect - pathname: normalizeUrl( - `/${organization.slug}/${project}/events/${dataRow.id}/` - ), - query: {...location.query, referrer: 'discover-events-table'}, - }; - } else { - if (!dataRow.trace) { - throw new Error( - 'Transaction event should always have a trace associated with it.' - ); - } - - target = generateLinkToEventInTraceView({ - traceSlug: dataRow.trace, - eventId: dataRow.id, - timestamp: dataRow.timestamp, - organization, - location, - eventView, - source: TraceViewSources.DISCOVER, - }); - } - const eventIdLink = ( - + {value} ); @@ -319,38 +299,17 @@ export function TableView(props: TableViewProps) { queryDataset === SavedQueryDatasets.TRANSACTIONS; if (columnKey === 'id') { - let target: any; - - if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { - const project = dataRow.project || dataRow['project.name']; - - target = { - // Redirects to the issue group event page via ProjectEventRedirect - pathname: normalizeUrl( - `/${organization.slug}/${project}/events/${dataRow.id}/` - ), - query: {...location.query, referrer: 'discover-events-table'}, - }; - } else { - if (!dataRow.trace) { - throw new Error( - 'Transaction event should always have a trace associated with it.' - ); - } - - target = generateLinkToEventInTraceView({ - traceSlug: dataRow.trace?.toString(), - eventId: dataRow.id, - timestamp: dataRow.timestamp!, - organization, - location, - eventView, - source: TraceViewSources.DISCOVER, - }); - } - const idLink = ( - + {cell} ); @@ -386,10 +345,11 @@ export function TableView(props: TableViewProps) { dataRow['max(timestamp)'] ?? dataRow.timestamp ); const dateSelection = eventView.normalizeDateSelection(location); - if (dataRow.trace) { + const traceSlug = dataRow.trace; + if (typeof traceSlug === 'string' && traceSlug) { const target = getTraceDetailsUrl({ organization, - traceSlug: String(dataRow.trace), + traceSlug, dateSelection, timestamp, location, @@ -704,6 +664,93 @@ export function TableView(props: TableViewProps) { ); } +type EventTargetOptions = { + dataRow: TableDataRow; + eventView: EventView; + isTransactionsDataset: boolean; + location: Location; + organization: Organization; +}; + +type TraceEventDataRow = TableDataRow & { + timestamp: string | number; + trace: string; +}; + +type IssueEventDataRow = TableDataRow & { + 'issue.id': string | number; +}; + +function getEventTarget({ + dataRow, + eventView, + isTransactionsDataset, + location, + organization, +}: EventTargetOptions): LocationDescriptor { + if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { + if (isIssueEventDataRow(dataRow)) { + return getIssueEventTarget(dataRow, organization); + } + + return getProjectEventRedirectTarget(dataRow, organization, location); + } + + if (!isTraceEventDataRow(dataRow)) { + return getProjectEventRedirectTarget(dataRow, organization, location); + } + + return generateLinkToEventInTraceView({ + traceSlug: dataRow.trace, + eventId: dataRow.id, + timestamp: dataRow.timestamp, + organization, + location, + eventView, + source: TraceViewSources.DISCOVER, + }); +} + +function isIssueEventDataRow(dataRow: TableDataRow): dataRow is IssueEventDataRow { + const issueId = dataRow['issue.id']; + // Discover coalesces missing issue IDs to 0, so treat 0 as no issue. + return issueId !== undefined && issueId !== null && issueId !== 0 && issueId !== '0'; +} + +function isTraceEventDataRow(dataRow: TableDataRow): dataRow is TraceEventDataRow { + return ( + typeof dataRow.trace === 'string' && + dataRow.trace !== '' && + dataRow.timestamp !== undefined + ); +} + +function getIssueEventTarget( + dataRow: IssueEventDataRow, + organization: Organization +): LocationDescriptor { + return normalizeUrl({ + pathname: `/organizations/${organization.slug}/issues/${dataRow['issue.id']}/events/${dataRow.id}/`, + query: { + referrer: 'discover-events-table', + }, + }); +} + +function getProjectEventRedirectTarget( + dataRow: TableDataRow, + organization: Organization, + location: Location +): LocationDescriptor { + const project = dataRow.project || dataRow['project.name']; + + return { + // Redirects to the issue group event page via ProjectEventRedirect + pathname: normalizeUrl(`/${organization.slug}/${project}/events/${dataRow.id}/`), + query: {...location.query, referrer: 'discover-events-table'}, + }; +} + const PrependHeader = styled('span')` color: ${p => p.theme.tokens.content.secondary}; `; diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx index c59d42a4138880..4b0991ab2ef4d2 100644 --- a/static/app/views/integrationOrganizationLink/index.tsx +++ b/static/app/views/integrationOrganizationLink/index.tsx @@ -77,15 +77,20 @@ function trackExternalAnalytics({ * Provider-initiated entry points handled here: * * - GitHub - * `/extensions/github/link/?installationId=…` (redirected from + * `/extensions/github/link/?installationId=...` (redirected from * `/extensions/external-install/github/:installationId`). Drives the * pipeline with `gitHubAppListingParams`. * * - Discord - * `/extensions/discord/link/?code=…&guild_id=…` (redirected from + * `/extensions/discord/link/?code=...&guild_id=...` (redirected from * `/extensions/discord/configure/`). Drives the pipeline with * `discordAppDirectoryParams`. * + * - Microsoft Teams + * `/extensions/msteams/link/?signed_params=...` (redirected from + * `/extensions/msteams/configure/`). Drives the pipeline with + * `msTeamsParams`. + * * - Anything else * falls through to {@link finishLegacyInstallation}, which bounces to the * legacy `/extensions//configure/` backend endpoint. @@ -218,6 +223,20 @@ export default function IntegrationOrganizationLink() { return {code, guild_id: guildId, use_configure: '1'}; }, [integrationSlug, location.query]); + // Microsoft Teams installs arrive here with `signed_params` in the URL query + // (forwarded from `/extensions/msteams/configure/`). The install button uses + // it as `initialData` for the pipeline modal. + const msTeamsParams = useMemo | null>(() => { + if (integrationSlug !== 'msteams') { + return null; + } + const signedParams = location.query.signed_params; + if (typeof signedParams !== 'string') { + return null; + } + return {signedParams}; + }, [integrationSlug, location.query]); + // Legacy install path. Redirects to `/extensions//configure/`, which // runs the Django-rendered `IntegrationExtensionConfigurationView` to drive // the install server-side via the legacy pipeline. Used by every provider @@ -246,7 +265,8 @@ export default function IntegrationOrganizationLink() { // Each provider-initiated entry point contributes its own params bag. // Whichever one is non-null routes through the API pipeline modal; // otherwise we fall back to the legacy server-driven install flow. - const urlParams = gitHubAppListingParams ?? discordAppDirectoryParams; + const urlParams = + gitHubAppListingParams ?? discordAppDirectoryParams ?? msTeamsParams; if (urlParams) { startFlow({provider, organization, onInstall, urlParams}); return; @@ -258,6 +278,7 @@ export default function IntegrationOrganizationLink() { organization, gitHubAppListingParams, discordAppDirectoryParams, + msTeamsParams, startFlow, onInstall, finishLegacyInstallation, diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 2fabb552aea528..d0b6a3fc606752 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -594,7 +594,9 @@ describe('ActivitySection', () => { project, }); - const org = OrganizationFixture({features: ['seer-activity-timeline']}); + const org = OrganizationFixture({ + features: ['display-seer-actions-as-issue-activities'], + }); render(, {organization: org}); expect(await screen.findByText('Root Cause Analysis')).toBeInTheDocument(); @@ -650,7 +652,9 @@ describe('ActivitySection', () => { project, }); - const org = OrganizationFixture({features: ['seer-activity-timeline']}); + const org = OrganizationFixture({ + features: ['display-seer-actions-as-issue-activities'], + }); render(, {organization: org}); expect(screen.queryByText('Pull Request Created')).not.toBeInTheDocument(); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 1386643232aa74..02261d34c1aa27 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -368,7 +368,9 @@ export function ActivitySection({ }, }; - const showSeerActivities = organization.features.includes('seer-activity-timeline'); + const showSeerActivities = organization.features.includes( + 'display-seer-actions-as-issue-activities' + ); const visibleActivities = showSeerActivities ? group.activity.filter(item => item.type !== GroupActivityType.SEER_PR_CREATED) : group.activity.filter(item => !SEER_ACTIVITY_TYPES.has(item.type)); diff --git a/static/app/views/issueDetails/eventNavigation/index.tsx b/static/app/views/issueDetails/eventNavigation/index.tsx index a3dfed99440709..dd07dd97283f36 100644 --- a/static/app/views/issueDetails/eventNavigation/index.tsx +++ b/static/app/views/issueDetails/eventNavigation/index.tsx @@ -246,9 +246,9 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) tourContext={IssueDetailsTourContext} id={IssueDetailsTour.NAVIGATION} - title={t('Compare events')} + title={t('Compare and copy events')} description={t( - 'Review the events associated with an issue. Compare the first, latest, or recommended event to see what changed.' + 'Review the events associated with an issue. Compare the first, latest, or recommended event to see what changed, or use Copy as to copy the issue details as Markdown.' )} > {tourProps => ( diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 7ffa24cec83f85..85dfc61939d9de 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -61,6 +61,7 @@ import {useMergedIssuesDrawer} from 'sentry/views/issueDetails/hooks/useMergedIs import {useSimilarIssuesDrawer} from 'sentry/views/issueDetails/hooks/useSimilarIssuesDrawer'; import { ISSUE_DETAILS_TOUR_GUIDE_KEY, + IssueDetailsTourModal, IssueDetailsTourContext, ORDERED_ISSUE_DETAILS_TOUR, type IssueDetailsTour, @@ -836,6 +837,7 @@ function GroupDetailsPageContent(props: GroupDetailsPageContentProps) { orderedStepIds={ORDERED_ISSUE_DETAILS_TOUR} TourContext={IssueDetailsTourContext} > + { initialRouterConfig, }); - expect(await screen.findByRole('region', {name: 'tags'})).toBeInTheDocument(); + expect(await screen.findByRole('region', {name: 'Tags'})).toBeInTheDocument(); const highlights = screen.getByRole('region', {name: 'Highlights'}); expect(within(highlights).getByRole('button', {name: 'Edit'})).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index fc2133f4b9cd64..e22daa23a0f09c 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -1,8 +1,6 @@ import {Fragment, useMemo, useRef} from 'react'; -import {ClassNames} from '@emotion/react'; import Feature from 'sentry/components/acl/feature'; -import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {BreadcrumbsDataSection} from 'sentry/components/events/breadcrumbs/breadcrumbsDataSection'; import {EventContexts} from 'sentry/components/events/contexts'; @@ -173,79 +171,67 @@ export function EventDetailsContent({ {isMetricKitHang ? ( ) : ( - /* Wrapping all stacktrace components since multiple could appear */ - - {({css}) => ( - - {shouldShowTombstonesBanner(event) && !isSampleError && ( - - + {shouldShowTombstonesBanner(event) && !isSampleError && ( + + + + )} + {defined(eventEntries[EntryType.EXCEPTION]) && ( + + {shouldUseNewStackTrace ? ( + + ) : ( + + )} + + )} + {issueTypeConfig.stacktrace.enabled && + defined(eventEntries[EntryType.STACKTRACE]) && ( + + {shouldUseNewStackTrace ? ( + - - )} - {defined(eventEntries[EntryType.EXCEPTION]) && ( - - {shouldUseNewStackTrace ? ( - - ) : ( - - )} - - )} - {issueTypeConfig.stacktrace.enabled && - defined(eventEntries[EntryType.STACKTRACE]) && ( - - {shouldUseNewStackTrace ? ( - - ) : ( - - )} - - )} - {defined(eventEntries[EntryType.THREADS]) && ( - - - - )} - + )} + + )} + {defined(eventEntries[EntryType.THREADS]) && ( + + + )} - + )} {isANR && ( diff --git a/static/app/views/issueDetails/issueDetailsTour.tsx b/static/app/views/issueDetails/issueDetailsTour.tsx index 8a26ed4764eeb1..d8145fb093465e 100644 --- a/static/app/views/issueDetails/issueDetailsTour.tsx +++ b/static/app/views/issueDetails/issueDetailsTour.tsx @@ -1,6 +1,14 @@ -import {createContext} from 'react'; +import {createContext, useContext, useEffect, useRef} from 'react'; + +import issueDetailsPreview from 'sentry-images/issue_details/issue-details-preview.png'; + +import {useModal} from '@sentry/scraps/modal'; import type {TourContextType} from 'sentry/components/tours/tourContext'; +import {useAssistant, useMutateAssistant} from 'sentry/components/tours/useAssistant'; +import {t} from 'sentry/locale'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import {useOrganization} from 'sentry/utils/useOrganization'; export const enum IssueDetailsTour { /** Trends and aggregates, the graph, and tag distributions */ @@ -27,6 +35,104 @@ export const ORDERED_ISSUE_DETAILS_TOUR = [ ]; export const ISSUE_DETAILS_TOUR_GUIDE_KEY = 'tour.issue_details'; +const ISSUE_DETAILS_TOUR_FORCE_HASH = '#issue-details-tour'; export const IssueDetailsTourContext = createContext | null>(null); + +function useIssueDetailsTourModal() { + const {openModal} = useModal(); + const organization = useOrganization(); + const hasOpenedTourModal = useRef(false); + const {isRegistered, currentStepId, startTour, endTour} = useContext( + IssueDetailsTourContext + )!; + const {data: assistantData} = useAssistant({ + notifyOnChangeProps: ['data'], + }); + const {mutate: mutateAssistant} = useMutateAssistant(); + const forceShowTourModal = window.location.hash === ISSUE_DETAILS_TOUR_FORCE_HASH; + const hasUnseenIssueDetailsTour = + assistantData?.find(item => item.guide === ISSUE_DETAILS_TOUR_GUIDE_KEY)?.seen === + false; + + const shouldShowTourModal = + !process.env.IS_ACCEPTANCE_TEST && + currentStepId === null && + (forceShowTourModal || hasUnseenIssueDetailsTour); + + useEffect(() => { + if (!isRegistered || !shouldShowTourModal || hasOpenedTourModal.current) { + return; + } + + let cancelled = false; + const dismissTour = () => { + mutateAssistant({ + guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, + status: 'dismissed', + }); + endTour(); + trackAnalytics('issue_details.tour.skipped', {organization}); + }; + + hasOpenedTourModal.current = true; + void import('sentry/components/tours/startTour').then( + ({StartTourModal, startTourModalCss}) => { + if (cancelled) { + return; + } + + openModal( + props => ( + { + startTour(); + trackAnalytics('issue_details.tour.started', { + organization, + method: 'modal', + }); + }} + /> + ), + { + modalCss: startTourModalCss, + onClose: reason => { + if (reason) { + dismissTour(); + } + }, + } + ); + } + ); + + return () => { + cancelled = true; + }; + }, [ + endTour, + forceShowTourModal, + isRegistered, + mutateAssistant, + openModal, + organization, + shouldShowTourModal, + startTour, + ]); +} + +export function IssueDetailsTourModal() { + useIssueDetailsTourModal(); + return null; +} diff --git a/static/app/views/onboarding/onboarding.spec.tsx b/static/app/views/onboarding/onboarding.spec.tsx index d9c74af620b79c..48650ecb050af4 100644 --- a/static/app/views/onboarding/onboarding.spec.tsx +++ b/static/app/views/onboarding/onboarding.spec.tsx @@ -898,7 +898,7 @@ describe('Onboarding', () => { body: createdProject, }); const createRequest = MockApiClient.addMockResponse({ - url: `/organizations/${controlOrganization.slug}/experimental/projects/`, + url: `/organizations/${controlOrganization.slug}/projects/`, method: 'POST', body: createdProject, }); diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx index 1e8baba2b6ddcb..137a8e0c5688e3 100644 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ b/static/app/views/projectInstall/createProject.spec.tsx @@ -60,13 +60,13 @@ function renderFrameworkModalMockRequests({ body: {slug: 'testProj'}, }); - const experimentalprojectCreationMockRequest = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/experimental/projects/`, + const orgProjectCreationMockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, method: 'POST', body: {slug: 'testProj', team: {slug: 'testTeam'}}, }); - return {projectCreationMockRequest, experimentalprojectCreationMockRequest}; + return {projectCreationMockRequest, orgProjectCreationMockRequest}; } describe('CreateProject', () => { @@ -483,7 +483,7 @@ describe('CreateProject', () => { await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); expect( - frameWorkModalMockRequests.experimentalprojectCreationMockRequest + frameWorkModalMockRequests.orgProjectCreationMockRequest ).toHaveBeenCalledTimes(1); expect(addSuccessMessage).toHaveBeenCalledWith( 'Created testProj under new team #testTeam' diff --git a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx index f2e76ae7f7fbe7..40e411f114af09 100644 --- a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx +++ b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx @@ -64,6 +64,7 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] { { path: `${userSettingsPathPrefix}/close-account/`, title: t('Close Account'), + keywords: [t('delete account')], description: t('Permanently close your Sentry account'), }, ], @@ -247,6 +248,7 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] { t('pagerduty'), t('opsgenie'), t('discord'), + t('linear'), t('microsoft teams'), t('msteams'), t('aws lambda'), @@ -301,6 +303,7 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] { t('api token'), t('token'), t('credentials'), + t('user auth tokens'), ], description: t('Manage organization tokens'), id: 'auth-tokens', @@ -315,6 +318,7 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] { t('api token'), t('token'), t('credentials'), + t('user auth tokens'), ], description: t( "Personal tokens allow you to perform actions against the Sentry API on behalf of your account. They're the easiest way to get started using the API." diff --git a/static/app/views/settings/organizationGeneralSettings/index.spec.tsx b/static/app/views/settings/organizationGeneralSettings/index.spec.tsx index a05731faa1c8d6..903cb1c64b1a96 100644 --- a/static/app/views/settings/organizationGeneralSettings/index.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/index.spec.tsx @@ -17,12 +17,9 @@ import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Config} from 'sentry/types/system'; -import {trackAnalytics} from 'sentry/utils/analytics'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import OrganizationGeneralSettings from 'sentry/views/settings/organizationGeneralSettings'; -jest.mock('sentry/utils/analytics'); - describe('OrganizationGeneralSettings', () => { const ENDPOINT = '/organizations/org-slug/'; const organization = OrganizationFixture(); @@ -70,36 +67,6 @@ describe('OrganizationGeneralSettings', () => { }); }); - it('can enable "codecov access"', async () => { - const organizationWithCodecovFeature = OrganizationFixture({ - features: ['codecov-integration'], - codecovAccess: false, - }); - OrganizationStore.onUpdate(organizationWithCodecovFeature, {replace: true}); - render(, { - organization: organizationWithCodecovFeature, - }); - const mock = MockApiClient.addMockResponse({ - url: ENDPOINT, - method: 'PUT', - }); - - await userEvent.click( - screen.getByRole('checkbox', {name: /Enable Code Coverage Insights/i}) - ); - - await waitFor(() => { - expect(mock).toHaveBeenCalledWith( - ENDPOINT, - expect.objectContaining({ - data: {codecovAccess: true}, - }) - ); - }); - - expect(trackAnalytics).toHaveBeenCalled(); - }); - it('changes org slug and redirects to new slug', async () => { const {router} = render(, { organization, diff --git a/static/app/views/settings/organizationGeneralSettings/index.tsx b/static/app/views/settings/organizationGeneralSettings/index.tsx index 6d71b5b847be78..2d6a5849c73ebb 100644 --- a/static/app/views/settings/organizationGeneralSettings/index.tsx +++ b/static/app/views/settings/organizationGeneralSettings/index.tsx @@ -17,7 +17,6 @@ import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; -import {trackAnalytics} from 'sentry/utils/analytics'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useApi} from 'sentry/utils/useApi'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -74,13 +73,6 @@ export default function OrganizationGeneralSettings() { navigate(`/settings/${updated.slug}/`, {replace: true}); } } else { - if (prevData.codecovAccess !== updated.codecovAccess) { - trackAnalytics('organization_settings.codecov_access_updated', { - organization: updated, - has_access: updated.codecovAccess, - }); - } - // This will update OrganizationStore (as well as OrganizationsStore // which is slightly incorrect because it has summaries vs a detailed org) updateOrganization(updated); diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx index 769955ea8687c5..835c69f398508b 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx @@ -183,40 +183,6 @@ describe('OrganizationSettingsForm', () => { ).toBeInTheDocument(); }); - it('can enable codecov', async () => { - putMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - method: 'PUT', - body: {...organization, codecovAccess: true}, - }); - - render( - , - { - organization: { - ...organization, - features: ['codecov-integration'], - }, - } - ); - - await userEvent.click( - screen.getByRole('checkbox', {name: /Enable Code Coverage Insights/}) - ); - - expect(putMock).toHaveBeenCalledWith( - '/organizations/org-slug/', - expect.objectContaining({ - data: { - codecovAccess: true, - }, - }) - ); - }); - it('can enable "Show Generative AI Features"', async () => { // initialData.hideAiFeatures = false (default) → switch starts OFF render( diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx index fb9f9b778de7f2..adb4b59773e0fc 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx @@ -1,11 +1,9 @@ import {Fragment, useMemo} from 'react'; -import styled from '@emotion/styled'; import {mutationOptions, useQuery} from '@tanstack/react-query'; import {useMutation} from '@tanstack/react-query'; import {z} from 'zod'; import {Alert} from '@sentry/scraps/alert'; -import {Tag} from '@sentry/scraps/badge'; import { AutoSaveForm, defaultFormOptions, @@ -20,12 +18,8 @@ import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; -import Feature from 'sentry/components/acl/feature'; -import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {AvatarChooser} from 'sentry/components/avatarChooser'; -import {Hovercard} from 'sentry/components/hovercard'; import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; -import {IconCodecov, IconLock} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Organization} from 'sentry/types/organization'; @@ -39,10 +33,6 @@ import {slugify} from 'sentry/utils/slugify'; import {useOrganization} from 'sentry/utils/useOrganization'; import {DATA_STORAGE_DOCS_LINK} from 'sentry/views/organizationCreate'; -const OverriddenCodecovSettingsLink = OverrideOrDefault({ - overrideName: 'component:codecov-integration-settings-link', -}); - const OverriddenOrganizationMembershipSettings = OverrideOrDefault({ overrideName: 'component:organization-membership-settings', defaultComponent: OrganizationMembershipSettingsBase, @@ -62,7 +52,6 @@ const generalSchema = z.object({ organizationId: z.string(), isEarlyAdopter: z.boolean(), hideAiFeatures: z.boolean(), - codecovAccess: z.boolean(), slug: z.string().min(1, t('Organization slug is required')), }); @@ -641,60 +630,6 @@ export function OrganizationSettingsForm({initialData, onSave}: Props) { )} - - {/* Enable Code Coverage Insights */} - - {field => ( - - {t('Enable Code Coverage Insights')}{' '} - ( - - } - > - }> - {t('disabled')} - - - )} - features="organizations:codecov-integration" - > - {() => null} - - - } - hintText={ - - {t('powered by')} Codecov{' '} - - - } - > - - - )} - @@ -714,14 +649,3 @@ export function OrganizationSettingsForm({initialData, onSave}: Props) { ); } - -const PoweredByCodecov = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - - & > span { - display: flex; - align-items: center; - } -`; diff --git a/static/app/views/settings/project/navigationConfiguration.tsx b/static/app/views/settings/project/navigationConfiguration.tsx index 6f7e7945169241..5b387bf03a2f56 100644 --- a/static/app/views/settings/project/navigationConfiguration.tsx +++ b/static/app/views/settings/project/navigationConfiguration.tsx @@ -59,7 +59,13 @@ export function getNavigationConfiguration({ { path: `${pathPrefix}/ownership/`, title: t('Ownership Rules'), - keywords: [t('ownership'), t('codeowners'), t('owners'), t('owner rules')], + keywords: [ + t('ownership'), + t('codeowners'), + t('code owners'), + t('owners'), + t('owner rules'), + ], description: t('Manage ownership rules for a project'), }, { @@ -107,6 +113,7 @@ export function getNavigationConfiguration({ { path: `${pathPrefix}/issue-grouping/`, title: t('Issue Grouping'), + keywords: [t('fingerprinting'), t('fingerprint rules')], }, { path: `${pathPrefix}/debug-symbols/`, @@ -173,7 +180,14 @@ export function getNavigationConfiguration({ path: `${pathPrefix}/keys/`, title: t('Client Keys (DSN)'), description: t("View and manage the project's client keys (DSN)"), - keywords: [t('dsn'), t('auth'), t('token'), t('client key'), t('dsn key')], + keywords: [ + t('dsn'), + t('auth'), + t('token'), + t('client key'), + t('dsn key'), + t('allowed domains'), + ], }, { path: `${pathPrefix}/loader-script/`, diff --git a/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx index 3df094b5cf6028..483df87828a4dc 100644 --- a/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx +++ b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx @@ -16,7 +16,7 @@ export function useCreateProjectFromWizard() { return api.requestPromise( params.team ? `/teams/${params.organization.slug}/${params.team}/projects/` - : `/organizations/${params.organization.slug}/experimental/projects/`, + : `/organizations/${params.organization.slug}/projects/`, { method: 'POST', host: params.organization.region.url, diff --git a/static/gsApp/components/codecovSettingsLink.tsx b/static/gsApp/components/codecovSettingsLink.tsx deleted file mode 100644 index d85f716d029abc..00000000000000 --- a/static/gsApp/components/codecovSettingsLink.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import {ExternalLink} from '@sentry/scraps/link'; - -import {t} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; - -import {getCodecovJwtLink, useCodecovJwt} from 'getsentry/utils/useCodecovJwt'; - -export function CodecovSettingsLink({organization}: {organization: Organization}) { - const {data: jwtData, isError} = useCodecovJwt(organization.slug); - - if (isError) { - return null; - } - - const codecovLink = getCodecovJwtLink('sentry-app-stacktracelink', jwtData); - return ( - - {t('Learn More')} - - ); -} diff --git a/static/gsApp/registerOverrides.tsx b/static/gsApp/registerOverrides.tsx index e746420eac73c5..850b04848cb6a1 100644 --- a/static/gsApp/registerOverrides.tsx +++ b/static/gsApp/registerOverrides.tsx @@ -1,7 +1,6 @@ import {lazy} from 'react'; import {LazyLoad} from 'sentry/components/lazyLoad'; -import {IconBusiness} from 'sentry/icons'; import {registerOverride} from 'sentry/overrideRegistry'; import type {Overrides} from 'sentry/types/overrides'; import type {OrganizationStatsProps} from 'sentry/views/organizationStats'; @@ -78,7 +77,6 @@ import {useScmFeatureMeta} from 'getsentry/overrides/useScmFeatureMeta'; import {rawTrackAnalyticsEvent} from 'getsentry/utils/rawTrackAnalyticsEvent'; import {trackMetric} from 'getsentry/utils/trackMetric'; -import {CodecovSettingsLink} from './components/codecovSettingsLink'; import {GsBillingCommandPaletteActions} from './components/gsBillingCommandPaletteActions'; import {PrimaryNavigationQuotaExceeded} from './components/navBillingStatus'; import {OpenInDiscoverBtn} from './components/openInDiscoverBtn'; @@ -236,7 +234,6 @@ const GETSENTRY_OVERRIDES: Partial = { InsightsDateRangeQueryLimitFooter, 'component:ai-configure-seer-quota-sidebar': () => AiConfigureSeerQuotaSidebar, 'component:ai-setup-data-consent': () => AiSetupDataConsent, - 'component:codecov-integration-settings-link': () => CodecovSettingsLink, 'component:continuous-profiling-billing-requirement-banner': () => ContinuousProfilingBillingRequirementBanner, 'component:header-date-page-filter-upsell-footer': () => DateRangeQueryLimitFooter, @@ -335,14 +332,6 @@ const GETSENTRY_OVERRIDES: Partial = { {typeof p.children === 'function' ? p.children(p) : p.children} ), - 'feature-disabled:codecov-integration-setting': () => ( - - - - ), 'feature-disabled:project-performance-score-card': p => ( {typeof p.children === 'function' ? p.children(p) : p.children} diff --git a/static/gsApp/utils/useCodecovJwt.tsx b/static/gsApp/utils/useCodecovJwt.tsx deleted file mode 100644 index 08d6e551f1c8ca..00000000000000 --- a/static/gsApp/utils/useCodecovJwt.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import type {UseApiQueryOptions} from 'sentry/utils/queryClient'; -import {useApiQuery} from 'sentry/utils/queryClient'; - -interface CodecovJWTResponse { - token: string; -} - -export function useCodecovJwt( - orgSlug: string, - options: Partial> = {} -) { - return useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/codecov-jwt/', { - path: {organizationIdOrSlug: orgSlug}, - }), - ], - { - staleTime: Infinity, - retry: false, - refetchOnWindowFocus: false, - ...options, - } - ); -} - -export function getCodecovJwtLink( - source: string, - jwtData?: CodecovJWTResponse -): string | undefined { - if (!jwtData?.token) { - return undefined; - } - - const params = new URLSearchParams({ - state: jwtData.token, - utm_medium: 'referral', - utm_source: source, - utm_campaign: 'sentry-codecov', - utm_department: 'marketing', - }); - return `https://app.codecov.io/login/?${params.toString()}`; -} diff --git a/tests/apidocs/endpoints/projects/test_dsyms.py b/tests/apidocs/endpoints/projects/test_dsyms.py index 1a47eab57b72b7..524d2ba1a56c57 100644 --- a/tests/apidocs/endpoints/projects/test_dsyms.py +++ b/tests/apidocs/endpoints/projects/test_dsyms.py @@ -1,7 +1,3 @@ -import zipfile -from io import BytesIO - -from django.core.files.uploadedfile import SimpleUploadedFile from django.test.client import RequestFactory from django.urls import reverse @@ -26,30 +22,3 @@ def test_get(self) -> None: request = RequestFactory().get(self.url) self.validate_schema(request, response) - - def test_post(self) -> None: - PROGUARD_UUID = "6dc7fdb0-d2fb-4c8e-9d6b-bb1aa98929b1" - PROGUARD_SOURCE = b"""\ - org.slf4j.helpers.Util$ClassContextSecurityManager -> org.a.b.g$a: - 65:65:void () -> - 67:67:java.lang.Class[] getClassContext() -> getClassContext - 65:65:void (org.slf4j.helpers.Util$1) -> - """ - out = BytesIO() - f = zipfile.ZipFile(out, "w") - f.writestr("proguard/%s.txt" % PROGUARD_UUID, PROGUARD_SOURCE) - f.close() - data = { - "file": SimpleUploadedFile( - "symbols.zip", out.getvalue(), content_type="application/zip" - ), - } - - response = self.client.post( - self.url, - data, - format="multipart", - ) - request = RequestFactory().post(self.url, data, SERVER_NAME="de.sentry.io", secure=True) - - self.validate_schema(request, response) diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index cbd76dac6eed77..5114098c68e435 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -50,7 +50,6 @@ export function OrganizationFixture(params: Partial = {}): Organiz avatarUuid: null, avatarUrl: null, }, - codecovAccess: false, dataScrubber: false, dataScrubberDefaults: false, dateCreated: new Date().toISOString(), diff --git a/tests/js/getsentry-test/fixtures/am1Plans.ts b/tests/js/getsentry-test/fixtures/am1Plans.ts index 5608f9b69155ca..1472d08262b9ea 100644 --- a/tests/js/getsentry-test/fixtures/am1Plans.ts +++ b/tests/js/getsentry-test/fixtures/am1Plans.ts @@ -66,7 +66,6 @@ const AM1_FREE_FEATURES = [ const AM1_TEAM_FEATURES = [ ...AM1_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', diff --git a/tests/js/getsentry-test/fixtures/am2Plans.ts b/tests/js/getsentry-test/fixtures/am2Plans.ts index 26d25f1dea4eff..cab6bce2ed447f 100644 --- a/tests/js/getsentry-test/fixtures/am2Plans.ts +++ b/tests/js/getsentry-test/fixtures/am2Plans.ts @@ -76,7 +76,6 @@ const AM2_FREE_FEATURES = [ const AM2_TEAM_FEATURES = [ ...AM2_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', diff --git a/tests/js/getsentry-test/fixtures/am3Plans.ts b/tests/js/getsentry-test/fixtures/am3Plans.ts index 0ed889ad3c42d8..9dbfb61dcd8a82 100644 --- a/tests/js/getsentry-test/fixtures/am3Plans.ts +++ b/tests/js/getsentry-test/fixtures/am3Plans.ts @@ -97,7 +97,6 @@ const AM3_FREE_FEATURES = [ const AM3_TEAM_FEATURES = [ ...AM3_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 35cfc1ed13a867..f55bda0b41d115 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -9,10 +9,13 @@ from sentry.analytics.events.advanced_search_feature_gated import AdvancedSearchFeatureGateEvent from sentry.analytics.events.manual_issue_assignment import ManualIssueAssignment -from sentry.api.helpers.group_index import update_groups, validate_search_filter_permissions +from sentry.api.helpers.group_index import ( + get_group_list, + update_groups, + validate_search_filter_permissions, +) from sentry.api.helpers.group_index.delete import schedule_tasks_to_delete_groups from sentry.api.helpers.group_index.update import ( - get_group_list, get_semver_releases, greatest_semver_release, handle_assigned_to, diff --git a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py index 7bc5d9a5512065..43a222d7b692ec 100644 --- a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py +++ b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py @@ -67,9 +67,8 @@ def get(self) -> Response[BarResponse]: m = mismatches[0] assert m.cls == "FooEndpoint" assert m.method == "get" - assert m.status == 200 - assert m.decl == "FooResponse" - assert m.annot == "BarResponse" + assert m.decl == frozenset({"FooResponse"}) + assert m.annot == frozenset({"BarResponse"}) def test_unmigrated_endpoint_skipped() -> None: @@ -109,9 +108,8 @@ def get(self) -> Response[FooResponse]: def test_canned_response_constant_skipped() -> None: - """Non-inline_sentry_response_serializer entries (e.g. RESPONSE_BAD_REQUEST) - are not comparable and must not produce false positives. - """ + """Non-`inline_sentry_response_serializer` entries (e.g. RESPONSE_BAD_REQUEST) + are not comparable and must not produce false positives.""" source = """ from typing import TypedDict from drf_spectacular.utils import extend_schema @@ -135,10 +133,36 @@ def get(self) -> Response[FooResponse]: assert _run(source) == [] -def test_only_2xx_entries_compared() -> None: - """Even if a 4xx/5xx entry used inline_sentry_response_serializer (unusual), - it should not be compared to the success-path annotation. - """ +def test_union_annotation_matches_multi_status_decorator() -> None: + """Union return type matching a decorator with multiple typed responses.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class ErrorBody(TypedDict): + detail: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", FooResponse), + 400: inline_sentry_response_serializer("Err", ErrorBody), + }, + ) + def get(self) -> Response[FooResponse] | Response[ErrorBody]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_union_annotation_missing_decorator_T_fires() -> None: + """If the decorator declares two typed responses but the annotation only + covers one, the set-equality check must fail.""" source = """ from typing import TypedDict from drf_spectacular.utils import extend_schema @@ -160,6 +184,87 @@ class FooEndpoint: ) def get(self) -> Response[FooResponse]: return Response({"x": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == frozenset({"FooResponse", "ErrorBody"}) + assert mismatches[0].annot == frozenset({"FooResponse"}) + + +def test_annotation_has_extra_T_not_in_decorator_fires() -> None: + """If the annotation union declares a `T` that the decorator doesn't, fail.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class GhostResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse] | Response[GhostResponse]: + return Response({"x": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"FooResponse", "GhostResponse"}) + + +def test_multi_2xx_decorator_with_union_annotation() -> None: + """Multiple 2xx schemas (e.g. 200 + 201) with a union annotation covering both.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class GetResponse(TypedDict): + x: int + +class CreatedResponse(TypedDict): + id: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", GetResponse), + 201: inline_sentry_response_serializer("FooCreated", CreatedResponse), + }, + ) + def post(self) -> Response[GetResponse] | Response[CreatedResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_union_with_non_response_arm_skipped() -> None: + """If the union contains an arm that isn't `Response[T]` (e.g. a bare + `HttpResponse`), the method is treated as unmigrated and skipped — there's + no clean comparison to make.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from django.http import HttpResponse +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse] | HttpResponse: + return Response({"x": 1}) """ assert _run(source) == [] @@ -186,14 +291,12 @@ async def get(self) -> Response[BarResponse]: """ mismatches = _run(source) assert len(mismatches) == 1 - assert mismatches[0].decl == "FooResponse" - assert mismatches[0].annot == "BarResponse" + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"BarResponse"}) def test_dotted_response_annotation_handled() -> None: - """Some files write the annotation as `rest_framework.response.Response[T]`. - The linter must extract T from both `Name` and `Attribute` forms. - """ + """Some files write the annotation as `rest_framework.response.Response[T]`.""" source = """ from typing import TypedDict import rest_framework.response @@ -215,8 +318,8 @@ def get(self) -> rest_framework.response.Response[BarResponse]: """ mismatches = _run(source) assert len(mismatches) == 1 - assert mismatches[0].decl == "FooResponse" - assert mismatches[0].annot == "BarResponse" + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"BarResponse"}) def test_main_returns_zero_on_clean(tmp_path: Path) -> None: @@ -268,3 +371,49 @@ def get(self) -> Response[BarResponse]: assert "FooResponse" in captured.out assert "BarResponse" in captured.out assert "mismatch" in captured.err + + +def test_direct_serializer_class_reference_skipped() -> None: + """Decorator entries that are bare class references (e.g. `MonitorSerializer`) + carry a typed output by sentry convention but no statically-resolvable link + to a TypedDict. The linter skips them silently — neither false-positives nor + false-negatives. Resolving these waits on the generic-`Serializer[T]` + refactor, or on migrating the entry to `inline_sentry_response_serializer`. + """ + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response + +class MonitorSerializer: ... + +class MonitorSerializerResponse(TypedDict): + id: str + +class MonitorEndpoint: + @extend_schema(responses={200: MonitorSerializer}) + def get(self) -> Response[MonitorSerializerResponse]: + return Response({"id": "x"}) +""" + assert _run(source) == [] + + +def test_openapi_response_wrapper_skipped() -> None: + """`OpenApiResponse(...)` and similar wrappers don't carry a comparable T — + skip silently.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class FooEndpoint: + @extend_schema( + responses={200: OpenApiResponse(description="ok")}, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 8f998c61f7e626..12ce633a5073d5 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -38,338 +38,6 @@ def integration(self): integration.add_organization(self.organization, self.user) return integration - def test_config_view(self) -> None: - resp = self.client.get(self.init_path) - assert resp.status_code == 200 - - resp = self.client.get(self.setup_path) - assert resp.status_code == 200 - self.assertContains(resp, "Connect Sentry") - self.assertContains(resp, "Submit") - - @responses.activate - def test_validate_url(self) -> None: - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Enter a valid URL") - - @responses.activate - def test_validate_private_key(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=503, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": "hot-garbage", - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains( - resp, "Private key must be a valid SSH private key encoded in a PEM format." - ) - - @responses.activate - def test_validate_consumer_key_length(self) -> None: - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "x" * 201, - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Consumer key is limited to 200") - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_request_token_timeout(self, mock_record: MagicMock) -> None: - timeout = ReadTimeout("Read timed out. (read timeout=30)") - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - body=timeout, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "request token from Bitbucket") - self.assertContains(resp, "Timed out") - - assert_failure_metric( - mock_record, "Timed out attempting to reach host: bitbucket.example.com" - ) - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_request_token_fails(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=503, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "request token from Bitbucket") - - assert_failure_metric(mock_record, "") - - @responses.activate - def test_authentication_request_token_redirect(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - - # Start pipeline - self.client.get(self.init_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - redirect = ( - "https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=abc123" - ) - assert redirect == resp["Location"] - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_access_token_failure(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - error_msg = "it broke" - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=500, - content_type="text/plain", - body=error_msg, - ) - - # Get config page - resp = self.client.get(self.init_path) - assert resp.status_code == 200 - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - assert resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "access token from Bitbucket") - - assert_failure_metric(mock_record, error_msg) - - def install_integration(self): - # Get config page - resp = self.client.get(self.setup_path) - assert resp.status_code == 200 - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - assert resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - - return resp - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_verifier_expired(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - error_msg = "oauth_error=token+expired" - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=404, - content_type="text/plain", - body=error_msg, - ) - - # Try getting the token but it has expired for some reason, - # perhaps a stale reload/history navigate. - resp = self.install_integration() - - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "access token from Bitbucket") - - assert_failure_metric(mock_record, error_msg) - - @responses.activate - def test_authentication_success(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=200, - content_type="text/plain", - body="oauth_token=valid-token&oauth_token_secret=valid-secret", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/rest/webhooks/1.0/webhook", - status=204, - body="", - ) - - self.install_integration() - - integration = Integration.objects.get() - assert integration.name == "sentry-bot" - assert integration.metadata["domain_name"] == "bitbucket.example.com" - assert integration.metadata["base_url"] == "https://bitbucket.example.com" - assert integration.metadata["verify_ssl"] is False - - org_integration = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id - ) - assert org_integration.config == {} - - idp = IdentityProvider.objects.get(type="bitbucket_server") - identity = Identity.objects.get( - idp=idp, user=self.user, external_id="bitbucket.example.com:sentry-bot" - ) - assert identity.data["consumer_key"] == "sentry-bot" - assert identity.data["access_token"] == "valid-token" - assert identity.data["access_token_secret"] == "valid-secret" - assert identity.data["private_key"] == EXAMPLE_PRIVATE_KEY - - @responses.activate - def test_setup_external_id_length(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=200, - content_type="text/plain", - body="oauth_token=valid-token&oauth_token_secret=valid-secret", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/rest/webhooks/1.0/webhook", - status=204, - body="", - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "a-very-long-consumer-key-that-when-combined-with-host-would-overflow", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - redirect = ( - "https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=abc123" - ) - assert redirect == resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - - integration = Integration.objects.get(provider="bitbucket_server") - assert ( - integration.external_id - == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" - ) - def test_source_url_matches(self) -> None: installation = self.integration.get_installation(self.organization.id) diff --git a/tests/sentry/integrations/gitlab/test_webhook.py b/tests/sentry/integrations/gitlab/test_webhook.py index 993d8759f1d3b5..a8a28dc587570d 100644 --- a/tests/sentry/integrations/gitlab/test_webhook.py +++ b/tests/sentry/integrations/gitlab/test_webhook.py @@ -15,11 +15,13 @@ WEBHOOK_TOKEN, GitLabTestCase, ) +from sentry.integrations.gitlab.webhooks import MergeEventWebhook from sentry.integrations.models.integration import Integration from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.grouplink import GroupLink from sentry.models.pullrequest import PullRequest +from sentry.seer.code_review.webhooks.merge_request import handle_merge_request_event from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric, assert_success_metric from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of @@ -342,6 +344,59 @@ def test_merge_event_no_last_commit(self, mock_record: MagicMock) -> None: assert_success_metric(mock_record) + @patch("sentry.integrations.gitlab.webhooks.metrics.incr") + def test_merge_event_no_author_email_does_not_error(self, mock_incr: MagicMock) -> None: + # A repo exists (so the processor runs), but the MR has no commit author + # email. The PR processor must stop cleanly rather than raising, which + # _handle would otherwise catch and mislabel as a processor error. + self.create_gitlab_repo("getsentry/sentry") + payload = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + del payload["object_attributes"]["last_commit"] + + response = self.client.post( + self.url, + data=orjson.dumps(payload), + content_type="application/json", + HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN, + HTTP_X_GITLAB_EVENT="Merge Request Hook", + ) + assert response.status_code == 204 + assert 0 == PullRequest.objects.count() + + error_metrics = [ + c + for c in mock_incr.call_args_list + if c.args and c.args[0] == "gitlab.webhook.processor.error" + ] + assert error_metrics == [] + + def test_merge_event_invokes_code_review_handler(self) -> None: + # The code-review handler is wired into the endpoint via the processor + # tuple. Confirm both that it is registered and that an inbound + # merge_request event is dispatched into it with the expected context. + assert handle_merge_request_event in MergeEventWebhook.WEBHOOK_EVENT_PROCESSORS + + self.create_gitlab_repo("getsentry/sentry") + + # wraps the real handler so it still runs while we record the invocation + spy = MagicMock(wraps=handle_merge_request_event) + with patch.object(MergeEventWebhook, "WEBHOOK_EVENT_PROCESSORS", (spy,)): + response = self.client.post( + self.url, + data=MERGE_REQUEST_OPENED_EVENT, + content_type="application/json", + HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN, + HTTP_X_GITLAB_EVENT="Merge Request Hook", + ) + + assert response.status_code == 204 + spy.assert_called_once() + call_kwargs = spy.call_args.kwargs + assert call_kwargs["event"]["object_attributes"]["action"] == "open" + assert call_kwargs["integration"].id == self.integration.id + assert call_kwargs["organization"].id == self.organization.id + assert call_kwargs["repo"].name == "getsentry/sentry" + def test_merge_event_create_pull_request(self) -> None: self.create_gitlab_repo("getsentry/sentry") group = self.create_group(project=self.project, short_id=9) diff --git a/tests/sentry/integrations/msteams/test_integration.py b/tests/sentry/integrations/msteams/test_integration.py index fc67907e495f1d..1e1dd8f0a0e90d 100644 --- a/tests/sentry/integrations/msteams/test_integration.py +++ b/tests/sentry/integrations/msteams/test_integration.py @@ -1,19 +1,23 @@ +from typing import Any from unittest.mock import MagicMock, patch from urllib.parse import urlencode import pytest import responses +from django.urls import reverse from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.msteams.constants import SALT from sentry.integrations.msteams.integration import MsTeamsIntegration, MsTeamsIntegrationProvider +from sentry.integrations.pipeline import IntegrationPipeline from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( NotificationProviderKey, NotificationTargetResourceType, ) from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError -from sentry.testutils.cases import IntegrationTestCase, TestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase, TestCase from sentry.testutils.silo import control_silo_test from sentry.utils import json from sentry.utils.signing import sign @@ -120,6 +124,137 @@ def test_personal_installation(self) -> None: self.assert_setup_flow(installation_type="tenant") +@control_silo_test +class MsTeamsApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + provider = MsTeamsIntegrationProvider + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + self.start_time = 1594768808 + self.install_params = { + "external_id": team_id, + "external_name": "my_team", + "service_url": "https://smba.trafficmanager.net/amer/", + "user_id": user_id, + "conversation_id": team_id, + "tenant_id": tenant_id, + } + + def tearDown(self) -> None: + responses.reset() + super().tearDown() + + def _get_pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize_pipeline(self, initial_data: dict[str, Any] | None = None) -> Any: + payload: dict[str, Any] = {"action": "initialize", "provider": self.provider.key} + if initial_data is not None: + payload["initialData"] = initial_data + return self.client.post(self._get_pipeline_url(), data=payload, format="json") + + def _advance_step(self, data: dict[str, Any]) -> Any: + return self.client.post(self._get_pipeline_url(), data=data, format="json") + + def _signed_params(self, installation_type: str) -> str: + return sign(salt=SALT, **{**self.install_params, "installation_type": installation_type}) + + @responses.activate + def test_initialize_returns_auto_advance_data(self) -> None: + resp = self._initialize_pipeline(initial_data={"signedParams": self._signed_params("team")}) + assert resp.status_code == 200 + assert resp.data["step"] == "msteams_install" + data = resp.data["data"] + assert data["appDirectoryInstall"] is True + assert "state" in data + + @responses.activate + def test_initialize_expired_signature(self) -> None: + with patch("sentry.integrations.msteams.integration.INSTALL_EXPIRATION_TIME", -1): + resp = self._initialize_pipeline( + initial_data={"signedParams": self._signed_params("team")} + ) + assert resp.status_code == 400 + + @responses.activate + def test_initialize_tampered_signature(self) -> None: + # Signed with a different salt, so unsigning with SALT raises + # BadSignature rather than SignatureExpired. + tampered = sign(salt="not-the-msteams-salt", **self.install_params) + resp = self._initialize_pipeline(initial_data={"signedParams": tampered}) + assert resp.status_code == 400 + + def _complete_install(self, installation_type: str) -> str: + """Run the full API pipeline and return the post-install card body.""" + responses.add( + responses.POST, + "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token", + json={"expires_in": 86399, "access_token": "my_token"}, + ) + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations/%s/activities" % team_id, + json={}, + ) + + with patch("time.time") as mock_time: + mock_time.return_value = self.start_time + + resp = self._initialize_pipeline( + initial_data={"signedParams": self._signed_params(installation_type)} + ) + pipeline_signature = resp.data["data"]["state"] + + resp = self._advance_step({"state": pipeline_signature}) + + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + token_body = responses.calls[0].request.body + assert token_body == urlencode( + { + "client_id": "msteams-client-id", + "client_secret": "msteams-client-secret", + "grant_type": "client_credentials", + "scope": "https://api.botframework.com/.default", + } + ) + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.external_id == team_id + assert integration.name == "my_team" + assert integration.metadata == { + "access_token": "my_token", + "service_url": "https://smba.trafficmanager.net/amer/", + "expires_at": self.start_time + 86399 - 60 * 5, + "installation_type": installation_type, + "tenant_id": tenant_id, + } + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + + return responses.calls[1].request.body.decode("utf-8") + + @responses.activate + def test_team_installation(self) -> None: + post_install_body = self._complete_install(installation_type="team") + assert f"organizations/{self.organization.slug}/alerts/rules/" in post_install_body + assert self.organization.name in post_install_body + + @responses.activate + def test_personal_installation(self) -> None: + post_install_body = self._complete_install(installation_type="tenant") + assert "Personal installation successful" in post_install_body + assert "/settings/account/notifications" in post_install_body + + @control_silo_test class MsTeamsIntegrationSendNotificationTest(TestCase): def setUp(self) -> None: diff --git a/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py b/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py new file mode 100644 index 00000000000000..36e78b52612a5d --- /dev/null +++ b/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py @@ -0,0 +1,134 @@ +import uuid +from unittest import mock + +import responses + +from sentry.models.activity import Activity +from sentry.models.options.project_option import ProjectOption +from sentry.notifications.notification_action.action_handler_registry.plugin_handler import ( + PluginActionHandler, +) +from sentry.plugins.base import plugins +from sentry.testutils.helpers.features import with_feature +from sentry.types.activity import ActivityType +from sentry.utils import json +from sentry.workflow_engine.models import Action +from sentry.workflow_engine.types import ActionInvocation, WorkflowEventData +from tests.sentry.workflow_engine.test_base import BaseWorkflowTest + + +class TestPluginActionHandlerExecute(BaseWorkflowTest): + def setUp(self) -> None: + super().setUp() + self.detector = self.create_detector(project=self.project) + self.workflow = self.create_workflow(environment=self.environment) + self.action = self.create_action( + type=Action.Type.PLUGIN, + ) + self.group, self.event, self.group_event = self.create_group_event() + self.event_data = WorkflowEventData( + event=self.group_event, workflow_env=self.environment, group=self.group + ) + self.invocation = ActionInvocation( + event_data=self.event_data, + action=self.action, + detector=self.detector, + notification_uuid=str(uuid.uuid4()), + workflow_id=self.workflow.id, + ) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + @responses.activate + def test_default_no_flags_fires_old_path_only(self) -> None: + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + assert len(responses.calls) == 1 + + @responses.activate + @with_feature( + { + "organizations:legacy-webhook-new-path": True, + "organizations:legacy-webhook-disable-old-path": True, + } + ) + def test_new_path_skips_webhooks_in_old_path(self) -> None: + """When new path is on and old path disabled, webhooks are sent via the + new task-based service and skipped in the old path.""" + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + assert len(responses.calls) == 1 + body = json.loads(responses.calls[0].request.body) + assert body["id"] == str(self.group.id) + + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.send_legacy_webhooks_for_invocation" + ) + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry" + ) + def test_old_path_always_runs( + self, mock_old_path: mock.MagicMock, mock_new_path: mock.MagicMock + ) -> None: + """Old path runs regardless of flags to keep non-webhook plugins firing.""" + PluginActionHandler.execute(self.invocation) + + mock_old_path.assert_called_once_with(self.invocation) + mock_new_path.assert_called_once_with(self.invocation) + + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.send_legacy_webhooks_for_invocation" + ) + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry" + ) + def test_non_group_event_skips_both_paths( + self, mock_old_path: mock.MagicMock, mock_new_path: mock.MagicMock + ) -> None: + activity = Activity.objects.create( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = ActionInvocation( + event_data=WorkflowEventData( + event=activity, workflow_env=self.environment, group=self.group + ), + action=self.action, + detector=self.detector, + notification_uuid=str(uuid.uuid4()), + workflow_id=self.workflow.id, + ) + + PluginActionHandler.execute(invocation) + + mock_old_path.assert_not_called() + mock_new_path.assert_not_called() + + @responses.activate + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry", + side_effect=Exception("legacy path error"), + ) + def test_old_path_exception_does_not_block_new_path( + self, mock_old_path: mock.MagicMock + ) -> None: + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + mock_old_path.assert_called_once() + assert len(responses.calls) == 1 + body = json.loads(responses.calls[0].request.body) + assert body["id"] == str(self.group.id) diff --git a/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py b/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py index e8cf060017eaf8..3229c0293b41fa 100644 --- a/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py +++ b/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py @@ -226,6 +226,23 @@ def test_new_path_webhooks_action_routes_to_legacy_webhook( mock_legacy.assert_called_once_with(self.invocation) mock_sentry_app.assert_not_called() + @responses.activate + @with_feature( + { + "organizations:legacy-webhook-new-path": True, + "organizations:legacy-webhook-disable-old-path": True, + } + ) + def test_new_path_disabled_webhooks_does_not_send(self) -> None: + responses.add(responses.POST, "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", False, self.project) + + with self.tasks(): + WebhookActionHandler.execute(self.invocation) + + assert len(responses.calls) == 0 + @responses.activate @mock.patch( "sentry.notifications.notification_action.action_handler_registry.webhook_handler.execute_via_group_type_registry", diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index f3fcda47a71e10..1c59242b727df9 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -332,6 +332,13 @@ def _post_selective(self, **overrides): self._get_create_url(), self._selective_data(**overrides), format="json" ) + def _post_regex(self, patterns, **overrides): + data = self._selective_data(**overrides) + del data["all_image_file_names"] + data["all_image_file_names_as_regex"] = patterns + with self.feature("organizations:preprod-snapshots"): + return self.client.post(self._get_create_url(), data, format="json") + def test_all_image_file_names_rejects_empty_list(self): response = self._post_selective(images={}, all_image_file_names=[]) assert response.status_code == 400 @@ -363,6 +370,48 @@ def test_selective_with_all_image_file_names_accepted(self): response = self._post_selective() assert response.status_code == 200 + def test_all_image_file_names_as_regex_accepted(self): + response = self._post_regex([r".*\.png"]) + assert response.status_code == 200 + + def test_all_image_file_names_as_regex_mutually_exclusive(self): + response = self._post_selective(all_image_file_names_as_regex=[r".*\.png"]) + assert response.status_code == 400 + assert "mutually exclusive" in response.data["detail"] + + def test_all_image_file_names_as_regex_requires_selective(self): + response = self._post_regex([r".*\.png"], selective=False) + assert response.status_code == 400 + assert "selective" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_empty_list(self): + response = self._post_regex([]) + assert response.status_code == 400 + assert "empty" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_invalid_pattern(self): + response = self._post_regex(["["]) + assert response.status_code == 400 + assert "all_image_file_names_as_regex" in response.data["detail"] + assert "invalid regex pattern" in response.data["detail"] + assert "[" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_unsupported_construct(self): + response = self._post_regex([r"screen(?=\.png)"]) + assert response.status_code == 400 + assert "invalid regex pattern" in response.data["detail"] + assert r"screen(?=\.png)" in response.data["detail"] + + def test_all_image_file_names_as_regex_must_match_all_images(self): + response = self._post_regex([r"other\.png"]) + assert response.status_code == 400 + assert "must match a pattern" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_overlong_pattern(self): + response = self._post_regex(["a" * 501]) + assert response.status_code == 400 + assert "all_image_file_names_as_regex" in response.data["detail"] + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_preprod_session") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_base_upload_triggers_comparison_for_waiting_head( diff --git a/tests/sentry/preprod/snapshots/test_manifest.py b/tests/sentry/preprod/snapshots/test_manifest.py index fda2da6202b42e..528a46e4762e62 100644 --- a/tests/sentry/preprod/snapshots/test_manifest.py +++ b/tests/sentry/preprod/snapshots/test_manifest.py @@ -1,6 +1,13 @@ import jsonschema +import pydantic +import pytest -from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest +from sentry.preprod.snapshots.manifest import ( + ImageMetadata, + InvalidImageNamePattern, + SnapshotManifest, + make_image_name_matcher, +) def _meta(**kwargs: object) -> dict: @@ -9,6 +16,41 @@ def _meta(**kwargs: object) -> dict: return defaults +class TestSnapshotManifestHeadImageNameMatcher: + def test_no_declared_set_returns_none(self) -> None: + manifest = SnapshotManifest(images={}) + assert manifest.head_image_name_matcher() is None + + def test_literal_names_matcher(self) -> None: + manifest = SnapshotManifest(images={}, all_image_file_names=["a.png", "b.png"]) + matches_head_image = manifest.head_image_name_matcher() + assert matches_head_image is not None + assert matches_head_image("a.png") + assert not matches_head_image("c.png") + + def test_regex_matcher_uses_full_match(self) -> None: + manifest = SnapshotManifest(images={}, all_image_file_names_as_regex=[r"login\.png"]) + matches_head_image = manifest.head_image_name_matcher() + assert matches_head_image is not None + assert matches_head_image("login.png") + assert not matches_head_image("screens/login.png") + + def test_both_fields_set_is_rejected(self) -> None: + with pytest.raises(pydantic.ValidationError): + SnapshotManifest( + images={}, + all_image_file_names=["a.png"], + all_image_file_names_as_regex=[r"a\.png"], + ) + + def test_make_image_name_matcher_rejects_unsupported_construct(self) -> None: + with pytest.raises(InvalidImageNamePattern) as exc: + make_image_name_matcher([r"a(?=b)"]) + assert exc.value.pattern == r"a(?=b)" + with pytest.raises(InvalidImageNamePattern): + make_image_name_matcher([r"(a)\1"]) + + class TestImageMetadataTagsCoercion: def test_tags_none(self) -> None: meta = ImageMetadata(**_meta()) diff --git a/tests/sentry/preprod/snapshots/test_tasks.py b/tests/sentry/preprod/snapshots/test_tasks.py index dd71c6698e2773..47a9c10b6db459 100644 --- a/tests/sentry/preprod/snapshots/test_tasks.py +++ b/tests/sentry/preprod/snapshots/test_tasks.py @@ -242,3 +242,74 @@ def test_selective_without_names_all_missing_are_skipped(self) -> None: assert result.skipped == {"b.png", "c.png"} assert result.removed == set() assert result.matched == {"a.png"} + + +class TestCategorizeImageDiffSelectiveRegex: + def test_regex_all_categories(self) -> None: + head = SnapshotManifest( + images={"new.png": _meta("h_new"), "matched.png": _meta("h1")}, + selective=True, + all_image_file_names_as_regex=[r".*\.png"], + ) + base = SnapshotManifest( + images={ + "matched.png": _meta("h1"), + "skipped.png": _meta("h2"), + "deleted.txt": _meta("h3"), + } + ) + + result = categorize_image_diff(head, base) + + assert result.added == {"new.png"} + assert result.matched == {"matched.png"} + assert result.skipped == {"skipped.png"} + assert result.removed == {"deleted.txt"} + + def test_regex_multiple_patterns(self) -> None: + head = SnapshotManifest( + images={"dark/a.png": _meta("h1")}, + selective=True, + all_image_file_names_as_regex=[r"dark/.*", r"light/.*"], + ) + base = SnapshotManifest( + images={ + "dark/a.png": _meta("h1"), + "light/b.png": _meta("h2"), + "other/c.png": _meta("h3"), + } + ) + + result = categorize_image_diff(head, base) + + assert result.matched == {"dark/a.png"} + assert result.skipped == {"light/b.png"} + assert result.removed == {"other/c.png"} + + def test_regex_rename_old_name_not_matching(self) -> None: + head = SnapshotManifest( + images={"new.png": _meta("shared")}, + selective=True, + all_image_file_names_as_regex=[r"new\.png"], + ) + base = SnapshotManifest(images={"old.png": _meta("shared")}) + + result = categorize_image_diff(head, base) + + assert result.renamed_pairs == [("new.png", "old.png")] + assert result.removed == set() + + def test_regex_rename_old_name_matching_is_detected_from_skipped(self) -> None: + head = SnapshotManifest( + images={"screens/new.png": _meta("shared")}, + selective=True, + all_image_file_names_as_regex=[r"screens/.*"], + ) + base = SnapshotManifest(images={"screens/old.png": _meta("shared")}) + + result = categorize_image_diff(head, base) + + assert result.renamed_pairs == [("screens/new.png", "screens/old.png")] + assert result.skipped == set() + assert result.added == set() + assert result.removed == set() diff --git a/tests/sentry/rules/actions/test_notify_event.py b/tests/sentry/rules/actions/test_notify_event.py index 2403c2de7e5aff..6c558c1b7888e0 100644 --- a/tests/sentry/rules/actions/test_notify_event.py +++ b/tests/sentry/rules/actions/test_notify_event.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock +from sentry.models.options.project_option import ProjectOption +from sentry.plugins.base import plugins from sentry.rules.actions.notify_event import NotifyEventAction from sentry.rules.actions.services import LegacyPluginService from sentry.testutils.cases import RuleTestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba pytestmark = [requires_snuba] @@ -23,3 +26,24 @@ def test_applies_correctly(self) -> None: assert len(results) == 1 assert plugin.should_notify.call_count == 1 assert results[0].callback is plugin.rule_notify + + def test_get_plugins_includes_webhooks_by_default(self) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + rule = self.get_rule() + result_slugs = [p.service.slug for p in rule.get_plugins()] + + assert "webhooks" in result_slugs + + @with_feature("organizations:legacy-webhook-disable-old-path") + def test_get_plugins_skips_webhooks_when_old_path_disabled(self) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + rule = self.get_rule() + result_slugs = [p.service.slug for p in rule.get_plugins()] + + assert "webhooks" not in result_slugs diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index df530fe6d273b7..9196687e362333 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1036,14 +1036,18 @@ def test_resolver_cache_function(self) -> None: assert (resolved_column, virtual_context) == (p95_column, p95_context) -def _make_deprecated_metadata(attr_type: AttributeType, replacement: str) -> AttributeMetadata: +def _make_deprecated_metadata( + attr_type: AttributeType, + replacement: str, + status: DeprecationStatus = DeprecationStatus.BACKFILL, +) -> AttributeMetadata: return AttributeMetadata( brief="", type=attr_type, pii=PiiInfo(isPii=IsPii.FALSE), is_in_otel=False, visibility=Visibility.PUBLIC, - deprecation=DeprecationInfo(replacement=replacement, status=DeprecationStatus.BACKFILL), + deprecation=DeprecationInfo(replacement=replacement, status=status), ) @@ -1249,3 +1253,50 @@ def test_deprecated_attribute_normalizes_supported_convention_attribute_types() assert attribute_definitions["old_double"].search_type == "number" assert attribute_definitions["new_double"].search_type == "number" + + +def test_normalize_deprecated_attributes_resolve_to_replacement() -> None: + attribute_definitions: dict[str, ResolvedAttribute] = {} + + _update_attribute_definitions_with_deprecations( + attribute_definitions, + { + "old_attr": _make_deprecated_metadata( + AttributeType.STRING, "new_attr", status=DeprecationStatus.NORMALIZE + ), + }, + ) + + assert "old_attr" in attribute_definitions + assert attribute_definitions["old_attr"].replacement == "new_attr" + assert attribute_definitions["old_attr"].deprecation_status == "normalize" + assert "new_attr" in attribute_definitions + assert attribute_definitions["new_attr"].search_type == "string" + + +def test_normalize_deprecated_attribute_preserves_existing_definition() -> None: + attribute_definitions = { + "gen_ai.request.messages": ResolvedAttribute( + public_alias="gen_ai.request.messages", + internal_name="gen_ai.request.messages", + search_type="string", + ), + } + + _update_attribute_definitions_with_deprecations( + attribute_definitions, + { + "gen_ai.request.messages": _make_deprecated_metadata( + AttributeType.STRING, "gen_ai.input.messages", status=DeprecationStatus.NORMALIZE + ), + }, + ) + + deprecated_attr = attribute_definitions["gen_ai.request.messages"] + replacement_attr = attribute_definitions["gen_ai.input.messages"] + + assert deprecated_attr.replacement == "gen_ai.input.messages" + assert deprecated_attr.deprecation_status == "normalize" + assert replacement_attr.public_alias == "gen_ai.input.messages" + assert replacement_attr.internal_name == "gen_ai.input.messages" + assert replacement_attr.search_type == "string" diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 54139cd7914b0f..59b83087607cd6 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1059,3 +1059,63 @@ def test_passes_correct_pr_description_suffix(self, mock_post): body = mock_post.call_args[0][0] assert body["payload"]["pr_description_suffix"] == f"Fixes {self.group.qualified_short_id}" + + @patch("sentry.seer.agent.client.make_agent_update_request") + def test_pr_description_suffix_includes_linear_issue(self, mock_post): + mock_post.return_value = MagicMock(status=200) + self.create_platform_external_issue( + group=self.group, + service_type="linear", + display_name="PROJ#123", + web_url="https://linear.app/proj/issue/PROJ-123", + ) + state = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={}, + metadata={"group_id": self.group.id}, + ) + + with self.feature("organizations:gen-ai-features"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + state=state, + ) + + body = mock_post.call_args[0][0] + expected = f"Fixes {self.group.qualified_short_id}\nFixes [PROJ-123](https://linear.app/proj/issue/PROJ-123)" + assert body["payload"]["pr_description_suffix"] == expected + + @patch("sentry.seer.agent.client.make_agent_update_request") + def test_pr_description_suffix_linear_alphanumeric_prefix(self, mock_post): + mock_post.return_value = MagicMock(status=200) + self.create_platform_external_issue( + group=self.group, + service_type="linear", + display_name="PROJ2#456", + web_url="https://linear.app/team/issue/PROJ2-456", + ) + state = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={}, + metadata={"group_id": self.group.id}, + ) + + with self.feature("organizations:gen-ai-features"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + state=state, + ) + + body = mock_post.call_args[0][0] + expected = f"Fixes {self.group.qualified_short_id}\nFixes [PROJ2-456](https://linear.app/team/issue/PROJ2-456)" + assert body["payload"]["pr_description_suffix"] == expected diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index b9b7f885e1ac7b..f64ede07004a36 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -546,7 +546,6 @@ def test_trigger_coding_agent_handoff_clears_preference_on_not_found(self, mock_ assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger): diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 0c1796a3c1be8d..1583c3bd2b026d 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -25,7 +25,6 @@ CodingAgentStatus, add_seer_project_repos, bulk_read_preferences_from_sentry_db, - bulk_update_seer_project_settings, bulk_write_preferences_to_sentry_db, clear_preference_automation_handoff, deduplicate_repositories, @@ -1040,9 +1039,19 @@ def _assert_handoff_options_cleared(self) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - def test_clears_all_four_handoff_options(self) -> None: + def test_clears_handoff_options(self) -> None: + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + + clear_preference_automation_handoff(self.project) + + self._assert_handoff_options_cleared() + + def test_preserves_auto_create_pr(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -1053,6 +1062,7 @@ def test_clears_all_four_handoff_options(self) -> None: clear_preference_automation_handoff(self.project) self._assert_handoff_options_cleared() + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True def test_preserves_unrelated_preference_fields(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") @@ -1609,188 +1619,134 @@ def test_returns_none_when_message_is_not_a_string(self) -> None: class TestUpdateSeerProjectSettings(TestCase): def setUp(self) -> None: super().setUp() - self.project = self.create_project(organization=self.organization) + self.project1 = self.create_project(organization=self.organization) + self.project2 = self.create_project(organization=self.organization) + + def test_updates_settings(self) -> None: + """All fields should be written to the correct project options.""" + update_seer_project_settings( + [self.project1.id], + { + "agent": AutomationCodingAgent.SEER, + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "automation_tuning": AutofixAutomationTuningSettings.MEDIUM, + "scanner_automation": False, + }, + ) + + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project1.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert self.project1.get_option("sentry:seer_scanner_automation") is False + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + + def test_mixed_sets_and_clears_settings(self) -> None: + """New and existing fields are upserted. Fields set to their defaults are cleared.""" + self.project1.update_option( + "sentry:seer_automation_handoff_target", + CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, + ) + self.project1.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) + + update_seer_project_settings( + [self.project1.id], + {"agent": AutomationCodingAgent.SEER, "scanner_automation": False}, + ) + + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_scanner_automation") is False + + assert not ProjectOption.objects.filter( + project=self.project1, key="sentry:seer_automation_handoff_target" + ).exists() def test_agent_seer_clears_handoff_options(self) -> None: """Setting agent=seer should delete all handoff-related project options.""" - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_target", CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, ) - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE ) - self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.SEER}) + update_seer_project_settings([self.project1.id], {"agent": AutomationCodingAgent.SEER}) - assert self.project.get_option("sentry:seer_automation_handoff_target") is None - assert self.project.get_option("sentry:seer_automation_handoff_point") is None - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None def test_agent_external_sets_handoff_options(self) -> None: - """Setting agent=cursor with integrationId should set handoff target, point, and integration ID.""" + """Setting agent=cursor with integration_id should set handoff target, point, and integration ID.""" update_seer_project_settings( - self.project, {"agent": AutomationCodingAgent.CURSOR, "integrationId": 99} + [self.project1.id], + {"agent": AutomationCodingAgent.CURSOR, "integration_id": 99}, ) assert ( - self.project.get_option("sentry:seer_automation_handoff_target") + self.project1.get_option("sentry:seer_automation_handoff_target") == CodingAgentProviderType.CURSOR_BACKGROUND_AGENT ) assert ( - self.project.get_option("sentry:seer_automation_handoff_point") + self.project1.get_option("sentry:seer_automation_handoff_point") == AutofixHandoffPoint.ROOT_CAUSE ) - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") == 99 + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") == 99 def test_agent_external_requires_integration_id(self) -> None: - """Setting an external agent without integrationId should raise ValueError.""" + """Setting an external agent without integration_id should raise ValueError.""" with pytest.raises(ValueError): - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.CURSOR}) - - def test_agent_external_with_open_pr_sets_auto_create_pr(self) -> None: - """External agent + stoppingPoint=open_pr should set auto_create_pr=True.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_agent_external_with_non_open_pr_does_not_set_auto_create_pr(self) -> None: - """External agent + stoppingPoint!=open_pr should not set auto_create_pr.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.CODE_CHANGES, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint=off should set tuning to OFF and preserve stopping point and auto_create_pr.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM - ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - update_seer_project_settings(self.project, {"stoppingPoint": "off"}) + update_seer_project_settings( + [self.project1.id], {"agent": AutomationCodingAgent.CURSOR} + ) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + def test_stopping_point_omitted_preserves_existing(self) -> None: + """Omitting stopping_point should leave stopping point and auto_create_pr unchanged.""" + self.project1.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + self.project1.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - def test_stopping_point_sets_tuning_medium_and_stores_value(self) -> None: - """A non-off stoppingPoint should set tuning to MEDIUM and store the value.""" - update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.ROOT_CAUSE} - ) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) - assert ( - self.project.get_option("sentry:seer_automated_run_stopping_point") - == AutofixStoppingPoint.ROOT_CAUSE - ) + assert self.project1.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project1.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_stopping_point_omitted_preserves_existing_options(self) -> None: - """Omitting stoppingPoint from data should leave tuning, stopping point, and auto_create_pr unchanged.""" - self.project.update_option( + def test_automation_tuning_omitted_preserves_existing(self) -> None: + """Omitting automation_tuning should leave the existing value unchanged.""" + self.project1.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - update_seer_project_settings(self.project, {"scannerAutomation": False}) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) assert ( - self.project.get_option("sentry:autofix_automation_tuning") + self.project1.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: - """Changing stoppingPoint away from open_pr should clear auto_create_pr.""" - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - self.project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) + def test_bulk_updates_settings(self) -> None: + """The provided settings fields should be applied to every project.""" update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.CODE_CHANGES} - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_automation_handoff_auto_create_pr" - ).exists() - - def test_stopping_point_open_pr_sets_auto_create_pr(self) -> None: - """stoppingPoint=open_pr should set auto_create_pr, even if no handoff is configured.""" - update_seer_project_settings(self.project, {"stoppingPoint": AutofixStoppingPoint.OPEN_PR}) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_scanner_automation_false(self) -> None: - """scannerAutomation=false should update the project option.""" - update_seer_project_settings(self.project, {"scannerAutomation": False}) - - assert self.project.get_option("sentry:seer_scanner_automation") is False - - def test_deletes_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - self.project.update_option("sentry:seer_scanner_automation", False) - assert ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - update_seer_project_settings(self.project, {"scannerAutomation": True}) - - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - -class TestBulkUpdateSeerProjectSettings(TestCase): - def setUp(self) -> None: - super().setUp() - self.project_a = self.create_project(organization=self.organization) - self.project_b = self.create_project(organization=self.organization) - self.projects = [self.project_a, self.project_b] - - def test_empty_projects(self) -> None: - """Empty project list should be a no-op without errors.""" - bulk_update_seer_project_settings([], {"scannerAutomation": False}) - - def test_sets_options(self) -> None: - """All provided settings fields should be applied to every project.""" - bulk_update_seer_project_settings( - self.projects, + [self.project1.id, self.project2.id], { "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - "scannerAutomation": False, + "integration_id": 99, + "stopping_point": AutofixStoppingPoint.OPEN_PR, + "scanner_automation": False, }, ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:seer_automation_handoff_target") == AutomationCodingAgent.CURSOR @@ -1800,113 +1756,45 @@ def test_sets_options(self) -> None: == AutofixHandoffPoint.ROOT_CAUSE ) assert project.get_option("sentry:seer_automation_handoff_integration_id") == 99 - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) assert ( project.get_option("sentry:seer_automated_run_stopping_point") == AutofixStoppingPoint.OPEN_PR ) - assert project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True assert project.get_option("sentry:seer_scanner_automation") is False - def test_agent_seer_clears_handoff_options(self) -> None: - """Switching to seer agent should delete handoff options across all projects.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings(self.projects, {"agent": AutomationCodingAgent.SEER}) - - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - - def test_upserts_existing_options(self) -> None: - """Existing options should be overwritten, not duplicated.""" - for project in self.projects: - project.update_option("sentry:seer_scanner_automation", True) - - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) - - for project in self.projects: - assert project.get_option("sentry:seer_scanner_automation") is False - assert ( - ProjectOption.objects.filter( - project=project, key="sentry:seer_scanner_automation" - ).count() - == 1 - ) - - def test_clears_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - for project in self.projects: - project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + def test_empty_projects(self) -> None: + """Empty project list should be a no-op without errors.""" + update_seer_project_settings([], {"scanner_automation": False}) - bulk_update_seer_project_settings( - self.projects, - {"stoppingPoint": AutofixStoppingPoint(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT)}, + def test_does_not_modify_excluded_projects(self) -> None: + """Projects not included in the update list should be completely unaffected.""" + self.project1.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR + ) + self.project2.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR ) - for project in self.projects: - assert ( - project.get_option("sentry:seer_automated_run_stopping_point") - == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT - ) - assert not ProjectOption.objects.filter( - project=project, key="sentry:seer_automated_run_stopping_point" - ).exists() - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint='off' should set tuning to OFF and preserve existing stopping point.""" - for project in self.projects: - project.update_option( - "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR - ) - project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - bulk_update_seer_project_settings(self.projects, {"stoppingPoint": "off"}) - - for project in self.projects: - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - - def test_mixed_sets_and_clears_options(self) -> None: - """Test that sets new options and deletes existing ones.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings( - self.projects, - {"agent": AutomationCodingAgent.SEER, "scannerAutomation": False}, + update_seer_project_settings( + [self.project1.id], + { + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "agent": AutomationCodingAgent.SEER, + }, ) - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert project.get_option("sentry:seer_scanner_automation") is False + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project2.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.OPEN_PR + ) - def test_omitted_fields_preserve_existing_options(self) -> None: - """Updating one field should not clobber unrelated existing options.""" - for project in self.projects: + def test_bulk_omitted_fields_preserve_existing_options(self) -> None: + """Updating one field should not clobber unrelated existing options across multiple projects.""" + for project in [self.project1, self.project2]: project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) @@ -1915,9 +1803,12 @@ def test_omitted_fields_preserve_existing_options(self) -> None: ) project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) + update_seer_project_settings( + [self.project1.id, self.project2.id], + {"scanner_automation": False}, + ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 7ebcfeae65a9cd..5e84e847788df0 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -41,4 +41,3 @@ def test_not_found_clears_automation_handoff(self, mock_trigger) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False diff --git a/tests/sentry/seer/code_review/test_utils.py b/tests/sentry/seer/code_review/test_utils.py index c7c53af43fc71b..34d9081abf1a30 100644 --- a/tests/sentry/seer/code_review/test_utils.py +++ b/tests/sentry/seer/code_review/test_utils.py @@ -922,3 +922,34 @@ def test_provider_is_github_enterprise_for_ghe_integration(self) -> None: event_payload={"repository": {"private": False}}, ) assert result["provider"] == "github_enterprise" + + def test_gitlab_uses_config_path_not_display_name(self) -> None: + from sentry.seer.code_review.utils import _build_repo_definition + + repo = self._make_repo("integrations:gitlab") + # Repository.name is the display "name_with_namespace"; it must not be used. + repo.name = "Cool Group / Sentry" + repo.config = {"path": "cool-group/sentry"} + + result = _build_repo_definition( + repo=repo, + target_commit_sha="abc123", + event_payload={"project": {"visibility_level": 0}}, + ) + assert result["provider"] == "gitlab" + assert result["owner"] == "cool-group" + assert result["name"] == "sentry" + + def test_gitlab_missing_config_path_raises_rather_than_using_display_name(self) -> None: + from sentry.seer.code_review.utils import _build_repo_definition + + repo = self._make_repo("integrations:gitlab") + repo.name = "Cool Group / Sentry" + repo.config = {} + + with pytest.raises(ValueError): + _build_repo_definition( + repo=repo, + target_commit_sha="abc123", + event_payload={"project": {}}, + ) diff --git a/tests/sentry/seer/code_review/webhooks/test_merge_request.py b/tests/sentry/seer/code_review/webhooks/test_merge_request.py new file mode 100644 index 00000000000000..26ebdfd1f6c8e1 --- /dev/null +++ b/tests/sentry/seer/code_review/webhooks/test_merge_request.py @@ -0,0 +1,778 @@ +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import orjson +import pytest + +from fixtures.gitlab import MERGE_REQUEST_OPENED_EVENT, GitLabTestCase +from sentry.models.organization import Organization +from sentry.models.organizationcontributors import OrganizationContributors +from sentry.models.repositorysettings import CodeReviewTrigger +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.code_review.webhooks.merge_request import ( + WEBHOOK_SEEN_KEY_PREFIX, + handle_merge_request_event, +) +from sentry.testutils.helpers.features import with_feature +from sentry.utils.redis import redis_clusters + + +def _make_event(action: str = "open", **overrides: object) -> dict[str, Any]: + event = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + event["object_attributes"]["action"] = action + # GitLab sends "changes" as a top-level payload field; everything else here + # (oldrev, draft, work_in_progress, last_commit, ...) lives in object_attributes. + if "changes" in overrides: + event["changes"] = overrides.pop("changes") + for key, value in overrides.items(): + event["object_attributes"][key] = value + return event + + +def _rpc_org(org: Organization) -> RpcOrganization: + return RpcOrganization( + id=org.id, + slug=org.slug, + name=org.name, + ) + + +class MergeRequestEventWebhookTest(GitLabTestCase): + CODE_REVIEW_FEATURES = { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + + @pytest.fixture(autouse=True) + def mock_seer_request(self) -> Generator[None]: + with patch("sentry.seer.code_review.webhooks.task.make_seer_request") as mock_seer: + self.mock_seer = mock_seer + yield + + def _setup_code_review( + self, + triggers: list[CodeReviewTrigger] | None = None, + name: str = "Cool Group / Sentry", + path: str = "cool-group/sentry", + ) -> None: + if triggers is None: + triggers = [ + CodeReviewTrigger.ON_NEW_COMMIT, + CodeReviewTrigger.ON_READY_FOR_REVIEW, + ] + + # GitLab stores Repository.name as the display "name_with_namespace"; the + # URL slug used to address the project lives in config["path"]. + repo = self.create_gitlab_repo(name=name) + repo.config["path"] = path + repo.save() + + trigger_values = [t.value for t in triggers] + self.create_repository_settings( + repository=repo, + enabled_code_review=True, + code_review_triggers=trigger_values, + ) + + OrganizationContributors.objects.get_or_create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier="51", + defaults={"alias": "root"}, + ) + + self.repo = repo + + def _call_handler(self, event: dict[str, Any]) -> None: + handle_merge_request_event( + event=event, + organization=_rpc_org(self.organization), + repo=self.repo, + integration=self.integration, + ) + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_open_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/review-request" + + @with_feature({"organizations:gen-ai-features", "organizations:code-review-beta"}) + def test_skips_when_gitlab_flag_disabled(self) -> None: + # The GitLab MR handler is gated on organizations:seer-code-review-gitlab, + # independent of the other code-review flags. + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_uses_pr_closed_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/pr-closed" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_merge_uses_pr_closed_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("merge") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/pr-closed" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_without_oldrev_is_skipped(self) -> None: + self._setup_code_review() + event = _make_event("update") + assert "oldrev" not in event["object_attributes"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_with_unrelated_changes_is_skipped(self) -> None: + # An "update" that only edits metadata (no new commit, no un-draft) must not + # trigger a review. + self._setup_code_review() + event = _make_event("update", changes={"title": {"previous": "a", "current": "b"}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_uses_review_request_endpoint(self) -> None: + # GitLab has no "ready_for_review" action; un-drafting arrives as an "update" + # whose changes flip draft -> false, and must be treated as ready-for-review. + self._setup_code_review() + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + assert "oldrev" not in event["object_attributes"] + # GitLab delivers "changes" at the top level, not under object_attributes. + assert "changes" in event and "changes" not in event["object_attributes"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + assert self.mock_seer.call_args[1]["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_via_work_in_progress_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event( + "update", changes={"work_in_progress": {"previous": True, "current": False}} + ) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + assert self.mock_seer.call_args[1]["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_trigger_is_ready_for_review(self) -> None: + self._setup_code_review() + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + payload = self.mock_seer.call_args[1]["payload"] + assert payload["data"]["config"]["trigger"] == "on_ready_for_review" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_filtered_when_ready_trigger_disabled(self) -> None: + # An un-draft maps to ON_READY_FOR_REVIEW, so a repo that only enabled + # ON_NEW_COMMIT must not get a review for it. + self._setup_code_review(triggers=[CodeReviewTrigger.ON_NEW_COMMIT]) + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_draft_mr(self) -> None: + self._setup_code_review() + event = _make_event("open", draft=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_work_in_progress_mr(self) -> None: + self._setup_code_review() + event = _make_event("open", work_in_progress=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_still_sends_for_draft_mr(self) -> None: + self._setup_code_review() + event = _make_event("close", draft=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_unsupported_action(self) -> None: + self._setup_code_review() + event = _make_event("approved") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_unknown_action(self) -> None: + self._setup_code_review() + event = _make_event("future_action_not_in_enum") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_missing_action(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["object_attributes"]["action"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_when_integration_is_none(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + handle_merge_request_event( + event=event, + organization=_rpc_org(self.organization), + repo=self.repo, + integration=None, + ) + + self.mock_seer.assert_not_called() + + def test_skips_when_code_review_not_enabled(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_missing_last_commit(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["object_attributes"]["last_commit"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_open_filtered_when_trigger_disabled(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_NEW_COMMIT]) + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_filtered_when_trigger_disabled(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_READY_FOR_REVIEW]) + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_filtered_when_no_triggers_configured(self) -> None: + self._setup_code_review(triggers=[]) + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_sends_when_triggers_configured(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_READY_FOR_REVIEW]) + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_correct_pr_id(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["pr_id"] == 1 + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_gitlab_provider(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["repo"]["provider"] == "gitlab" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_owner_and_name_use_path_not_display_name(self) -> None: + # Repository.name is the display "Cool Group / Sentry"; Seer must receive + # the URL slugs derived from config["path"] ("cool-group/sentry"). + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["owner"] == "cool-group" + assert repo["name"] == "sentry" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_owner_and_name_handle_subgroups(self) -> None: + self._setup_code_review(name="Group / Subgroup / Project", path="group/subgroup/project") + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["owner"] == "group" + assert repo["name"] == "subgroup/project" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_true_for_private_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 0 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is True + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_true_for_internal_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 10 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is True + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_false_for_public_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 20 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is False + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_none_when_visibility_absent(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["project"]["visibility_level"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is None + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_trigger_on_ready_for_review_for_open(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger"] == "on_ready_for_review" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_trigger_on_new_commit_for_update(self) -> None: + self._setup_code_review() + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger"] == "on_new_commit" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_trigger_user_from_event(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger_user"] == "root" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_duplicate_delivery_within_window_skipped(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_duplicate_delivery_after_ttl_processes_again(self) -> None: + self._setup_code_review() + event = _make_event("open") + commit_sha = event["object_attributes"]["last_commit"]["id"] + iid = event["object_attributes"]["iid"] + + with self.tasks(): + self._call_handler(event) + assert self.mock_seer.call_count == 1 + + # Simulate TTL expiry so the same delivery can be processed again. + seen_key = ( + f"{WEBHOOK_SEEN_KEY_PREFIX}{self.organization.id}:{self.repo.id}:" + f"{iid}:open:{commit_sha}" + ) + redis_clusters.get("default").delete(seen_key) + + with self.tasks(): + self._call_handler(event) + assert self.mock_seer.call_count == 2 + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_distinct_commits_are_not_deduped(self) -> None: + # Two new-commit pushes have different last_commit ids, so they are distinct + # operations and both must reach Seer despite sharing the same MR and action. + self._setup_code_review() + first = _make_event("update", oldrev="0" * 40) + second = _make_event("update", oldrev="0" * 40) + second["object_attributes"]["last_commit"] = { + **second["object_attributes"]["last_commit"], + "id": "f" * 40, + } + + with self.tasks(): + self._call_handler(first) + self._call_handler(second) + + assert self.mock_seer.call_count == 2 diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index 808d362a7224b9..a61d723fb0dc1e 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -218,7 +218,6 @@ def test_post_with_null_automation_handoff(self) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False def test_post_with_invalid_automation_handoff_target(self) -> None: """Test that POST request fails with invalid target value""" diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py index ffc0634513cd5b..565e466839e850 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_settings.py +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from sentry.constants import ObjectStatus @@ -6,6 +8,10 @@ from sentry.testutils.cases import APITestCase +@patch( + "sentry.seer.endpoints.project_seer_settings.is_seer_seat_based_tier_enabled", + return_value=True, +) class ProjectSeerSettingsEndpointTest(APITestCase): endpoint = "sentry-api-0-project-seer-settings" @@ -21,7 +27,7 @@ def setUp(self) -> None: }, ) - def test_get_returns_defaults(self) -> None: + def test_get_returns_defaults(self, mock_is_seat_based) -> None: """A project with no options set should return defaults.""" response = self.client.get(self.url) @@ -32,11 +38,13 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } - def test_get_returns_configured_project_options(self) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: """A project with explicit options should reflect them in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -48,10 +56,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "open_pr" + assert response.data["autoCreatePr"] is None + assert response.data["automationTuning"] == "medium" assert response.data["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project with an external handoff should return the agent alias and integration ID.""" + def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: + """A project with an external handoff should return the agent, integration ID, + and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -65,8 +76,25 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == "42" + assert response.data["autoCreatePr"] is False + + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["autoCreatePr"] is True - def test_get_stopping_point_off_when_tuning_off(self) -> None: + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: """stoppingPoint should be 'off' when tuning is OFF.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF @@ -77,8 +105,9 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "off" + assert response.data["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -89,8 +118,9 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "root_cause" + assert response.data["automationTuning"] == "medium" - def test_get_repos_count(self) -> None: + def test_get_repos_count(self, mock_is_seat_based) -> None: """reposCount should reflect active SeerProjectRepository rows.""" repo1 = self.create_repo(project=self.project, name="owner/repo-1") repo2 = self.create_repo(project=self.project, name="owner/repo-2") @@ -102,7 +132,7 @@ def test_get_repos_count(self) -> None: assert response.status_code == 200 assert response.data["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_repo(project=self.project, name="owner/active") disabled_repo = self.create_repo(project=self.project, name="owner/deleted") @@ -116,10 +146,12 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: assert response.status_code == 200 assert response.data["reposCount"] == 1 - def test_put_returns_updated_settings(self) -> None: + def test_put_returns_updated_settings(self, mock_is_seat_based) -> None: """PUT response should contain the full updated settings object.""" response = self.client.put( - self.url, data={"agent": "seer", "stoppingPoint": "code_changes"}, format="json" + self.url, + data={"agent": "seer", "stoppingPoint": "code_changes", "automationTuning": "medium"}, + format="json", ) assert response.status_code == 200 @@ -130,7 +162,7 @@ def test_put_returns_updated_settings(self) -> None: assert "scannerAutomation" in response.data assert "reposCount" in response.data - def test_put_external_agent_with_valid_integration(self) -> None: + def test_put_external_agent_with_valid_integration(self, mock_is_seat_based) -> None: """Valid external agent + integrationId should succeed and reflect in response.""" integration = self.create_integration( organization=self.organization, external_id="ext", provider="github" @@ -145,38 +177,114 @@ def test_put_external_agent_with_valid_integration(self) -> None: assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == str(integration.id) - def test_put_scanner_automation(self) -> None: + def test_put_scanner_automation(self, mock_is_seat_based) -> None: """PUT scannerAutomation should update and return the new value.""" response = self.client.put(self.url, data={"scannerAutomation": False}, format="json") assert response.status_code == 200 assert response.data["scannerAutomation"] is False - def test_put_stopping_point_off(self) -> None: - """PUT stoppingPoint=off should disable automation.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + def test_put_stopping_point(self, mock_is_seat_based) -> None: + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + + def test_put_stopping_point_open_pr_syncs_auto_create_pr(self, mock_is_seat_based) -> None: + """Setting stoppingPoint to open_pr should also set auto_create_pr to True.""" + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + + def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self, mock_is_seat_based) -> None: + """Setting stoppingPoint to non-open_pr should clear auto_create_pr.""" + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.put(self.url, data={"stoppingPoint": "code_changes"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + def test_put_legacy_stopping_point_does_not_sync_auto_create_pr( + self, mock_is_seat_based + ) -> None: + """Legacy: changing stoppingPoint should not touch auto_create_pr.""" + mock_is_seat_based.return_value = False + + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + def test_put_legacy_auto_create_pr(self, mock_is_seat_based) -> None: + """autoCreatePr should update the handoff option directly.""" + mock_is_seat_based.return_value = False + + response = self.client.put(self.url, data={"autoCreatePr": True}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + + def test_put_legacy_auto_create_pr_preserves_stopping_point(self, mock_is_seat_based) -> None: + """autoCreatePr should not change the stored stopping_point.""" + mock_is_seat_based.return_value = False + + self.project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + + response = self.client.put(self.url, data={"autoCreatePr": True}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "root_cause" + + def test_put_automation_tuning(self, mock_is_seat_based) -> None: + """Seat-based: automationTuning accepts off and medium.""" + response = self.client.put(self.url, data={"automationTuning": "off"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.OFF ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - response = self.client.put(self.url, data={"stoppingPoint": "off"}, format="json") + response = self.client.put(self.url, data={"automationTuning": "medium"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + + def test_put_automation_tuning_rejects_granular(self, mock_is_seat_based) -> None: + """Seat-based: granular tuning values like 'high' should be rejected.""" + response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") + assert response.status_code == 400 + + def test_put_legacy_automation_tuning_allows_granular(self, mock_is_seat_based) -> None: + """Legacy: automationTuning should set tuning directly.""" + mock_is_seat_based.return_value = False + response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") assert response.status_code == 200 - assert response.data["stoppingPoint"] == "off" + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.HIGH + ) - def test_put_requires_at_least_one_update_field(self) -> None: + def test_put_requires_at_least_one_update_field(self, mock_is_seat_based) -> None: """Sending no update fields should return 400.""" response = self.client.put(self.url, data={}, format="json") assert response.status_code == 400 - def test_put_requires_integration_id_for_external_agent(self) -> None: + def test_put_requires_integration_id_for_external_agent(self, mock_is_seat_based) -> None: """External agent without integrationId should return 400.""" response = self.client.put( self.url, data={"agent": "cursor_background_agent"}, format="json" ) assert response.status_code == 400 - def test_put_rejects_integration_id_without_agent(self) -> None: + def test_put_rejects_integration_id_without_agent(self, mock_is_seat_based) -> None: """integrationId without agent should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -184,7 +292,7 @@ def test_put_rejects_integration_id_without_agent(self) -> None: response = self.client.put(self.url, data={"integrationId": integration.id}, format="json") assert response.status_code == 400 - def test_put_rejects_integration_id_with_seer_agent(self) -> None: + def test_put_rejects_integration_id_with_seer_agent(self, mock_is_seat_based) -> None: """integrationId with agent=seer should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -194,7 +302,7 @@ def test_put_rejects_integration_id_with_seer_agent(self) -> None: ) assert response.status_code == 400 - def test_put_rejects_integration_id_from_other_org(self) -> None: + def test_put_rejects_integration_id_from_other_org(self, mock_is_seat_based) -> None: """An integration ID that doesn't belong to this org should return 400.""" other_org = self.create_organization() integration = self.create_integration( @@ -208,22 +316,22 @@ def test_put_rejects_integration_id_from_other_org(self) -> None: ) assert response.status_code == 400 - def test_put_seer_agent_does_not_require_integration_id(self) -> None: + def test_put_seer_agent_does_not_require_integration_id(self, mock_is_seat_based) -> None: """agent=seer should not require integrationId.""" response = self.client.put(self.url, data={"agent": "seer"}, format="json") assert response.status_code == 200 - def test_put_rejects_invalid_agent(self) -> None: + def test_put_rejects_invalid_agent(self, mock_is_seat_based) -> None: """An unrecognized agent value should return 400.""" response = self.client.put(self.url, data={"agent": "invalid"}, format="json") assert response.status_code == 400 - def test_put_rejects_invalid_stopping_point(self) -> None: + def test_put_rejects_invalid_stopping_point(self, mock_is_seat_based) -> None: """An unrecognized stoppingPoint value should return 400.""" response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") assert response.status_code == 400 - def test_put_creates_audit_log_entry(self) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: """PUT should create an audit log entry with the project ID.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode @@ -246,6 +354,10 @@ def test_put_creates_audit_log_entry(self) -> None: assert entry.data["project_id"] == self.project.id +@patch( + "sentry.seer.endpoints.project_seer_settings.is_seer_seat_based_tier_enabled", + return_value=True, +) class OrganizationSeerProjectSettingsEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-seer-project-settings" @@ -258,7 +370,7 @@ def setUp(self) -> None: kwargs={"organization_id_or_slug": self.organization.slug}, ) - def test_get_returns_defaults(self) -> None: + def test_get_returns_defaults(self, mock_is_seat_based) -> None: """Projects with no options set should return default values.""" response = self.client.get(self.url) @@ -270,11 +382,13 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } - def test_get_returns_configured_project_options(self) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: """Projects with explicit option values should reflect those in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -286,11 +400,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "open_pr" + assert response.data[0]["autoCreatePr"] is None + assert response.data[0]["automationTuning"] == "medium" assert response.data[0]["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project configured with an external handoff target should return - the alias and integration ID.""" + def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: + """A project configured with an external handoff should return the agent, + integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -304,8 +420,25 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data[0]["agent"] == "cursor_background_agent" assert response.data[0]["integrationId"] == "42" + assert response.data[0]["autoCreatePr"] is False - def test_get_stopping_point_off_when_tuning_off(self) -> None: + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data[0]["autoCreatePr"] is True + + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: """When tuning is OFF, stoppingPoint should be 'off' regardless of the stored seer_automated_run_stopping_point value.""" self.project.update_option( @@ -317,8 +450,9 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "off" + assert response.data[0]["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -329,8 +463,9 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "root_cause" + assert response.data[0]["automationTuning"] == "medium" - def test_get_repos_count(self) -> None: + def test_get_repos_count(self, mock_is_seat_based) -> None: """reposCount should reflect the number of active SeerProjectRepository rows.""" repo1 = self.create_repo(project=self.project, name="owner/repo-1") repo2 = self.create_repo(project=self.project, name="owner/repo-2") @@ -342,7 +477,7 @@ def test_get_repos_count(self) -> None: assert response.status_code == 200 assert response.data[0]["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_repo(project=self.project, name="owner/active") disabled_repo = self.create_repo(project=self.project, name="owner/deleted") @@ -356,7 +491,7 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: assert response.status_code == 200 assert response.data[0]["reposCount"] == 1 - def test_get_only_returns_accessible_projects(self) -> None: + def test_get_only_returns_accessible_projects(self, mock_is_seat_based) -> None: """Response should only include projects the user has access to.""" self.organization.flags.allow_joinleave = False self.organization.save() @@ -376,7 +511,7 @@ def test_get_only_returns_accessible_projects(self) -> None: assert len(project_ids) == 1 assert str(inaccessible_project.id) not in project_ids - def test_get_paginates_results(self) -> None: + def test_get_paginates_results(self, mock_is_seat_based) -> None: """Results should be paginated with Link headers indicating next/previous.""" for i in range(5): self.create_project(organization=self.organization, slug=f"paginate-{i}") @@ -391,7 +526,7 @@ def test_get_paginates_results(self) -> None: assert 'rel="previous"; results="true"' in response2.headers["Link"] assert 'rel="next"; results="false"' in response2.headers["Link"] - def test_get_sort_by_name(self) -> None: + def test_get_sort_by_name(self, mock_is_seat_based) -> None: """sortBy=name should order by project slug.""" project_b = self.create_project(organization=self.organization, slug="banana") project_a = self.create_project(organization=self.organization, slug="apple") @@ -402,7 +537,7 @@ def test_get_sort_by_name(self) -> None: slugs = [r["projectSlug"] for r in response.data] assert slugs.index(project_a.slug) < slugs.index(project_b.slug) - def test_get_sort_by_repos_count(self) -> None: + def test_get_sort_by_repos_count(self, mock_is_seat_based) -> None: """sortBy=reposCount should order by SeerProjectRepository count.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -416,7 +551,7 @@ def test_get_sort_by_repos_count(self) -> None: ids = [r["projectId"] for r in response.data] assert ids.index(str(project2.id)) < ids.index(str(project1.id)) - def test_get_sort_by_agent(self) -> None: + def test_get_sort_by_agent(self, mock_is_seat_based) -> None: """sortBy=agent should order alphabetically by agent alias.""" project_seer = self.create_project(organization=self.organization) @@ -435,7 +570,7 @@ def test_get_sort_by_agent(self) -> None: assert ids.index(str(project_claude.id)) < ids.index(str(project_cursor.id)) assert ids.index(str(project_cursor.id)) < ids.index(str(project_seer.id)) - def test_get_sort_by_stopping_point(self) -> None: + def test_get_sort_by_stopping_point(self, mock_is_seat_based) -> None: """sortBy=stoppingPoint should order by hierarchy rank (off < root_cause < code_changes < open_pr).""" project_open_pr = self.create_project(organization=self.organization) project_open_pr.update_option( @@ -458,19 +593,19 @@ def test_get_sort_by_stopping_point(self) -> None: assert ids.index(str(self.project.id)) < ids.index(str(project_root_cause.id)) assert ids.index(str(project_root_cause.id)) < ids.index(str(project_open_pr.id)) - def test_get_sort_by_invalid_field_returns_400(self) -> None: + def test_get_sort_by_invalid_field_returns_400(self, mock_is_seat_based) -> None: """An unrecognized sortBy value should return 400.""" response = self.client.get(self.url, {"sortBy": "invalid"}) assert response.status_code == 400 - def test_get_filter_empty_results(self) -> None: + def test_get_filter_empty_results(self, mock_is_seat_based) -> None: """A filter that matches nothing should return an empty list.""" response = self.client.get(self.url, {"query": "id:999999999"}) assert response.status_code == 200 assert response.data == [] - def test_get_filter_by_free_text_name(self) -> None: + def test_get_filter_by_free_text_name(self, mock_is_seat_based) -> None: """Free text query should match against both name and slug.""" project1 = self.create_project( organization=self.organization, name="", slug="matching-slug" @@ -489,7 +624,7 @@ def test_get_filter_by_free_text_name(self) -> None: assert str(project2.id) in ids assert str(project3.id) not in ids - def test_get_filter_by_id(self) -> None: + def test_get_filter_by_id(self, mock_is_seat_based) -> None: """id:N should return only the project with that ID.""" self.create_project(organization=self.organization) project = self.create_project(organization=self.organization) @@ -500,7 +635,7 @@ def test_get_filter_by_id(self) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project.id)] - def test_get_filter_by_id_list(self) -> None: + def test_get_filter_by_id_list(self, mock_is_seat_based) -> None: """id:[N,M] should return only the projects with those IDs.""" project1 = self.create_project(organization=self.organization) project2 = self.create_project(organization=self.organization) @@ -512,7 +647,7 @@ def test_get_filter_by_id_list(self) -> None: ids = [r["projectId"] for r in response.data] assert sorted(ids) == sorted([str(project1.id), str(project2.id)]) - def test_get_filter_by_repos_count(self) -> None: + def test_get_filter_by_repos_count(self, mock_is_seat_based) -> None: """reposCount with numeric operators.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -531,7 +666,7 @@ def test_get_filter_by_repos_count(self) -> None: assert str(project2.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_stopping_point(self) -> None: + def test_get_filter_by_stopping_point(self, mock_is_seat_based) -> None: """stoppingPoint filter should account for tuning state.""" project1 = self.create_project(organization=self.organization) project1.update_option( @@ -550,7 +685,7 @@ def test_get_filter_by_stopping_point(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_by_agent_seer(self) -> None: + def test_get_filter_by_agent_seer(self, mock_is_seat_based) -> None: """agent:seer should return projects with no handoff target (NULL).""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -562,7 +697,7 @@ def test_get_filter_by_agent_seer(self) -> None: assert str(self.project.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_agent_external(self) -> None: + def test_get_filter_by_agent_external(self, mock_is_seat_based) -> None: """agent:cursor_background_agent should return projects with cursor handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -574,7 +709,7 @@ def test_get_filter_by_agent_external(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_negation(self) -> None: + def test_get_filter_negation(self, mock_is_seat_based) -> None: """!agent:seer should exclude projects with no handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -586,7 +721,7 @@ def test_get_filter_negation(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_multiple_filters(self) -> None: + def test_get_multiple_filters(self, mock_is_seat_based) -> None: """Combining multiple filters should intersect the results.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -604,13 +739,13 @@ def test_get_multiple_filters(self) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project1.id)] - def test_get_invalid_search_query_returns_400(self) -> None: + def test_get_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: """A malformed search query should return 400 with detail.""" response = self.client.get(self.url, {"query": "bogusKey:value"}) assert response.status_code == 400 assert "detail" in response.data - def test_put_updates_all_projects(self) -> None: + def test_put_updates_all_projects(self, mock_is_seat_based) -> None: """Empty query should update all accessible projects.""" project2 = self.create_project(organization=self.organization) @@ -620,7 +755,7 @@ def test_put_updates_all_projects(self) -> None: assert self.project.get_option("sentry:seer_scanner_automation") is False assert project2.get_option("sentry:seer_scanner_automation") is False - def test_put_applies_to_filtered_projects_only(self) -> None: + def test_put_applies_to_filtered_projects_only(self, mock_is_seat_based) -> None: """The query parameter should scope which projects get updated.""" project2 = self.create_project(organization=self.organization) project2.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -635,50 +770,96 @@ def test_put_applies_to_filtered_projects_only(self) -> None: assert project2.get_option("sentry:seer_scanner_automation") is False assert self.project.get_option("sentry:seer_scanner_automation") is True - def test_put_requires_at_least_one_update_field(self) -> None: - """Sending only query with no update fields should return 400.""" - response = self.client.put(self.url, data={"query": ""}, format="json") - assert response.status_code == 400 + def test_put_updates_settings(self, mock_is_seat_based) -> None: + """Bulk update with multiple seer agent fields should apply all of them.""" + project2 = self.create_project(organization=self.organization) - def test_put_requires_integration_id_for_external_agent(self) -> None: - """External agent without integrationId should return 400.""" response = self.client.put( - self.url, data={"agent": "cursor_background_agent"}, format="json" + self.url, + data={ + "agent": "seer", + "stoppingPoint": "code_changes", + "automationTuning": "medium", + "scannerAutomation": False, + }, + format="json", ) - assert response.status_code == 400 - def test_put_rejects_invalid_agent(self) -> None: - """An unrecognized agent value should return 400.""" - response = self.client.put(self.url, data={"agent": "invalid"}, format="json") - assert response.status_code == 400 + assert response.status_code == 204 + for p in (self.project, project2): + assert p.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert ( + p.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert p.get_option("sentry:seer_scanner_automation") is False - def test_put_rejects_invalid_stopping_point(self) -> None: - """An unrecognized stoppingPoint value should return 400.""" - response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") - assert response.status_code == 400 + def test_put_updates_settings_with_external_agent(self, mock_is_seat_based) -> None: + """Bulk update with external agent fields should set agent, integration, and stopping point.""" + project2 = self.create_project(organization=self.organization) + integration = self.create_integration( + organization=self.organization, external_id="ext", provider="github" + ) - def test_put_rejects_integration_id_from_other_org(self) -> None: - """An integration ID that doesn't belong to this org should return 400.""" - other_org = self.create_organization() + response = self.client.put( + self.url, + data={ + "agent": "cursor_background_agent", + "integrationId": integration.id, + "stoppingPoint": "open_pr", + "scannerAutomation": True, + }, + format="json", + ) + + assert response.status_code == 204 + for p in (self.project, project2): + assert ( + p.get_option("sentry:seer_automation_handoff_target") == "cursor_background_agent" + ) + assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id + assert p.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert p.get_option("sentry:seer_scanner_automation") is True + + def test_put_legacy_updates_settings(self, mock_is_seat_based) -> None: + """Legacy: bulk update with granular tuning, auto_create_pr, and external agent.""" + mock_is_seat_based.return_value = False + project2 = self.create_project(organization=self.organization) integration = self.create_integration( - organization=other_org, external_id="other", provider="github" + organization=self.organization, external_id="ext", provider="github" ) response = self.client.put( self.url, - data={"agent": "cursor_background_agent", "integrationId": integration.id}, + data={ + "automationTuning": "high", + "autoCreatePr": True, + "agent": "cursor_background_agent", + "integrationId": integration.id, + }, format="json", ) - assert response.status_code == 400 - def test_put_invalid_search_query_returns_400(self) -> None: + assert response.status_code == 204 + for p in (self.project, project2): + assert ( + p.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.HIGH + ) + assert p.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + assert ( + p.get_option("sentry:seer_automation_handoff_target") == "cursor_background_agent" + ) + assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id + + def test_put_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: """A malformed query value should return 400.""" response = self.client.put( self.url, data={"query": "invalidKey:value", "scannerAutomation": False}, format="json" ) assert response.status_code == 400 - def test_put_creates_audit_log_entry(self) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: """Bulk update should create an audit log entry with project count and IDs.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index f1ca1dc498a240..dbf59ebe247607 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -1752,7 +1752,6 @@ def test_integration_not_found_clears_handoff_project_options(self, mock_launch) assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") def test_integration_not_found_skips_clear_when_project_outside_org(self, mock_launch): diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py index 9a23a9272fe38b..142c130fb0ecd9 100644 --- a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -9,6 +9,7 @@ from sentry.models.groupassignee import GroupAssignee from sentry.seer.supergroups.endpoints import organization_supergroups_by_group from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import cell_silo_test def mock_seer_response(data: dict[str, Any]) -> MagicMock: @@ -18,6 +19,7 @@ def mock_seer_response(data: dict[str, Any]) -> MagicMock: return response +@cell_silo_test class OrganizationSupergroupsByGroupEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-supergroups-by-group" @@ -189,6 +191,63 @@ def test_skips_fanout_over_threshold(self, mock_seer): assert "assignees" not in sg assert self.resolved_group.id in sg["group_ids"] + def test_returns_404_when_all_groups_are_in_inaccessible_projects(self): + self.organization.flags.allow_joinleave = False + self.organization.save() + + member_user = self.create_user() + self.create_member(organization=self.organization, user=member_user, role="member") + + other_project = self.create_project(organization=self.organization) + inaccessible_group = self.create_group(project=other_project) + + self.login_as(member_user) + + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[inaccessible_group.id], + status_code=404, + ) + + @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") + def test_filters_out_groups_from_inaccessible_projects(self, mock_seer): + self.organization.flags.allow_joinleave = False + self.organization.save() + + member_user = self.create_user() + member_team = self.create_team(organization=self.organization) + self.create_member( + organization=self.organization, + user=member_user, + role="member", + teams=[member_team], + ) + self.project.add_team(member_team) + + accessible_group = self.create_group(project=self.project) + + other_project = self.create_project(organization=self.organization) + inaccessible_group = self.create_group(project=other_project) + + mock_seer.return_value = mock_seer_response( + {"data": [{"id": 1, "group_ids": [accessible_group.id], "title": "sg"}]} + ) + + self.login_as(member_user) + + with self.feature("organizations:top-issues-ui"): + response = self.get_success_response( + self.organization.slug, + group_id=[accessible_group.id, inaccessible_group.id], + ) + + seer_call_body = mock_seer.call_args[0][0] + assert accessible_group.id in seer_call_body["group_ids"] + assert inaccessible_group.id not in seer_call_body["group_ids"] + + assert len(response.data["data"]) == 1 + @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") def test_assignee_summary_tolerates_missing_actor(self, mock_seer): # GroupAssignee row references a user_id that `user_service.get_many_by_id` no longer returns diff --git a/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py b/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py index 8e556deb0aa7f5..2f5abd4512d33a 100644 --- a/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py +++ b/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py @@ -97,6 +97,7 @@ def setUp(self) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_dispatches_task_per_url(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com\nhttp://b.com") send_legacy_webhooks_for_invocation(self.invocation) @@ -109,6 +110,29 @@ def test_dispatches_task_per_url(self, mock_task: mock.MagicMock) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_no_urls_configured_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) + + send_legacy_webhooks_for_invocation(self.invocation) + + assert mock_task.delay.call_count == 0 + + @mock.patch( + "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", + ) + def test_webhooks_disabled_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") + + send_legacy_webhooks_for_invocation(self.invocation) + + assert mock_task.delay.call_count == 0 + + @mock.patch( + "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", + ) + def test_webhooks_explicitly_disabled_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", False) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") + send_legacy_webhooks_for_invocation(self.invocation) assert mock_task.delay.call_count == 0 @@ -117,6 +141,7 @@ def test_no_urls_configured_is_noop(self, mock_task: mock.MagicMock) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_triggering_rules_uses_workflow_name(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") send_legacy_webhooks_for_invocation(self.invocation) @@ -133,6 +158,7 @@ def test_triggering_rules_prefers_legacy_rule_label(self, mock_task: mock.MagicM rule.save() self.create_alert_rule_workflow(rule_id=rule.id, workflow=self.workflow) + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") send_legacy_webhooks_for_invocation(self.invocation) diff --git a/tests/sentry/spans/consumers/process_segments/test_enrichment.py b/tests/sentry/spans/consumers/process_segments/test_enrichment.py index a7d07795ed84b6..ef8fe6cbdc2512 100644 --- a/tests/sentry/spans/consumers/process_segments/test_enrichment.py +++ b/tests/sentry/spans/consumers/process_segments/test_enrichment.py @@ -1,5 +1,6 @@ from typing import cast +from sentry_conventions.attributes import ATTRIBUTE_NAMES from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry.spans.consumers.process_segments.enrichment import ( @@ -514,6 +515,77 @@ def _mock_performance_issue_span(is_segment, attributes, **fields) -> SpanEvent: ) +def test_conventional_user_attributes_propagated_to_child_spans() -> None: + """New conventional user attributes (user.email, user.id, etc.) are propagated + from the segment span to child spans that don't already have them.""" + user_attrs = { + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "user@example.com"}, + ATTRIBUTE_NAMES.USER_ID: {"type": "string", "value": "12345"}, + ATTRIBUTE_NAMES.USER_IP_ADDRESS: {"type": "string", "value": "203.0.113.1"}, + ATTRIBUTE_NAMES.USER_NAME: {"type": "string", "value": "testuser"}, + ATTRIBUTE_NAMES.USER_GEO_CITY: {"type": "string", "value": "San Francisco"}, + ATTRIBUTE_NAMES.USER_GEO_COUNTRY_CODE: {"type": "string", "value": "US"}, + ATTRIBUTE_NAMES.USER_GEO_REGION: {"type": "string", "value": "CA"}, + ATTRIBUTE_NAMES.USER_GEO_SUBDIVISION: {"type": "string", "value": "San Francisco"}, + } + + segment = build_mock_span( + project_id=1, + is_segment=True, + span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, + attributes=user_attrs, + ) + child = build_mock_span( + project_id=1, + span_id="bbbbbbbbbbbbbbbb", + parent_span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, + ) + + _, enriched = TreeEnricher.enrich_spans([segment, child]) + + enriched_child = enriched[1] + for attr_name, attr in user_attrs.items(): + assert attribute_value(enriched_child, attr_name) == attr["value"], ( + f"{attr_name} not propagated to child" + ) + + +def test_conventional_user_attributes_not_overwritten_on_child() -> None: + """If a child span already has a conventional user attribute, the segment + value must not overwrite it.""" + segment = build_mock_span( + project_id=1, + is_segment=True, + span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, + attributes={ + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "segment@example.com"}, + ATTRIBUTE_NAMES.USER_ID: {"type": "string", "value": "111"}, + }, + ) + child = build_mock_span( + project_id=1, + span_id="bbbbbbbbbbbbbbbb", + parent_span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, + attributes={ + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "child@example.com"}, + }, + ) + + _, enriched = TreeEnricher.enrich_spans([segment, child]) + + enriched_child = enriched[1] + assert attribute_value(enriched_child, ATTRIBUTE_NAMES.USER_EMAIL) == "child@example.com" + assert attribute_value(enriched_child, ATTRIBUTE_NAMES.USER_ID) == "111" + + def test_enrich_gen_ai_agent_name_from_immediate_parent() -> None: """Test that gen_ai.agent.name is inherited from the immediate parent with gen_ai.invoke_agent operation.""" parent_span = build_mock_span( diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index f193cf0c7e3839..d254bd6aa49d51 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -10,7 +10,6 @@ SummarizeIssueResponse, SummarizeIssueScores, ) -from sentry.seer.models.project_repository import SeerProjectRepository from sentry.tasks.seer.autofix import ( check_autofix_status, configure_seer_for_existing_org, @@ -293,22 +292,3 @@ def test_sets_seat_based_tier_cache_to_true(self) -> None: # Cache should be set to True to prevent race conditions assert cache.get(cache_key) is True - - def test_preserves_existing_repositories(self) -> None: - """Test that existing repositories are preserved when preferences are set.""" - project = self.create_project(organization=self.organization) - repo = self.create_repo( - project=project, - provider="integrations:github", - external_id="ext123", - name="existing-org/existing-repo", - ) - self.create_seer_project_repository(project=project, repository=repo) - # Force the update path by using an invalid stopping point. - project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") - - configure_seer_for_existing_org(organization_id=self.organization.id) - - seer_repos = list(SeerProjectRepository.objects.filter(project_repository__project=project)) - assert len(seer_repos) == 1 - assert seer_repos[0].project_repository.repository_id == repo.id diff --git a/tests/sentry/tasks/seer/test_night_shift.py b/tests/sentry/tasks/seer/test_night_shift.py index 212566d2f225ed..a1efa32ef7a2c4 100644 --- a/tests/sentry/tasks/seer/test_night_shift.py +++ b/tests/sentry/tasks/seer/test_night_shift.py @@ -458,21 +458,6 @@ def test_autofix_stopping_point_honors_project_preference(self) -> None: assert mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.SOLUTION - def test_forwards_reasoning_effort_to_trigger(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - group = self._store_event_and_update_group( - project, "fixable", seer_fixability_score=0.9, times_seen=5 - ) - - with self._patched_night_shift([(group.id, "autofix")]) as (mock_trigger, _): - run_night_shift_for_org(org.id, options={"reasoning_effort": "low"}) - - mock_trigger.assert_called_once() - assert mock_trigger.call_args.kwargs["reasoning_effort"] == "low" - def test_dry_run_skips_autofix(self) -> None: org = self.create_organization() project = self.create_project(organization=org) diff --git a/tests/sentry/tasks/test_options.py b/tests/sentry/tasks/test_options.py index 53844e8b7185d1..80d444a5400b65 100644 --- a/tests/sentry/tasks/test_options.py +++ b/tests/sentry/tasks/test_options.py @@ -11,15 +11,12 @@ class SyncOptionsTest(TestCase): _TEST_KEY = "foo" - _SEEN_TEST_KEY = "test.option-seen" - def tearDown(self) -> None: super().tearDown() - for key in (self._TEST_KEY, self._SEEN_TEST_KEY): - try: - default_manager.unregister(key) - except UnknownOption: - pass + try: + default_manager.unregister(self._TEST_KEY) + except UnknownOption: + pass def test_task_persistent_name(self) -> None: assert sync_options.name == "sentry.tasks.options.sync_options" @@ -38,22 +35,3 @@ def test_simple(self, mock_set_cache: MagicMock) -> None: sync_options(cutoff=60) assert not mock_set_cache.called - - def test_option_seen_logs_first_access_and_short_circuits(self) -> None: - default_manager.register(self._SEEN_TEST_KEY, default="x") - default_manager._seen.discard(self._SEEN_TEST_KEY) - - # First read: must emit exactly one log record with the key in extra. - with self.assertLogs("sentry", level="INFO") as cm: - default_manager.get(self._SEEN_TEST_KEY) - - assert any( - r.getMessage() == "option.seen" - and getattr(r, "option_key", None) == self._SEEN_TEST_KEY - for r in cm.records - ) - assert self._SEEN_TEST_KEY in default_manager._seen - - # Second read: short-circuit — _record_seen must not be called again. - with self.assertNoLogs("sentry", level="INFO"): - default_manager.get(self._SEEN_TEST_KEY) diff --git a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py index 93aa51c5060665..38086c5dac2a69 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -898,9 +898,12 @@ def test_aliased_attribute(self) -> None: assert response.status_code == 200, response.content keys = {item["key"] for item in response.data} - assert len(keys) == 2 + assert len(keys) == 4 assert "transaction.op" in keys assert "span.op" in keys + # These two are unrelated, but happen to match. + assert "db.operation" in keys + assert "db.operation.name" in keys response = self.do_request(query={"attributeType": "string", "substringMatch": "sentry.op"}) assert response.status_code == 200, response.content diff --git a/uv.lock b/uv.lock index 31e2079b6e9048..199e6fae844c66 100644 --- a/uv.lock +++ b/uv.lock @@ -809,6 +809,19 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/google_crc32c-1.6.0-cp314-cp314-manylinux_2_31_x86_64.whl", hash = "sha256:db3b57e16252dfd8606edcf0b2ec652dde01b910a8890c030ca8edcde97f7978" }, ] +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116" }, +] + [[package]] name = "google-resumable-media" version = "2.7.0" @@ -903,6 +916,18 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c" }, ] +[[package]] +name = "grpcio-health-checking" +version = "1.67.1" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/grpcio_health_checking-1.67.1-py3-none-any.whl", hash = "sha256:93753da5062152660aef2286c9b261e07dd87124a65e4dc9fbd47d1ce966b39d" }, +] + [[package]] name = "grpcio-status" version = "1.67.0" @@ -2188,6 +2213,7 @@ dependencies = [ { name = "google-cloud-storage", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "google-cloud-storage-transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "google-crc32c", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "google-re2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "granian", extra = ["pname", "reload", "uvloop"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "grpc-google-iam-v1", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2362,6 +2388,7 @@ requires-dist = [ { name = "google-cloud-storage", specifier = ">=2.18.0" }, { name = "google-cloud-storage-transfer", specifier = ">=1.17.0" }, { name = "google-crc32c", specifier = ">=1.6.0" }, + { name = "google-re2", specifier = ">=1.1.20251105" }, { name = "googleapis-common-protos", specifier = ">=1.63.2" }, { name = "granian", extras = ["pname", "reload", "uvloop"], specifier = ">=2.7" }, { name = "grpc-google-iam-v1", specifier = ">=0.13.1" }, @@ -2415,7 +2442,7 @@ requires-dist = [ { name = "sentry-protos", specifier = ">=0.17.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, { name = "sentry-relay", specifier = ">=0.9.27" }, - { name = "sentry-scm", specifier = "==0.16.0" }, + { name = "sentry-scm", specifier = "==0.20.0" }, { name = "sentry-sdk", extras = ["http2"], specifier = ">=2.59.0" }, { name = "sentry-usage-accountant", specifier = ">=0.0.10" }, { name = "setuptools", specifier = ">=70.0.0" }, @@ -2427,7 +2454,7 @@ requires-dist = [ { name = "stripe", specifier = ">=6.7.0" }, { name = "structlog", specifier = ">=22.1.0" }, { name = "symbolic", specifier = ">=12.14.1" }, - { name = "taskbroker-client", specifier = ">=0.16.0,<1" }, + { name = "taskbroker-client", specifier = ">=0.17.0" }, { name = "tiktoken", specifier = ">=0.8.0" }, { name = "tldextract", specifier = ">=5.1.2" }, { name = "tokenizers", specifier = ">=0.22.0" }, @@ -2633,14 +2660,14 @@ wheels = [ [[package]] name = "sentry-scm" -version = "0.16.0" +version = "0.20.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_scm-0.16.0-py3-none-any.whl", hash = "sha256:506e544b320e155c5808aca588ed2579cf66fb08d7fb0148893dbd328cdb6fe4" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_scm-0.20.0-py3-none-any.whl", hash = "sha256:9e26a9bbf62730974e3748a27462fe814ac6cfe9fe8286be9b881475be7aa72d" }, ] [[package]] @@ -2821,12 +2848,13 @@ wheels = [ [[package]] name = "taskbroker-client" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "confluent-kafka", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "cronsim", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "grpcio-health-checking", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "msgpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2838,7 +2866,7 @@ dependencies = [ { name = "zstandard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.16.0-py3-none-any.whl", hash = "sha256:10322e6bb51a70a77ba18498d38f1b751aab4f862983381d4d218ac3b4a6d54c" }, + { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.17.0-py3-none-any.whl", hash = "sha256:a52f5ff5914a50dbea50f07777a3c46080b467e04c7ec06895e7d3dceea16dc3" }, ] [[package]]