From b7f40dc8f071ca3fdbc97793b09f7a1e68aebd41 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Thu, 28 May 2026 11:56:33 -0400 Subject: [PATCH 01/60] fix(project-filter): increase bottom margin (#116328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix a small misalignment between highlight and the bottom of the project selector. Same fix as: https://github.com/getsentry/sentry/blob/b68ea049d1ae2be4471a52fd42de580ab6b71223/static/app/components/core/compactSelect/listBox/index.tsx#L296 Before: Screenshot 2026-05-27 at 10 25 39 AM After: Screenshot 2026-05-27 at 11 24 50 AM Co-authored-by: Claude --- .../core/compactSelect/useVirtualizedItems.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/static/app/components/core/compactSelect/useVirtualizedItems.tsx b/static/app/components/core/compactSelect/useVirtualizedItems.tsx index d55080db991a..0a1e876db060 100644 --- a/static/app/components/core/compactSelect/useVirtualizedItems.tsx +++ b/static/app/components/core/compactSelect/useVirtualizedItems.tsx @@ -10,6 +10,13 @@ const heightEstimations = { xs: {regular: 25, large: 42}, } as const satisfies Record; +/** + * Matches `theme.space.xs` used as vertical padding on ListWrap (ul). + * Added to the virtualizer's wrapper height so the ListWrap's top/bottom + * padding remains visible when the list shrinks to a small number of items. + */ +const listPaddingVertical = 4; + // explicitly using object here because Record requires an index signature // eslint-disable-next-line @typescript-eslint/no-restricted-types type ObjectLike = object; @@ -51,7 +58,7 @@ export function useVirtualizedItems({ wrapperProps: { 'data-is-virtualized': true, style: { - height: virtualizer.getTotalSize(), + height: virtualizer.getTotalSize() + listPaddingVertical * 2, width: '100%', position: 'relative', }, From 2db70ec8b114613a5fa3ba6dff3127e3c4019aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 28 May 2026 17:58:17 +0200 Subject: [PATCH 02/60] ref(api): promote org-scoped project creation endpoint to public (#116333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /organizations/{org}/experimental/projects/` has been stable since Jan 2024 — it is what the onboarding UI calls when a member (no `project:write`) creates a project. Two problems: the `/experimental/` path is a one-off with no codebase pattern, and `EXPERIMENTAL` status excludes it from the OpenAPI spec, so `@sentry/api` and the CLI have never been able to use it. `POST /organizations/{org}/projects/` was an empty slot (only GET existed). This fills it. **Before** ``` POST /organizations/{org}/experimental/projects/ EXPERIMENTAL ← not in spec POST /teams/{org}/{team}/projects/ PUBLIC ``` **After** ``` POST /organizations/{org}/projects/ PUBLIC ← same behavior, first-class POST /organizations/{org}/experimental/projects/ EXPERIMENTAL ← temporary alias (backward compat) POST /teams/{org}/{team}/projects/ PUBLIC ``` The behavior is identical — auto-creates `team-{username}` for the caller, requires only `project:read`. The team-scoped endpoint is untouched. The `/experimental/` alias is kept alive as a backward-compat shim (`OrganizationProjectsExperimentalCompatEndpoint`) so the frontend doesn't break between this deploy and the frontend PR landing. It's POST-only (`http_method_names = ["post", "options"]`), marked `EXPERIMENTAL` so it stays out of the spec, and carries a TODO to remove it once the frontend PR merges. Also fixes a pre-existing data consistency bug in the moved code: `project.add_team(team)` was called outside the `transaction.atomic()` block, meaning a failure there would leave a team and project in the DB without being linked. Moved it inside the transaction. **Housekeeping:** `organization_projects_experiment.py` deleted, constants/helpers folded into `organization_projects.py`, both apidocs allowlists updated, CODEOWNERS updated, test file renamed `test_organization_projects_create.py` + new test for the `role=member` happy path. Frontend follow-up (separate PR): drop `/experimental/` from `useCreateProject.ts` and `useCreateProjectFromWizard.tsx`, then a cleanup PR removes the alias. ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. --- .github/CODEOWNERS | 4 +- .github/codeowners-coverage-baseline.txt | 2 +- src/sentry/api/urls.py | 9 +- .../api_ownership_allowlist_dont_modify.py | 1 - ...pi_publish_status_allowlist_dont_modify.py | 2 +- .../core/endpoints/organization_projects.py | 252 +++++++++++++++++- .../organization_projects_experiment.py | 233 ---------------- src/sentry/core/endpoints/team_projects.py | 2 +- ...y => test_organization_projects_create.py} | 46 +++- 9 files changed, 294 insertions(+), 257 deletions(-) delete mode 100644 src/sentry/core/endpoints/organization_projects_experiment.py rename tests/sentry/core/endpoints/{test_organization_projects_experiment.py => test_organization_projects_create.py} (87%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2e77ee113240..a86c9a1af6ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -532,7 +532,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get ## Enterprise /src/sentry/api/endpoints/oauth_userinfo.py @getsentry/foundations /src/sentry/api/endpoints/organization_auditlogs.py @getsentry/foundations -/src/sentry/api/endpoints/organization_projects_experiment.py @getsentry/foundations +/src/sentry/core/endpoints/organization_projects.py @getsentry/foundations /src/sentry/api/endpoints/organization_stats*.py @getsentry/foundations /src/sentry/api/endpoints/release_threshold*.py @getsentry/replay-backend /src/sentry/api/endpoints/user_social_identity* @getsentry/foundations @@ -550,7 +550,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/settings/organizationAuth/ @getsentry/foundations /static/app/views/settings/organizationMembers/inviteBanner.tsx @getsentry/foundations /tests/sentry/api/endpoints/test_auth*.py @getsentry/foundations -/tests/sentry/api/endpoints/test_organization_projects_experiment.py @getsentry/foundations +/tests/sentry/core/endpoints/test_organization_projects_create.py @getsentry/foundations /tests/sentry/api/test_data_secrecy.py @getsentry/foundations /tests/sentry/api/test_scim*.py @getsentry/foundations /tests/sentry/auth/test_staff.py @getsentry/foundations diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index 734fcb38d43f..d9732f411f9a 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -1893,7 +1893,7 @@ tests/sentry/core/endpoints/test_organization_member_invite_details.py tests/sentry/core/endpoints/test_organization_member_invite_index.py tests/sentry/core/endpoints/test_organization_member_team_details.py tests/sentry/core/endpoints/test_organization_projects.py -tests/sentry/core/endpoints/test_organization_projects_experiment.py +tests/sentry/core/endpoints/test_organization_projects_create.py tests/sentry/core/endpoints/test_organization_request_project_creation.py tests/sentry/core/endpoints/test_organization_teams.py tests/sentry/core/endpoints/test_organization_user_details.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 3a2f581a9ac4..b7137efc9df7 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -106,9 +106,7 @@ from sentry.core.endpoints.organization_projects import ( OrganizationProjectsCountEndpoint, OrganizationProjectsEndpoint, -) -from sentry.core.endpoints.organization_projects_experiment import ( - OrganizationProjectsExperimentEndpoint, + OrganizationProjectsExperimentalCompatEndpoint, ) from sentry.core.endpoints.organization_request_project_creation import ( OrganizationRequestProjectCreation, @@ -2216,10 +2214,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationProjectsEndpoint.as_view(), name="sentry-api-0-organization-projects", ), + # TODO: remove once frontend PR lands (ref/onboarding-project-creation-url) re_path( r"^(?P[^/]+)/experimental/projects/$", - OrganizationProjectsExperimentEndpoint.as_view(), - name="sentry-api-0-organization-projects-experiment", + OrganizationProjectsExperimentalCompatEndpoint.as_view(), + name="sentry-api-0-organization-projects-experimental-compat", ), re_path( r"^(?P[^/]+)/projects-count/$", diff --git a/src/sentry/apidocs/api_ownership_allowlist_dont_modify.py b/src/sentry/apidocs/api_ownership_allowlist_dont_modify.py index 1158aa5506a5..521bfb1f0639 100644 --- a/src/sentry/apidocs/api_ownership_allowlist_dont_modify.py +++ b/src/sentry/apidocs/api_ownership_allowlist_dont_modify.py @@ -55,7 +55,6 @@ "/api/0/users/{user_id}/roles/", "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/keys/{key_id}/", "/api/0/organizations/{organization_id_or_slug}/recent-searches/", - "/api/0/organizations/{organization_id_or_slug}/projects/", "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/environments/{environment}/", "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/token/", "/api/0/organizations/{organization_id_or_slug}/searches/{search_id}/", diff --git a/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py index 5bf314427580..18875487346d 100644 --- a/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py +++ b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py @@ -387,8 +387,8 @@ "PUT", }, "/api/0/organizations/{organization_id_or_slug}/onboarding-continuation-email/": {"POST"}, - "/api/0/organizations/{organization_id_or_slug}/processingissues/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/experimental/projects/": {"POST"}, + "/api/0/organizations/{organization_id_or_slug}/processingissues/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/projects-count/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/sent-first-event/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/repos/": {"GET", "POST"}, diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index f236276fa352..9bb79d583e9a 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -1,32 +1,61 @@ -from typing import Any +import logging +import random +import string +from email.headerregistry import Address +from typing import Any, TypeIs +from django.contrib.auth.models import AnonymousUser +from django.db import IntegrityError, router, transaction from django.db.models import Q from django.db.models.query import QuerySet +from django.utils.text import slugify from drf_spectacular.utils import extend_schema -from rest_framework.exceptions import ParseError +from rest_framework.exceptions import NotAuthenticated, ParseError, PermissionDenied from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.serializers import ValidationError +from sentry import audit_log, features +from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationAndStaffPermission, OrganizationEndpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.exceptions import ConflictError, ResourceDoesNotExist from sentry.api.helpers.environments import get_environment_id from sentry.api.paginator import OffsetPaginator +from sentry.api.permissions import StaffPermissionMixin from sentry.api.serializers import serialize from sentry.api.serializers.models.project import ( OrganizationProjectResponse, ProjectSummarySerializer, ) -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_CONFLICT, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) from sentry.apidocs.examples.organization_examples import OrganizationExamples +from sentry.apidocs.examples.project_examples import ProjectExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus +from sentry.core.endpoints.team_projects import ( + AuditData, + ProjectPostSerializer, + apply_default_project_settings, +) from sentry.models.organization import Organization +from sentry.models.organizationmember import OrganizationMember +from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project from sentry.models.team import Team from sentry.search.utils import tokenize_query +from sentry.signals import project_created, team_created from sentry.snuba import discover, metrics_enhanced_performance, metrics_performance +from sentry.users.models.user import User +from sentry.utils.snowflake import MaxSnowflakeRetryError ERR_INVALID_STATS_PERIOD = ( "Invalid stats_period. Valid choices are '', '1h', '24h', '7d', '14d', '30d', and '90d'" @@ -39,6 +68,10 @@ "metrics": metrics_performance, } +CONFLICTING_TEAM_SLUG_ERROR = "A team with this slug already exists." +MISSING_PERMISSION_ERROR_STRING = "You do not have permission to join a new team as a Team Admin." +DISABLED_FEATURE_ERROR_STRING = "Your organization has disabled this feature for members." + def get_dataset(dataset_label: str) -> Any: if dataset_label not in DATASETS: @@ -46,13 +79,34 @@ def get_dataset(dataset_label: str) -> Any: return DATASETS[dataset_label] +def _generate_suffix() -> str: + letters = string.ascii_lowercase + return "".join(random.choice(letters) for _ in range(3)) + + +def fetch_slugifed_email_username(email: str) -> str: + return slugify(Address(addr_spec=email).username) + + +class OrganizationProjectsPermission(StaffPermissionMixin, OrganizationPermission): + scope_map = { + "GET": ["org:read", "org:write", "org:admin"], + # Intentionally lowered: org members can create projects when + # allowMemberProjectCreation is enabled on the org. + "POST": ["project:read", "project:write", "project:admin"], + } + + @extend_schema(tags=["Organizations"]) @cell_silo_endpoint class OrganizationProjectsEndpoint(OrganizationEndpoint): publish_status = { "GET": ApiPublishStatus.PUBLIC, + "POST": ApiPublishStatus.PUBLIC, } - permission_classes = (OrganizationAndStaffPermission,) + permission_classes = (OrganizationProjectsPermission,) + owner = ApiOwner.FOUNDATIONS + logger = logging.getLogger("team-project.create") @extend_schema( operation_id="List an Organization's Projects", @@ -200,6 +254,194 @@ def serialize_on_result(result): paginator_cls=OffsetPaginator, ) + def should_add_creator_to_team(self, user: User | AnonymousUser) -> TypeIs[User]: + return user.is_authenticated + + @extend_schema( + tags=["Projects"], + operation_id="Create a Project for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=ProjectPostSerializer, + responses={ + 201: ProjectSummarySerializer, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + 409: RESPONSE_CONFLICT, + }, + examples=ProjectExamples.CREATE_PROJECT, + description=( + "Create a new project for an organization. A personal team (`team-{username}`) " + "is automatically created for the caller with Team Admin role, and the project is " + "bound to it. If the org has member project creation disabled " + "(`disable_member_project_creation`), `org:write` scope is required." + ), + ) + def post(self, request: Request, organization: Organization) -> Response: + """ + Create a new project for an organization. + + Auto-creates a personal team (``team-{username}``) for the caller with Team Admin + role, then creates the project under that team. A random suffix is appended to the + team slug if the default name is already taken (up to five attempts). + """ + serializer = ProjectPostSerializer( + data=request.data, context={"organization": organization} + ) + + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + if not self.should_add_creator_to_team(request.user): + raise NotAuthenticated("User is not authenticated") + + result = serializer.validated_data + + if not features.has("organizations:team-roles", organization): + raise ResourceDoesNotExist(detail=MISSING_PERMISSION_ERROR_STRING) + if organization.flags.disable_member_project_creation and not request.access.has_scope( + "org:write" + ): + raise PermissionDenied(detail=DISABLED_FEATURE_ERROR_STRING) + + # parse the email to retrieve the username before the "@" + parsed_email = fetch_slugifed_email_username(request.user.email) + + project_name = result["name"] + default_team_slug = f"team-{parsed_email}" + suffixed_team_slug = default_team_slug + + # attempt to a maximum of 5 times to add a suffix to team slug until it is unique + for _ in range(5): + if not Team.objects.filter(organization=organization, slug=suffixed_team_slug).exists(): + break + suffixed_team_slug = f"{default_team_slug}-{_generate_suffix()}" + else: + raise ConflictError( + { + "detail": "Unable to create a default team for this user. Please try again.", + } + ) + default_team_slug = suffixed_team_slug + + try: + with transaction.atomic(router.db_for_write(Team)): + team = Team.objects.create( + name=default_team_slug, + slug=default_team_slug, + idp_provisioned=result.get("idp_provisioned", False), + organization=organization, + ) + member = OrganizationMember.objects.get( + user_id=request.user.id, organization=organization + ) + OrganizationMemberTeam.objects.create( + team=team, + organizationmember=member, + role="admin", + ) + project = Project.objects.create( + name=project_name, + # slug is *not* set so we get an automatic one + organization=organization, + platform=result.get("platform"), + ) + project.add_team(team) + except (IntegrityError, MaxSnowflakeRetryError): + raise ConflictError( + { + "non_field_errors": [CONFLICTING_TEAM_SLUG_ERROR], + "detail": CONFLICTING_TEAM_SLUG_ERROR, + } + ) + except OrganizationMember.DoesNotExist: + raise PermissionDenied( + detail="You must be a member of the organization to join a new team as a Team Admin" + ) + + team_created.send_robust( + organization=organization, + user=request.user, + team=team, + sender=self.__class__, + ) + self.create_audit_entry( + request=request, + organization=organization, + target_object=team.id, + event=audit_log.get_event_id("TEAM_ADD"), + data=team.get_audit_log_data(), + ) + + common_audit_data: AuditData = { + "request": request, + "organization": team.organization, + "target_object": project.id, + } + origin = request.data.get("origin") + if origin: + self.create_audit_entry( + **common_audit_data, + event=audit_log.get_event_id("PROJECT_ADD_WITH_ORIGIN"), + data={ + **project.get_audit_log_data(), + "origin": origin, + }, + ) + else: + self.create_audit_entry( + **common_audit_data, + event=audit_log.get_event_id("PROJECT_ADD"), + data={**project.get_audit_log_data()}, + ) + + apply_default_project_settings(organization, project) + + project_created.send_robust( + project=project, + user=request.user, + default_rules=result.get("default_rules", True), + origin=origin, + sender=self, + ) + self.create_audit_entry( + request=request, + organization=team.organization, + event=audit_log.get_event_id("TEAM_AND_PROJECT_CREATED"), + data={"team_slug": default_team_slug, "project_slug": project_name}, + ) + self.logger.info( + "created team through project creation flow", + extra={"team_slug": default_team_slug, "project_slug": project_name}, + ) + serialized_response = serialize( + project, request.user, ProjectSummarySerializer(collapse=["unusedFeatures"]) + ) + serialized_response["team_slug"] = team.slug + + return Response(serialized_response, status=201) + + +@cell_silo_endpoint +class OrganizationProjectsExperimentalCompatEndpoint(OrganizationProjectsEndpoint): + """ + Backward-compat alias for POST /organizations/{org}/experimental/projects/. + + Routes to the exact same handler as OrganizationProjectsEndpoint. Marked + EXPERIMENTAL so it is excluded from the OpenAPI spec (avoiding operationId + collisions with the canonical /projects/ URL). + + TODO: remove once the frontend PR (ref/onboarding-project-creation-url) lands. + """ + + publish_status = { + "POST": ApiPublishStatus.EXPERIMENTAL, + } + # Restrict to POST only — prevents the inherited get() from being reachable + # (GET should 405 here, matching the original experimental endpoint's behaviour) + # and stops the schema builder from discovering an undeclared method. + http_method_names = ["post", "options"] + @cell_silo_endpoint class OrganizationProjectsCountEndpoint(OrganizationEndpoint): diff --git a/src/sentry/core/endpoints/organization_projects_experiment.py b/src/sentry/core/endpoints/organization_projects_experiment.py deleted file mode 100644 index a9ab00b0eb71..000000000000 --- a/src/sentry/core/endpoints/organization_projects_experiment.py +++ /dev/null @@ -1,233 +0,0 @@ -import logging -import random -import string -from email.headerregistry import Address -from typing import TypedDict, TypeIs - -from django.contrib.auth.models import AnonymousUser -from django.db import IntegrityError, router, transaction -from django.utils.text import slugify -from rest_framework.exceptions import NotAuthenticated, PermissionDenied -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ValidationError - -from sentry import audit_log, features -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.api.exceptions import ConflictError, ResourceDoesNotExist -from sentry.api.serializers import serialize -from sentry.api.serializers.models.project import ProjectSummarySerializer -from sentry.core.endpoints.team_projects import ( - ProjectPostSerializer, - apply_default_project_settings, -) -from sentry.models.organization import Organization -from sentry.models.organizationmember import OrganizationMember -from sentry.models.organizationmemberteam import OrganizationMemberTeam -from sentry.models.project import Project -from sentry.models.team import Team -from sentry.signals import project_created, team_created -from sentry.users.models.user import User -from sentry.utils.snowflake import MaxSnowflakeRetryError - -CONFLICTING_TEAM_SLUG_ERROR = "A team with this slug already exists." -MISSING_PERMISSION_ERROR_STRING = "You do not have permission to join a new team as a Team Admin." -DISABLED_FEATURE_ERROR_STRING = "Your organization has disabled this feature for members." - - -def _generate_suffix() -> str: - letters = string.ascii_lowercase - return "".join(random.choice(letters) for _ in range(3)) - - -def fetch_slugifed_email_username(email: str) -> str: - return slugify(Address(addr_spec=email).username) - - -# This endpoint is intended to be available to all members of an -# organization so we include "project:read" in the POST scopes. - - -class OrgProjectPermission(OrganizationPermission): - scope_map = { - "POST": ["project:read", "project:write", "project:admin"], - } - - -class AuditData(TypedDict): - request: Request - organization: Organization - target_object: int - - -@cell_silo_endpoint -class OrganizationProjectsExperimentEndpoint(OrganizationEndpoint): - publish_status = { - "POST": ApiPublishStatus.EXPERIMENTAL, - } - permission_classes = (OrgProjectPermission,) - logger = logging.getLogger("team-project.create") - owner = ApiOwner.FOUNDATIONS - - def should_add_creator_to_team(self, user: User | AnonymousUser) -> TypeIs[User]: - return user.is_authenticated - - def post(self, request: Request, organization: Organization) -> Response: - """ - Create a new Team and Project - `````````````````` - - Create a new team where the user is set as Team Admin. The - name+slug of the team is automatically set as 'default-team-[username]'. - If this is taken, a random three letter suffix is added as needed - (eg: ...-gnm, ...-zls). Then create a new project bound to this team - - :pparam string organization_id_or_slug: the id or slug of the organization the - team should be created for. - :param string name: the name for the new project. - :param string platform: the optional platform that this project is for. - :param bool default_rules: create default rules (defaults to True) - :auth: required - """ - serializer = ProjectPostSerializer( - data=request.data, context={"organization": organization} - ) - - if not serializer.is_valid(): - raise ValidationError(serializer.errors) - if not self.should_add_creator_to_team(request.user): - raise NotAuthenticated("User is not authenticated") - - result = serializer.validated_data - - if not features.has("organizations:team-roles", organization): - raise ResourceDoesNotExist(detail=MISSING_PERMISSION_ERROR_STRING) - if organization.flags.disable_member_project_creation and not request.access.has_scope( - "org:write" - ): - raise PermissionDenied(detail=DISABLED_FEATURE_ERROR_STRING) - - # parse the email to retrieve the username before the "@" - parsed_email = fetch_slugifed_email_username(request.user.email) - - project_name = result["name"] - default_team_slug = f"team-{parsed_email}" - suffixed_team_slug = default_team_slug - - # attempt to a maximum of 5 times to add a suffix to team slug until it is unique - for _ in range(5): - if not Team.objects.filter(organization=organization, slug=suffixed_team_slug).exists(): - break - suffixed_team_slug = f"{default_team_slug}-{_generate_suffix()}" - else: - raise ConflictError( - { - "detail": "Unable to create a default team for this user. Please try again.", - } - ) - default_team_slug = suffixed_team_slug - - try: - with transaction.atomic(router.db_for_write(Team)): - team = Team.objects.create( - name=default_team_slug, - slug=default_team_slug, - idp_provisioned=result.get("idp_provisioned", False), - organization=organization, - ) - member = OrganizationMember.objects.get( - user_id=request.user.id, organization=organization - ) - OrganizationMemberTeam.objects.create( - team=team, - organizationmember=member, - role="admin", - ) - project = Project.objects.create( - name=project_name, - # slug is *not* set so we get an automatic one - organization=organization, - platform=result.get("platform"), - ) - except (IntegrityError, MaxSnowflakeRetryError): - # We can only catch duplicate team slugs here. Duplicate project slugs are - # impossible because the project slug is always generated based on the project name. - # If the generated slug is already in use, the system automatically adds a suffix - # to make it unique. - raise ConflictError( - { - "non_field_errors": [CONFLICTING_TEAM_SLUG_ERROR], - "detail": CONFLICTING_TEAM_SLUG_ERROR, - } - ) - except OrganizationMember.DoesNotExist: - raise PermissionDenied( - detail="You must be a member of the organization to join a new team as a Team Admin" - ) - else: - project.add_team(team) - - team_created.send_robust( - organization=organization, - user=request.user, - team=team, - sender=self.__class__, - ) - self.create_audit_entry( - request=request, - organization=organization, - target_object=team.id, - event=audit_log.get_event_id("TEAM_ADD"), - data=team.get_audit_log_data(), - ) - - common_audit_data: AuditData = { - "request": request, - "organization": team.organization, - "target_object": project.id, - } - origin = request.data.get("origin") - if origin: - self.create_audit_entry( - **common_audit_data, - event=audit_log.get_event_id("PROJECT_ADD_WITH_ORIGIN"), - data={ - **project.get_audit_log_data(), - "origin": origin, - }, - ) - else: - self.create_audit_entry( - **common_audit_data, - event=audit_log.get_event_id("PROJECT_ADD"), - data={**project.get_audit_log_data()}, - ) - - apply_default_project_settings(organization, project) - - project_created.send_robust( - project=project, - user=request.user, - default_rules=result.get("default_rules", True), - origin=origin, - sender=self, - ) - self.create_audit_entry( - request=request, - organization=team.organization, - event=audit_log.get_event_id("TEAM_AND_PROJECT_CREATED"), - data={"team_slug": default_team_slug, "project_slug": project_name}, - ) - self.logger.info( - "created team through project creation flow", - extra={"team_slug": default_team_slug, "project_slug": project_name}, - ) - serialized_response = serialize( - project, request.user, ProjectSummarySerializer(collapse=["unusedFeatures"]) - ) - serialized_response["team_slug"] = team.slug - - return Response(serialized_response, status=201) diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index 2cba658e055f..a6a67a45573a 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -218,7 +218,7 @@ def get(self, request: Request, team) -> Response: """, ) def post(self, request: Request, team: Team) -> Response: - from sentry.core.endpoints.organization_projects_experiment import ( + from sentry.core.endpoints.organization_projects import ( DISABLED_FEATURE_ERROR_STRING, ) diff --git a/tests/sentry/core/endpoints/test_organization_projects_experiment.py b/tests/sentry/core/endpoints/test_organization_projects_create.py similarity index 87% rename from tests/sentry/core/endpoints/test_organization_projects_experiment.py rename to tests/sentry/core/endpoints/test_organization_projects_create.py index 98b236b7e75d..1c852cd69b1c 100644 --- a/tests/sentry/core/endpoints/test_organization_projects_experiment.py +++ b/tests/sentry/core/endpoints/test_organization_projects_create.py @@ -4,9 +4,9 @@ from django.utils.text import slugify -from sentry.core.endpoints.organization_projects_experiment import ( +from sentry.core.endpoints.organization_projects import ( DISABLED_FEATURE_ERROR_STRING, - OrganizationProjectsExperimentEndpoint, + OrganizationProjectsEndpoint, fetch_slugifed_email_username, ) from sentry.models.organizationmember import OrganizationMember @@ -19,8 +19,8 @@ from sentry.testutils.helpers.features import with_feature -class OrganizationProjectsExperimentCreateTest(APITestCase): - endpoint = "sentry-api-0-organization-projects-experiment" +class OrganizationProjectsCreateTest(APITestCase): + endpoint = "sentry-api-0-organization-projects" method = "post" p1 = "project-one" p2 = "project-two" @@ -36,11 +36,43 @@ def validate_team_with_suffix(self, team: Team): return bool(re.match(pattern, team.slug)) and bool(re.match(pattern, team.name)) def test_missing_permission(self) -> None: + # A user with no org membership at all is rejected. user = self.create_user() self.login_as(user=user) self.get_error_response(self.organization.slug, status_code=403) + @with_feature(["organizations:team-roles"]) + def test_org_member_can_create_project(self) -> None: + # Members have project:read scope, which is sufficient for POST /organizations/{org}/projects/. + # This verifies the intentionally-lowered scope in OrganizationProjectsPermission. + # Orgs default to disable_member_project_creation=True; explicitly enable it here. + self.organization.flags.disable_member_project_creation = False + self.organization.save() + + member_user = self.create_user(is_superuser=False) + self.create_member( + user=member_user, organization=self.organization, role="member", teams=[] + ) + self.login_as(user=member_user) + + response = self.get_success_response(self.organization.slug, name=self.p1, status_code=201) + + project = Project.objects.get(id=response.data["id"]) + assert project.name == project.slug == self.p1 + + # Endpoint auto-created a personal team for this member + member_email_username = fetch_slugifed_email_username(member_user.email) + team = Team.objects.get(slug=f"team-{member_email_username}") + assert project.teams.first() == team + + member = OrganizationMember.objects.get( + user_id=member_user.id, organization=self.organization + ) + assert OrganizationMemberTeam.objects.filter( + organizationmember=member, team=team, is_active=True, role="admin" + ).exists() + def test_missing_project_name(self) -> None: response = self.get_error_response(self.organization.slug, status_code=400) assert response.data == {"name": ["This field is required."]} @@ -52,9 +84,7 @@ def test_invalid_platform(self) -> None: assert response.data == {"platform": ["Invalid platform"]} @with_feature(["organizations:team-roles"]) - @patch.object( - OrganizationProjectsExperimentEndpoint, "should_add_creator_to_team", return_value=False - ) + @patch.object(OrganizationProjectsEndpoint, "should_add_creator_to_team", return_value=False) def test_not_authenticated(self, mock_add_creator: MagicMock) -> None: response = self.get_error_response(self.organization.slug, name=self.p1, status_code=401) assert response.data == {"detail": "User is not authenticated"} @@ -289,7 +319,7 @@ def test_disable_member_project_creation(self) -> None: @with_feature(["organizations:team-roles"]) @patch( - "sentry.core.endpoints.organization_projects_experiment.OrganizationProjectsExperimentEndpoint.create_audit_entry" + "sentry.core.endpoints.organization_projects.OrganizationProjectsEndpoint.create_audit_entry" ) def test_create_project_with_origin(self, create_audit_entry: MagicMock) -> None: signal_handler = Mock() From 324798afe830320a0e3a2f49e5734a1986502c8b Mon Sep 17 00:00:00 2001 From: tnt-sentry Date: Thu, 28 May 2026 12:00:05 -0400 Subject: [PATCH 03/60] chore(github-enterprise): Remove fully-GA github.com source flag checks (#116385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `features.has("organizations:github-enterprise-github-com-source", ...)` guards from the two GHE setup pipelines (form-based `InstallationConfigView` and API-driven `GHEInstallationConfigApiStep`), along with the tests that exercised the rejection path. The flag is at 100% rollout (segment `GA`, no conditions) in flagpole, so every org already passes the check — removing it makes that explicit and lets us proceed with the standard flagpole removal sequence. This is step 1 of three. Follow-ups: 1. Remove the `feature.organizations:github-enterprise-github-com-source` block from `sentry-options-automator/options/default/flagpole.yaml`. 2. Remove the `manager.add(...)` registration from `src/sentry/features/temporary.py`. Each waits for the previous to deploy. --- .../github_enterprise/integration.py | 28 +------- .../github_enterprise/test_integration.py | 68 ++----------------- 2 files changed, 8 insertions(+), 88 deletions(-) diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 4d4dbe04c65f..7bca6e3c7c12 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -591,15 +591,6 @@ def handle_post( ) -> PipelineStepResult: validated_data["url"] = urlparse(validated_data["url"]).netloc.lower() - if validated_data["url"] == "github.com" and not features.has( - "organizations:github-enterprise-github-com-source", - pipeline.organization, - ): - return PipelineStepResult.error( - "Installing on github.com is not enabled for your organization. " - "Contact Sentry support to request access." - ) - if not validated_data["public_link"]: validated_data["public_link"] = None @@ -749,26 +740,9 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR parsed = urlparse(form_data["url"]) # Tolerate input without a scheme — `urlparse("github.com").netloc` is empty # and the host lands in `path`. Without this, OAuth URLs become malformed - # (`https:///login/...`) and the github.com flag gate below is bypassed. + # (`https:///login/...`). form_data["url"] = (parsed.netloc or parsed.path).strip("/").lower() - if form_data["url"] == "github.com" and not features.has( - "organizations:github-enterprise-github-com-source", - pipeline.organization, - ): - form.add_error( - "url", - _( - "Installing on github.com is not enabled for your organization. " - "Contact Sentry support to request access." - ), - ) - return render_to_response( - template="sentry/integrations/github-enterprise-config.html", - context={"form": form}, - request=request, - ) - if not form_data["public_link"]: form_data["public_link"] = None diff --git a/tests/sentry/integrations/github_enterprise/test_integration.py b/tests/sentry/integrations/github_enterprise/test_integration.py index b6f91df1457e..5f4651fafc8d 100644 --- a/tests/sentry/integrations/github_enterprise/test_integration.py +++ b/tests/sentry/integrations/github_enterprise/test_integration.py @@ -10,7 +10,6 @@ import orjson import pytest import responses -from django.http import HttpResponse from django.test import RequestFactory from django.urls import reverse @@ -1250,8 +1249,8 @@ def test_update_comment(self) -> None: ) -class InstallationConfigViewGitHubComFlagGateTest(TestCase): - """The form must reject github.com installs when the org lacks the feature flag.""" +class InstallationConfigViewGitHubComInstallTest(TestCase): + """The form must accept github.com and GHES installs and route correctly.""" def _make_post_data(self, url: str) -> dict[str, str]: return { @@ -1265,39 +1264,22 @@ def _make_post_data(self, url: str) -> dict[str, str]: "public_link": "", } - def test_github_com_install_rejected_without_flag(self) -> None: + def test_github_com_install_allowed(self) -> None: view = InstallationConfigView() request = RequestFactory().post("/", data=self._make_post_data("https://github.com")) request.user = self.user pipeline = MagicMock() pipeline.organization = self.organization - response = view.dispatch(request, pipeline) - # Form re-renders with an error rather than calling pipeline.next_step() - assert pipeline.next_step.call_count == 0 - # The response body contains the form error message - assert isinstance(response, HttpResponse) - assert b"github.com" in response.content - assert response.status_code == 200 # form re-rendered - - def test_github_com_install_allowed_with_flag(self) -> None: - view = InstallationConfigView() - request = RequestFactory().post("/", data=self._make_post_data("https://github.com")) - request.user = self.user - pipeline = MagicMock() - pipeline.organization = self.organization - - with self.feature("organizations:github-enterprise-github-com-source"): - view.dispatch(request, pipeline) + view.dispatch(request, pipeline) - # State bound with host="github.com"; pipeline advanced pipeline.bind_state.assert_any_call( "installation_data", mock.ANY, ) pipeline.next_step.assert_called_once() - def test_ghes_install_unaffected_by_flag(self) -> None: + def test_ghes_install_allowed(self) -> None: view = InstallationConfigView() request = RequestFactory().post( "/", data=self._make_post_data("https://github.example.org") @@ -1306,7 +1288,6 @@ def test_ghes_install_unaffected_by_flag(self) -> None: pipeline = MagicMock() pipeline.organization = self.organization - # No flag enabled — GHES install should proceed view.dispatch(request, pipeline) pipeline.next_step.assert_called_once() @@ -1331,21 +1312,6 @@ def test_app_install_redirect_uses_apps_path_for_github_com(self) -> None: == "https://example.com/app" ) - def test_github_com_install_rejected_for_normalized_inputs(self) -> None: - # Any input that normalizes to "github.com" must hit the gate. Previously - # `urlparse("github.com").netloc` returned empty and let the bypass through, - # producing malformed OAuth URLs and a silent gate bypass. - view = InstallationConfigView() - for url_input in ("github.com", "GitHub.com", "https://GitHub.com", "github.com/"): - request = RequestFactory().post("/", data=self._make_post_data(url_input)) - request.user = self.user - pipeline = MagicMock() - pipeline.organization = self.organization - response = view.dispatch(request, pipeline) - assert pipeline.next_step.call_count == 0, f"flag bypassed for input {url_input!r}" - assert isinstance(response, HttpResponse) - assert response.status_code == 200 - class BuildIntegrationGitHubComTest(TestCase): """build_integration must produce external_id with the 'github.com:' prefix so the @@ -1583,38 +1549,18 @@ def test_full_pipeline_flow(self, mock_jwt: MagicMock) -> None: ).exists() @responses.activate - def test_config_step_rejects_github_com_without_flag(self) -> None: + def test_config_step_allows_github_com(self) -> None: self._initialize_pipeline() resp = self._submit_config(url="https://github.com") - assert resp.status_code == 400 - assert resp.data["status"] == "error" - assert "github.com" in resp.data["data"]["detail"] - - @responses.activate - def test_config_step_allows_github_com_with_flag(self) -> None: - self._initialize_pipeline() - with self.feature("organizations:github-enterprise-github-com-source"): - resp = self._submit_config(url="https://github.com") assert resp.status_code == 200 assert resp.data["status"] == "advance" assert resp.data["step"] == "app_install_redirect" - def test_config_step_rejects_mixed_case_github_com(self) -> None: - # The github.com flag gate must be case-insensitive. URL hostnames are - # case-insensitive in DNS, so `GitHub.COM` resolves identically and - # would bypass the gate if the comparison weren't normalized. - self._initialize_pipeline() - resp = self._submit_config(url="https://GitHub.COM") - assert resp.status_code == 400 - assert resp.data["status"] == "error" - assert "github.com" in resp.data["data"]["detail"] - def test_app_install_step_uses_apps_path_for_github_com(self) -> None: # github.com hosts the App install page at /apps/{name}, GHES at # /github-apps/{name}. Wrong URL → 404 → broken install flow. self._initialize_pipeline() - with self.feature("organizations:github-enterprise-github-com-source"): - self._submit_config(url="https://github.com") + self._submit_config(url="https://github.com") resp = self._advance_step({}) assert resp.status_code == 200 assert resp.data["data"]["appInstallUrl"] == "https://github.com/apps/sentry-app" From e122a14ddcd0671d5c39f42fdf97a127d74970bc Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 28 May 2026 13:08:17 -0300 Subject: [PATCH 04/60] fix(search-query-builder): Add dynamic fetching to has (#116097) Currently the `has` filter does not support dynamic fetching of attributes for it's value combobox. This means users may not be seeing all of the potential keys. This PR updates the query builder `has` filter to search dynamically for those values based off of the `getTagKeys` prop. Closes LOGS-819 --------- Co-authored-by: Claude Opus 4 --- .../searchQueryBuilder/index.spec.tsx | 65 +++++++++++++++++++ .../tokens/filter/valueCombobox.tsx | 59 ++++++++++++----- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 26e065245eb2..0cfe5c34fee6 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -6585,5 +6585,70 @@ describe('SearchQueryBuilder', () => { expect(mockGetTagKeys).toHaveBeenCalledWith('some_query'); }); }); + + it('uses getTagKeys for has value suggestions and deduplicates static keys', async () => { + const mockGetTagKeys = jest.fn().mockResolvedValue([ + {key: 'custom_tag_name', name: 'Custom Tag Name', kind: FieldKind.TAG}, + {key: 'async_tag_one', name: 'Async Tag One', kind: FieldKind.TAG}, + ]); + const mockGetTagValues = jest.fn().mockResolvedValue([]); + + render( + + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: has'}) + ); + const input = await screen.findByRole('combobox', {name: 'Edit filter value'}); + await userEvent.type(input, 'tag'); + + await waitFor(() => { + expect(mockGetTagKeys).toHaveBeenCalledWith('tag'); + }); + + expect( + await screen.findByRole('option', {name: 'async_tag_one'}) + ).toBeInTheDocument(); + expect(screen.getAllByRole('option', {name: 'custom_tag_name'})).toHaveLength(1); + expect(mockGetTagValues).not.toHaveBeenCalled(); + }); + + it('saves the selected async has suggestion as the returned key', async () => { + const mockGetTagKeys = jest + .fn() + .mockResolvedValue([ + {key: 'async_tag_one', name: 'Async Tag One', kind: FieldKind.TAG}, + ]); + const mockOnChange = jest.fn(); + + render( + + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: has'}) + ); + const input = await screen.findByRole('combobox', {name: 'Edit filter value'}); + await userEvent.type(input, 'async'); + await userEvent.click(await screen.findByRole('option', {name: 'async_tag_one'})); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenLastCalledWith( + 'has:async_tag_one', + expect.anything() + ); + }); + }); }); }); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index 000ce055dcff..f484c07a4bc9 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -343,7 +343,9 @@ function sortSuggestionsByFzf( return suggestions .map((suggestion, index) => { - const result = fzf(suggestion.value, query, false); + const text = + typeof suggestion.label === 'string' ? suggestion.label : suggestion.value; + const result = fzf(text, query, false); return { suggestion, score: result.end === -1 ? 0 : Math.max(1, result.score), @@ -364,7 +366,8 @@ function useFilterSuggestions({ token: TokenResult; }) { const keyName = getKeyName(token.key); - const {getFieldDefinition, getTagValues, filterKeys} = useSearchQueryBuilderConfig(); + const {getFieldDefinition, getTagKeys, getTagValues, filterKeys} = + useSearchQueryBuilderConfig(); const key = filterKeys[keyName]; const fieldDefinition = getFieldDefinition(keyName); const valueType = getFilterValueType(token, fieldDefinition); @@ -382,7 +385,9 @@ function useFilterSuggestions({ // This is because the way keys are fetched doesn't guarantee that we have // every key loaded. So we should try to fetch values for it even if it // doesn't exist in the list of available keys. - const shouldFetchValues = predefinedValues === null && (key ? !key.predefined : true); + const shouldFetchTagKeys = token.filter === FilterType.HAS && !!getTagKeys; + const shouldFetchValues = + !shouldFetchTagKeys && predefinedValues === null && (key ? !key.predefined : true); const shouldUseDefaultSuggestionOrder = shouldUseDefaultNumericSuggestions( filterValue, valueType @@ -411,6 +416,13 @@ function useFilterSuggestions({ const queryKey = useDebouncedValue(baseQueryKey); const isDebouncing = baseQueryKey !== queryKey; + const tagKeysBaseQueryKey = useMemo( + () => ['search-query-builder-tag-keys', filterValue] as const, + [filterValue] + ); + const tagKeysQueryKey = useDebouncedValue(tagKeysBaseQueryKey); + const isDebouncingTagKeys = tagKeysBaseQueryKey !== tagKeysQueryKey; + // TODO(malwilley): Display error states // eslint-disable-next-line @tanstack/query/exhaustive-deps const {data, isFetching} = useQuery({ @@ -421,6 +433,15 @@ function useFilterSuggestions({ enabled: shouldFetchValues, }); + // TODO(malwilley): Display error states + // eslint-disable-next-line @tanstack/query/exhaustive-deps + const {data: asyncKeys, isFetching: isFetchingTagKeys} = useQuery({ + queryKey: tagKeysQueryKey, + queryFn: ctx => getTagKeys?.(ctx.queryKey[1] ?? '') ?? [], + placeholderData: keepPreviousData, + enabled: shouldFetchTagKeys, + }); + const createItem = useCallback( (suggestion: SuggestionItem) => { const label = suggestion.label ?? suggestion.value; @@ -434,7 +455,7 @@ function useFilterSuggestions({ ), value: suggestion.value, details: suggestion.description, - textValue: suggestion.value, + textValue: typeof label === 'string' ? label : suggestion.value, hideCheck: true, selectionMode: canSelectMultipleValues ? 'multiple' : 'single', trailingItems: ({isFocused, disabled}: any) => { @@ -457,7 +478,14 @@ function useFilterSuggestions({ const suggestionGroups = useMemo(() => { let groups: SuggestionSection[]; - if (shouldFetchValues) { + if (shouldFetchTagKeys) { + const suggestions = + asyncKeys?.map(tag => ({ + label: prettifyTagKey(tag.key), + value: tag.key, + })) ?? []; + groups = [{sectionText: '', suggestions}]; + } else if (shouldFetchValues) { const suggestions = data?.map(value => { return { value, @@ -485,7 +513,9 @@ function useFilterSuggestions({ })); }, [ data, + asyncKeys, predefinedValues, + shouldFetchTagKeys, shouldFetchValues, key?.key, filterValue, @@ -506,7 +536,7 @@ function useFilterSuggestions({ return { items, suggestionSectionItems, - isFetching: isFetching || isDebouncing, + isFetching: isFetching || isDebouncing || isFetchingTagKeys || isDebouncingTagKeys, }; } @@ -788,16 +818,13 @@ export function SearchQueryBuilderValueCombobox({ {escapeSearchValue = false}: {escapeSearchValue?: boolean} = {} ) => { if (token.filter === FilterType.HAS) { - const suggested = getSuggestedFilterKey(value); - if (suggested) { - dispatch({ - type: 'UPDATE_TOKEN_VALUE', - token, - value: suggested, - }); - onCommit(); - return true; - } + dispatch({ + type: 'UPDATE_TOKEN_VALUE', + token, + value: getSuggestedFilterKey(value) ?? value, + }); + onCommit(); + return true; } const valueForSaving = From be01523f69a337763547d8c172bad4afcc30d688 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Thu, 28 May 2026 12:14:15 -0400 Subject: [PATCH 05/60] fix(spans): deprecations shouldn't shadow public field names (#116387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical attribute name for current environment (`production`, `staging`, etc) is `sentry.environment`. However, for users' convenience, we instead expose this in search as `environment`: https://github.com/getsentry/sentry/blob/a26a0bef04d21e6ee7b6a55d7324ce01f7e3c9d3/src/sentry/search/eap/spans/attributes.py#L431 We also have code that makes Sentry aware of deprecated attributes from https://github.com/getsentry/sentry-conventions, in `_update_attribute_definitions_with_deprecations`: https://github.com/getsentry/sentry/blob/a26a0bef04d21e6ee7b6a55d7324ce01f7e3c9d3/src/sentry/search/eap/spans/attributes.py#L528-L590 When the sentry-conventions package was updated in #113515 it brought in two new attributes that backfill into `sentry.environment`: ```json {"key": "resource.deployment.environment", "_status": "backfill", "replacement": "sentry.environment"} {"key": "resource.deployment.environment.name", "_status": "backfill", "replacement": "sentry.environment"} ``` With this, `_update_attribute_definitions_with_deprecations` started adding a field definition for `sentry.environment`, shadowing our explicit mapping to the public field name `environment`. Not only did this start defining the wrong attribute name, it actually hid environment entirely, due to separate handling that filters out `sentry.`-prefixed attribute names. This PR explicitly checks for existing field definitions _before_ adding new ones from sentry-conventions' deprecations. Currently `environment`/`sentry.environment` appears to be the only field affected by this bug, but this pattern in general should work if new such definitions/deprecations are ever added. Fixes BROWSE-527. --- 🤖: Claude Code (Opus 4.6) used to generate the code, with human editing + careful review. All words my own. --- src/sentry/search/eap/spans/attributes.py | 17 ++++--- tests/sentry/search/eap/test_spans.py | 44 +++++++++++++++++++ ...test_organization_trace_item_attributes.py | 26 +++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/sentry/search/eap/spans/attributes.py b/src/sentry/search/eap/spans/attributes.py index fd711d703660..5e8b49657f28 100644 --- a/src/sentry/search/eap/spans/attributes.py +++ b/src/sentry/search/eap/spans/attributes.py @@ -556,7 +556,10 @@ def _update_attribute_definitions_with_deprecations( deprecation_status=status, ) # TODO: Introduce units to attribute schema. - if replacement not in attribute_definitions: + if ( + replacement not in attribute_definitions + and replacement not in span_attribute_definitions_by_internal_name + ): attribute_definitions[replacement] = replace( deprecated_attr, public_alias=replacement, @@ -575,7 +578,10 @@ def _update_attribute_definitions_with_deprecations( deprecation_status=status, ) - if replacement not in attribute_definitions: + if ( + replacement not in attribute_definitions + and replacement not in span_attribute_definitions_by_internal_name + ): attribute_definitions[replacement] = ResolvedAttribute( public_alias=replacement, internal_name=replacement, @@ -585,9 +591,10 @@ def _update_attribute_definitions_with_deprecations( span_attribute_definitions_by_internal_name[key] = attribute_definitions[ deprecated_public_alias ] - span_attribute_definitions_by_internal_name[replacement] = attribute_definitions[ - replacement - ] + if replacement in attribute_definitions: + span_attribute_definitions_by_internal_name[replacement] = attribute_definitions[ + replacement + ] try: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index dce7f9996185..04f3d6cd1d56 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1210,6 +1210,50 @@ def test_deprecated_attribute_does_not_overwrite_existing_replacement() -> None: assert replacement_attr.replacement is None +def test_deprecated_attribute_replacement_does_not_shadow_existing_internal_name() -> None: + attribute_definitions = { + "environment": ResolvedAttribute( + public_alias="environment", + internal_name="sentry.environment", + search_type="string", + ), + } + + _update_attribute_definitions_with_deprecations( + attribute_definitions, + [ + { + "key": "resource.deployment.environment", + "type": "string", + "deprecation": { + "_status": "backfill", + "replacement": "sentry.environment", + }, + }, + { + "key": "resource.deployment.environment.name", + "type": "string", + "deprecation": { + "_status": "backfill", + "replacement": "sentry.environment", + }, + }, + ], + ) + + assert "sentry.environment" not in attribute_definitions + assert attribute_definitions["environment"].public_alias == "environment" + assert attribute_definitions["environment"].internal_name == "sentry.environment" + + assert ( + attribute_definitions["resource.deployment.environment"].replacement == "sentry.environment" + ) + assert ( + attribute_definitions["resource.deployment.environment.name"].replacement + == "sentry.environment" + ) + + def test_deprecated_attribute_normalizes_supported_convention_attribute_types() -> None: attribute_definitions: dict[str, ResolvedAttribute] = {} 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 7863f449a9a9..e7301fdbe83f 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -1161,6 +1161,32 @@ def test_multiple_attribute_types(self) -> None: assert ("tags[tag.number,number]", "number") in keys assert ("tag.string", "string") in keys + def test_sentry_environment_attribute_name(self) -> None: + self.store_segment( + self.project.id, + uuid4().hex, + uuid4().hex, + span_id=uuid4().hex[:16], + organization_id=self.organization.id, + parent_span_id=None, + timestamp=before_now(days=0, minutes=10).replace(microsecond=0), + transaction="foo", + duration=100, + exclusive_time=100, + environment="prod", + ) + + response = self.do_request( + query={ + "attributeType": "string", + "substringMatch": "environment", + } + ) + assert response.status_code == 200, response.content + + names = {item["name"] for item in response.data} + assert "environment" in names + class OrganizationTraceItemAttributesEndpointTraceMetricsTest( OrganizationTraceItemAttributesEndpointTestBase, TraceMetricsTestCase From 931509d10f10acb7175fb5ddafc2b0c5b1905046 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 28 May 2026 09:23:55 -0700 Subject: [PATCH 06/60] feat(replays): Add superuser replay debugger dropdown option (#116391) - Add a new "Debug in Sentry Replay Debugger" dropdown menu item for superusers/employees in the replay detail view that opens the [Sentry Replay Debugger](https://github.com/getsentry/replay-debugger/releases) macOS app - Replace `(superuser)` text with `FeatureBadge` debug variant on existing superuser-only menu items image Co-authored-by: Claude Opus 4 --- .../detail/header/replayItemDropdown.tsx | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/static/app/views/explore/replays/detail/header/replayItemDropdown.tsx b/static/app/views/explore/replays/detail/header/replayItemDropdown.tsx index f8a4d415b8f7..1021817963a8 100644 --- a/static/app/views/explore/replays/detail/header/replayItemDropdown.tsx +++ b/static/app/views/explore/replays/detail/header/replayItemDropdown.tsx @@ -1,12 +1,14 @@ import * as Sentry from '@sentry/react'; +import {FeatureBadge} from '@sentry/scraps/badge'; import {Flex} from '@sentry/scraps/layout'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {IconDelete, IconDownload, IconEllipsis, IconUpload} from 'sentry/icons'; -import {t} from 'sentry/locale'; +import {ExternalLink} from 'sentry/components/links/externalLink'; +import {IconBug, IconDelete, IconDownload, IconEllipsis, IconUpload} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; import {defined} from 'sentry/utils'; import {downloadObjectAsJson} from 'sentry/utils/downloadObjectAsJson'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; @@ -74,7 +76,8 @@ export function ReplayItemDropdown({projectSlug, replay, replayRecord}: Props) { label: ( - {t('Download Replay Record (superuser)')} + {t('Download Replay Record')} + ), onAction: () => { @@ -92,6 +95,41 @@ export function ReplayItemDropdown({projectSlug, replay, replayRecord}: Props) { disabled: !canDownload, } : null, + canSeeEmployeeLinks + ? { + key: 'open-in-replay-debugger', + label: ( + + + + {tct('Debug in [link]', { + link: ( + + {t('Sentry Replay Debugger')} + + ), + })} + + + + ), + onAction: async () => { + try { + if (!replay) { + addErrorMessage(t('Replay not found')); + return; + } + const json = JSON.stringify(replay.getRRWebFrames()); + await navigator.clipboard.writeText(json); + window.location.href = 'sentry-replay-debugger://open'; + } catch (error) { + Sentry.captureException(error); + addErrorMessage(t('Could not open replay debugger. Please try again.')); + } + }, + disabled: !canDownload, + } + : null, canSeeEmployeeLinks && isMobile ? { key: 'download-1st-video', From acd82c951a548de1fce0d10b478458c097768718 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 28 May 2026 09:24:25 -0700 Subject: [PATCH 07/60] feat(eslint): Add no-raw-css-in-styled rule (#115934) Enforces css template tags inside of styled css. Allows us to enforce our css lint rules and formatting. ```tsx // Bad const Row = styled('div')<{isSelected: boolean}>` padding: ${p => p.theme.space.md}; ${p => p.isSelected && ` background: ${p.theme.tokens.background.secondary}; border-color: ${p.theme.tokens.border.accent}; `} `; // Good const Row = styled('div')<{isSelected: boolean}>` padding: ${p => p.theme.space.md}; ${p => p.isSelected && css` background: ${p.theme.tokens.background.secondary}; border-color: ${p.theme.tokens.border.accent}; `} `; ``` --- eslint.config.ts | 1 + .../avatarChooser/avatarCropper.tsx | 9 +- .../checkInTimeline/timelineCursor.tsx | 9 +- .../core/avatarButton/avatarButton.tsx | 19 +- .../app/components/core/button/buttonBar.tsx | 205 +++++++++--------- static/app/components/core/code/codeBlock.tsx | 8 +- .../components/core/compactSelect/control.tsx | 19 +- .../app/components/core/drawer/components.tsx | 17 +- .../app/components/core/input/inputGroup.tsx | 6 +- .../core/menuListItem/menuListItem.tsx | 6 +- static/app/components/dropdownMenu/list.tsx | 7 +- .../attachmentViewers/logFileViewer.tsx | 22 +- static/app/components/events/eventDrawer.tsx | 3 +- .../debugImageDetails/generalInfo.tsx | 13 +- .../interfaces/sourceMapsDebuggerModal.tsx | 7 +- .../forms/fieldGroup/fieldControlState.tsx | 12 +- .../components/modals/insightChartModal.tsx | 12 +- static/app/components/panels/panelBody.tsx | 7 +- static/app/components/panels/panelTable.tsx | 8 +- static/app/components/placeholder.tsx | 13 +- static/app/components/platformPicker.tsx | 8 +- .../aggregateFlamegraphTreeTable.tsx | 7 +- static/app/components/progressRing.tsx | 9 +- static/app/components/searchBar/index.tsx | 7 +- .../components/searchBar/searchDropdown.tsx | 6 +- static/app/components/similarSpectrum.tsx | 2 +- static/app/components/slider/index.tsx | 22 +- .../tables/gridEditable/sortLink.tsx | 15 +- .../components/tables/gridEditable/styles.tsx | 9 +- .../workflowEngine/gridCell/titleCell.tsx | 13 +- .../app/stories/view/storyTableOfContents.tsx | 3 +- static/app/views/alerts/list/rules/row.tsx | 6 +- .../metric/details/relatedTransactions.tsx | 8 +- .../detectorListTable/detectorTypeCell.tsx | 7 +- .../app/views/discover/table/queryField.tsx | 5 +- static/app/views/explore/components/table.tsx | 7 +- .../components/messageToolCalls.tsx | 3 +- .../components/messagesPanel.tsx | 81 +++---- static/app/views/explore/logs/styles.tsx | 50 +++-- .../metricInfoTabs/metricInfoTabStyles.tsx | 19 +- .../explore/metrics/metricsOnboarding.tsx | 17 +- .../queryVisualizations/table.tsx | 7 +- .../explore/profiling/landing/styles.tsx | 7 +- .../app/views/explore/releases/list/index.tsx | 3 +- .../explore/tables/tracesTable/styles.tsx | 15 +- .../app/views/feedback/feedbackListPage.tsx | 7 +- .../pageOverviewWebVitalsDetailPanel.tsx | 8 +- .../components/webVitalsDetailPanel.tsx | 8 +- .../components/onboardingStepHeading.tsx | 3 +- .../newTraceDetails/traceWaterfall.tsx | 3 +- .../main/sizeCompareSelectionContent.tsx | 3 +- .../main/buildDetailsMetricCards.tsx | 9 +- .../snapshots/main/snapshotDiffBodies.tsx | 8 +- .../preprod/snapshots/main/snapshotFrames.tsx | 3 +- .../snapshots/main/snapshotMainContent.tsx | 35 +-- .../components/fileDiffViewer.tsx | 35 ++- .../integrationExternalMappings.tsx | 30 +-- static/eslint/eslintPluginSentry/index.ts | 2 + .../no-raw-css-in-styled.spec.ts | 130 +++++++++++ .../no-raw-css-in-styled.ts | 87 ++++++++ 60 files changed, 784 insertions(+), 326 deletions(-) create mode 100644 static/eslint/eslintPluginSentry/no-raw-css-in-styled.spec.ts create mode 100644 static/eslint/eslintPluginSentry/no-raw-css-in-styled.ts diff --git a/eslint.config.ts b/eslint.config.ts index cc190b0dff1e..267f2bc28d41 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -458,6 +458,7 @@ export default typescript.config([ '@sentry/no-flag-comments': 'error', '@sentry/no-query-data-type-parameters': 'error', '@sentry/no-static-translations': 'error', + '@sentry/no-raw-css-in-styled': 'error', '@sentry/no-styled-shortcut': 'error', '@sentry/no-unnecessary-use-callback': 'error', }, diff --git a/static/app/components/avatarChooser/avatarCropper.tsx b/static/app/components/avatarChooser/avatarCropper.tsx index bef67e6a2503..a55c3345d478 100644 --- a/static/app/components/avatarChooser/avatarCropper.tsx +++ b/static/app/components/avatarChooser/avatarCropper.tsx @@ -1,4 +1,5 @@ import {Fragment, useCallback, useLayoutEffect, useRef, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; export function getDiffNW(yDiff: number, xDiff: number) { @@ -475,7 +476,13 @@ const ResizeHandle = styled('div')<{position: Position}>` position: absolute; background-color: ${p => p.theme.colors.gray400}; cursor: ${p => `${p.position}-resize`}; - ${p => RESIZER_POSITIONS[p.position].map(pos => `${pos}: -5px;`)} + ${p => + RESIZER_POSITIONS[p.position].map( + pos => + css` + ${pos}: -5px; + ` + )} `; const HiddenCanvas = styled('canvas')` diff --git a/static/app/components/checkInTimeline/timelineCursor.tsx b/static/app/components/checkInTimeline/timelineCursor.tsx index 8035d4fad285..fb5fb6231735 100644 --- a/static/app/components/checkInTimeline/timelineCursor.tsx +++ b/static/app/components/checkInTimeline/timelineCursor.tsx @@ -1,4 +1,5 @@ import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; @@ -183,7 +184,13 @@ const CursorLabel = styled(Overlay)<{ line-height: 1.2; position: absolute; ${p => - p.anchor === 'top' ? `top: ${p.anchorOffset}px;` : `bottom: ${p.anchorOffset}px;`} + p.anchor === 'top' + ? css` + top: ${p.anchorOffset}px; + ` + : css` + bottom: ${p.anchorOffset}px; + `} left: clamp( 0px, calc(var(--cursorOffset) + ${p => p.offsets?.left ?? 0}px + ${TOOLTIP_OFFSET}px), diff --git a/static/app/components/core/avatarButton/avatarButton.tsx b/static/app/components/core/avatarButton/avatarButton.tsx index 8ab8f539c18d..9333793a2481 100644 --- a/static/app/components/core/avatarButton/avatarButton.tsx +++ b/static/app/components/core/avatarButton/avatarButton.tsx @@ -1,4 +1,5 @@ import {useTheme, type Theme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {skipToken, useQuery} from '@tanstack/react-query'; import color from 'color'; @@ -121,15 +122,15 @@ const StyledAvatarButton = styled(Button)<{chonk: string | undefined}>` ${p => p.chonk && - ` - &&::before { - background: ${p.chonk}; - box-shadow: 0 ${AVATAR_BUTTON_ELEVATION[p.size ?? 'md'] ?? '2px'} 0 0px ${p.chonk}; - } - &&::after { - border-color: ${p.chonk}; - } - `} + css` + &&::before { + background: ${p.chonk}; + box-shadow: 0 ${AVATAR_BUTTON_ELEVATION[p.size ?? 'md'] ?? '2px'} 0 0px ${p.chonk}; + } + &&::after { + border-color: ${p.chonk}; + } + `} `; // Returns 'fill' when the image covers the full frame edge-to-edge, 'padded' otherwise. diff --git a/static/app/components/core/button/buttonBar.tsx b/static/app/components/core/button/buttonBar.tsx index ecae91cd4930..25be9093c117 100644 --- a/static/app/components/core/button/buttonBar.tsx +++ b/static/app/components/core/button/buttonBar.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Grid, type GridProps} from '@sentry/scraps/layout'; @@ -43,107 +44,107 @@ export const ButtonBar = styled( ${p => p.orientation === 'vertical' - ? ` - /* First button is square on the bottom side */ - &:first-child:not(:last-child) { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - } - - /* Middle buttons are square */ - &:not(:last-child):not(:first-child), - &:not(:last-child):not(:first-child)[role='presentation'] > button, - &:not(:last-child):not(:first-child)[role='presentation'] > a { - border-radius: 0; - - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-radius: 0; - } - } - - /* Middle buttons only need one border so we don't get a double line */ - & + [role='presentation'] > button, - & + [role='presentation'] > a, - & + .dropdown:not(:last-child), - & + a:not(:last-child), - & + input:not(:last-child), - & + button:not(:last-child) { - margin-top: -1px; - } - - /* Last button is square on the top side */ - &:last-child:not(:first-child) { - border-top-left-radius: 0; - border-top-right-radius: 0; - margin-top: -1px; - - &[role='presentation'] > button, - &[role='presentation'] > a, - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-top-left-radius: 0; - border-top-right-radius: 0; - margin-top: -1px; - } - } - ` - : ` - /* First button is square on the right side */ - &:first-child:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - - /* Middle buttons are square */ - &:not(:last-child):not(:first-child), - &:not(:last-child):not(:first-child)[role='presentation'] > button, - &:not(:last-child):not(:first-child)[role='presentation'] > a { - border-radius: 0; - - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-radius: 0; - } - } - - /* Middle buttons only need one border so we don't get a double line */ - & + [role='presentation'] > button, - & + [role='presentation'] > a, - & + .dropdown:not(:last-child), - & + a:not(:last-child), - & + input:not(:last-child), - & + button:not(:last-child) { - margin-left: -1px; - } - - /* Last button is square on the left side */ - &:last-child:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - margin-left: -1px; - - &[role='presentation'] > button, - &[role='presentation'] > a, - & > .dropdown-actor > button, - & > .dropdown-actor > a { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - margin-left: -1px; - } - } - `} + ? css` + /* First button is square on the bottom side */ + &:first-child:not(:last-child) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + /* Middle buttons are square */ + &:not(:last-child):not(:first-child), + &:not(:last-child):not(:first-child)[role='presentation'] > button, + &:not(:last-child):not(:first-child)[role='presentation'] > a { + border-radius: 0; + + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-radius: 0; + } + } + + /* Middle buttons only need one border so we don't get a double line */ + & + [role='presentation'] > button, + & + [role='presentation'] > a, + & + .dropdown:not(:last-child), + & + a:not(:last-child), + & + input:not(:last-child), + & + button:not(:last-child) { + margin-top: -1px; + } + + /* Last button is square on the top side */ + &:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: -1px; + + &[role='presentation'] > button, + &[role='presentation'] > a, + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: -1px; + } + } + ` + : css` + /* First button is square on the right side */ + &:first-child:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + /* Middle buttons are square */ + &:not(:last-child):not(:first-child), + &:not(:last-child):not(:first-child)[role='presentation'] > button, + &:not(:last-child):not(:first-child)[role='presentation'] > a { + border-radius: 0; + + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-radius: 0; + } + } + + /* Middle buttons only need one border so we don't get a double line */ + & + [role='presentation'] > button, + & + [role='presentation'] > a, + & + .dropdown:not(:last-child), + & + a:not(:last-child), + & + input:not(:last-child), + & + button:not(:last-child) { + margin-left: -1px; + } + + /* Last button is square on the left side */ + &:last-child:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -1px; + + &[role='presentation'] > button, + &[role='presentation'] > a, + & > .dropdown-actor > button, + & > .dropdown-actor > a { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -1px; + } + } + `} } `; diff --git a/static/app/components/core/code/codeBlock.tsx b/static/app/components/core/code/codeBlock.tsx index e22d7bd41c5a..9c7e13ad505c 100644 --- a/static/app/components/core/code/codeBlock.tsx +++ b/static/app/components/core/code/codeBlock.tsx @@ -287,9 +287,11 @@ const Tab = styled('button')<{isSelected: boolean}>` color: var(--prism-comment); ${p => p.isSelected - ? `border-bottom: 3px solid ${p.theme.tokens.graphics.accent.vibrant}; - padding-bottom: 5px; - color: var(--prism-base);` + ? css` + border-bottom: 3px solid ${p.theme.tokens.graphics.accent.vibrant}; + padding-bottom: 5px; + color: var(--prism-base); + ` : ''} `; diff --git a/static/app/components/core/compactSelect/control.tsx b/static/app/components/core/compactSelect/control.tsx index 98a3e7cb23c5..e617b7d772b6 100644 --- a/static/app/components/core/compactSelect/control.tsx +++ b/static/app/components/core/compactSelect/control.tsx @@ -10,6 +10,7 @@ import { import * as React from 'react'; import isPropValid from '@emotion/is-prop-valid'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {FocusScope} from '@react-aria/focus'; import {useKeyboard} from '@react-aria/interactions'; @@ -693,9 +694,21 @@ const StyledOverlay = styled(Overlay, { flex-direction: column; overflow: hidden; - ${p => p.width && `width: ${withUnits(p.width)};`} - ${p => p.height && `height: ${withUnits(p.height)};`} - ${p => p.minWidth && `min-width: ${withUnits(p.minWidth)};`} + ${p => + p.width && + css` + width: ${withUnits(p.width)}; + `} + ${p => + p.height && + css` + height: ${withUnits(p.height)}; + `} + ${p => + p.minWidth && + css` + min-width: ${withUnits(p.minWidth)}; + `} max-width: ${p => (p.maxWidth ? `min(${withUnits(p.maxWidth)}, 100%)` : '100%')}; max-height: ${p => p.maxHeight diff --git a/static/app/components/core/drawer/components.tsx b/static/app/components/core/drawer/components.tsx index c48e790c90cd..426ebd2b5311 100644 --- a/static/app/components/core/drawer/components.tsx +++ b/static/app/components/core/drawer/components.tsx @@ -1,4 +1,5 @@ import {createContext, Fragment, useContext, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {mergeRefs} from '@react-aria/utils'; @@ -193,14 +194,14 @@ const Header = styled('header')<{ padding-bottom: ${p => (p.hideCloseButton ? p.theme.space.lg : p.theme.space.sm)}; ${p => p.height && - ` - --drawer-header-height: ${p.height}; - height: var(--drawer-header-height); - box-sizing: border-box; - align-items: center; - box-shadow: none; - border-bottom: 1px solid ${p.theme.tokens.border.primary}; - `} + css` + --drawer-header-height: ${p.height}; + height: var(--drawer-header-height); + box-sizing: border-box; + align-items: center; + box-shadow: none; + border-bottom: 1px solid ${p.theme.tokens.border.primary}; + `} `; export const DrawerBody = styled('aside')` diff --git a/static/app/components/core/input/inputGroup.tsx b/static/app/components/core/input/inputGroup.tsx index 90ffa88078ba..e35b066d5b72 100644 --- a/static/app/components/core/input/inputGroup.tsx +++ b/static/app/components/core/input/inputGroup.tsx @@ -271,5 +271,9 @@ InputGroup.TrailingItems = TrailingItems; const InputGroupWrap = styled('div')<{disabled?: boolean}>` position: relative; - ${p => p.disabled && `color: ${p.theme.tokens.content.disabled};`}; + ${p => + p.disabled && + css` + color: ${p.theme.tokens.content.disabled}; + `}; `; diff --git a/static/app/components/core/menuListItem/menuListItem.tsx b/static/app/components/core/menuListItem/menuListItem.tsx index 3e14c9d25b9e..f430cf6cb256 100644 --- a/static/app/components/core/menuListItem/menuListItem.tsx +++ b/static/app/components/core/menuListItem/menuListItem.tsx @@ -154,7 +154,11 @@ const StyledDetails = styled('div')<{disabled: boolean; priority: Priority}>` line-height: 1.4; margin-bottom: 0; - ${p => p.priority !== 'default' && `color: ${getTextColor(p)};`} + ${p => + p.priority !== 'default' && + css` + color: ${getTextColor(p)}; + `} `; /** diff --git a/static/app/components/dropdownMenu/list.tsx b/static/app/components/dropdownMenu/list.tsx index 62745021e8ca..ad23f860db02 100644 --- a/static/app/components/dropdownMenu/list.tsx +++ b/static/app/components/dropdownMenu/list.tsx @@ -1,5 +1,6 @@ import {createContext, Fragment, useContext, useMemo, useRef} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {FocusScope} from '@react-aria/focus'; import {useKeyboard} from '@react-aria/interactions'; @@ -272,7 +273,11 @@ const DropdownMenuListWrap = styled('ul')<{hasTitle: boolean}>` overflow-x: hidden; overflow-y: auto; - ${p => p.hasTitle && `padding-top: calc(${p.theme.space.xs} + 1px);`} + ${p => + p.hasTitle && + css` + padding-top: calc(${p.theme.space.xs} + 1px); + `} &:focus { outline: none; diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx index db52a6dc91eb..7c75ed39e67d 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import Ansi from 'ansi-to-react'; @@ -52,16 +53,17 @@ const COLOR_MAP = { const SentryStyleAnsi = styled(Ansi)` ${p => Object.entries(COLOR_MAP).map( - ([ansiColor, themeColor]) => ` - .ansi-${ansiColor}-bg { - background-color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-bright-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}200`]}; - }` + ([ansiColor, themeColor]) => css` + .ansi-${ansiColor}-bg { + background-color: ${p.theme.colors[`${themeColor}500`]}; + } + .ansi-${ansiColor}-fg { + color: ${p.theme.colors[`${themeColor}500`]}; + } + .ansi-bright-${ansiColor}-fg { + color: ${p.theme.colors[`${themeColor}200`]}; + } + ` )} .ansi-black-fg, diff --git a/static/app/components/events/eventDrawer.tsx b/static/app/components/events/eventDrawer.tsx index 51b4be494c78..8ba2740db74b 100644 --- a/static/app/components/events/eventDrawer.tsx +++ b/static/app/components/events/eventDrawer.tsx @@ -1,4 +1,5 @@ import type {ComponentPropsWithoutRef} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {DrawerBody, DrawerHeader} from '@sentry/scraps/drawer'; @@ -44,7 +45,7 @@ const EventDrawerContainerRoot = styled('div')<{hasPageFrameFeature: boolean}>` ${p => p.hasPageFrameFeature && - ` + css` /* Responsive height that matches the TopBar (48px mobile, 53px desktop) */ --event-drawer-header-height: ${NAVIGATION_MOBILE_TOPBAR_HEIGHT_WITH_PAGE_FRAME}px; --event-navigator-box-shadow: none; diff --git a/static/app/components/events/interfaces/debugMeta/debugImageDetails/generalInfo.tsx b/static/app/components/events/interfaces/debugMeta/debugImageDetails/generalInfo.tsx index 8d214c542a12..397462a538d6 100644 --- a/static/app/components/events/interfaces/debugMeta/debugImageDetails/generalInfo.tsx +++ b/static/app/components/events/interfaces/debugMeta/debugImageDetails/generalInfo.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Processings} from 'sentry/components/events/interfaces/debugMeta/debugImage/processings'; @@ -57,7 +58,11 @@ const Label = styled('div')<{coloredBg?: boolean}>` color: ${p => p.theme.tokens.content.primary}; padding: ${p => p.theme.space.md} ${p => p.theme.space.lg} ${p => p.theme.space.md} ${p => p.theme.space.md}; - ${p => p.coloredBg && `background-color: ${p.theme.tokens.background.secondary};`} + ${p => + p.coloredBg && + css` + background-color: ${p.theme.tokens.background.secondary}; + `} `; const Value = styled(Label)` @@ -66,5 +71,9 @@ const Value = styled(Label)` color: ${p => p.theme.tokens.content.secondary}; padding: ${p => p.theme.space.md}; font-family: ${p => p.theme.font.family.mono}; - ${p => p.coloredBg && `background-color: ${p.theme.tokens.background.secondary};`} + ${p => + p.coloredBg && + css` + background-color: ${p.theme.tokens.background.secondary}; + `} `; diff --git a/static/app/components/events/interfaces/sourceMapsDebuggerModal.tsx b/static/app/components/events/interfaces/sourceMapsDebuggerModal.tsx index d50e1c1d19c4..94e1f390ad19 100644 --- a/static/app/components/events/interfaces/sourceMapsDebuggerModal.tsx +++ b/static/app/components/events/interfaces/sourceMapsDebuggerModal.tsx @@ -1,6 +1,7 @@ import type {PropsWithChildren, ReactNode} from 'react'; import {Fragment, useMemo, useState} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import BadStackTraceExample from 'sentry-images/issue_details/bad-stack-trace-example.png'; @@ -1984,7 +1985,11 @@ function SourceMapStepNotRequiredNote() { } const StyledTabPanels = styled(TabPanels)<{hideAllTabs: boolean}>` - ${p => !p.hideAllTabs && `padding-top: ${p.theme.space.xl};`} + ${p => + !p.hideAllTabs && + css` + padding-top: ${p.theme.space.xl}; + `} `; const CheckList = styled('ul')` diff --git a/static/app/components/forms/fieldGroup/fieldControlState.tsx b/static/app/components/forms/fieldGroup/fieldControlState.tsx index 7dc69b8b38f0..f6402a512750 100644 --- a/static/app/components/forms/fieldGroup/fieldControlState.tsx +++ b/static/app/components/forms/fieldGroup/fieldControlState.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {FieldGroupProps} from './types'; @@ -13,6 +14,13 @@ export const FieldControlState = styled('div')` ${p => p.flexibleControlStateSize - ? `&:not(:empty) { margin-left: ${p.theme.space.lg} }` - : `width: 24px; margin-left: ${p.theme.space.xs};`}; + ? css` + &:not(:empty) { + margin-left: ${p.theme.space.lg}; + } + ` + : css` + width: 24px; + margin-left: ${p.theme.space.xs}; + `}; `; diff --git a/static/app/components/modals/insightChartModal.tsx b/static/app/components/modals/insightChartModal.tsx index b178295f7830..c8019b8839ec 100644 --- a/static/app/components/modals/insightChartModal.tsx +++ b/static/app/components/modals/insightChartModal.tsx @@ -56,12 +56,12 @@ const Container = styled('div')<{fullscreen?: boolean; height?: number | null}>` const ContentArea = styled('div')<{fullscreen?: boolean}>` ${p => p.fullscreen && - ` - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - `} + css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + `} `; export const modalCss = css` diff --git a/static/app/components/panels/panelBody.tsx b/static/app/components/panels/panelBody.tsx index df16b1ea5236..2056cd724012 100644 --- a/static/app/components/panels/panelBody.tsx +++ b/static/app/components/panels/panelBody.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {textStyles} from 'sentry/styles/text'; @@ -8,7 +9,11 @@ type BaseProps = { }; export const PanelBody = styled('div')` - ${p => p.display && `display: ${p.display};`} + ${p => + p.display && + css` + display: ${p.display}; + `} padding: ${p => (p.withPadding ? p.theme.space.xl : undefined)}; ${textStyles}; `; diff --git a/static/app/components/panels/panelTable.tsx b/static/app/components/panels/panelTable.tsx index 28eb677b1999..e0c7422b3883 100644 --- a/static/app/components/panels/panelTable.tsx +++ b/static/app/components/panels/panelTable.tsx @@ -161,9 +161,11 @@ const Wrapper = styled(Panel, { ${p => p.disableHeaderBorderBottom ? '' - : `&:nth-last-child(n + ${p.hasRows ? p.columns + 1 : 0}) { - border-bottom: 1px solid ${p.theme.tokens.border.primary}; - }`} + : css` + &:nth-last-child(n + ${p.hasRows ? p.columns + 1 : 0}) { + border-bottom: 1px solid ${p.theme.tokens.border.primary}; + } + `} } > ${TableEmptyStateWarning}, > ${LoadingWrapper} { diff --git a/static/app/components/placeholder.tsx b/static/app/components/placeholder.tsx index 7f0ea15492c7..be75b2e86858 100644 --- a/static/app/components/placeholder.tsx +++ b/static/app/components/placeholder.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {SpaceSize} from 'sentry/utils/theme'; @@ -37,10 +38,18 @@ export const Placeholder = styled( border-radius: ${p => p.theme.radius.md}; background-color: ${p => p.error ? p.theme.colors.red100 : p.theme.tokens.background.tertiary}; - ${p => !!p.error && `color: ${p.theme.colors.red200};`} + ${p => + !!p.error && + css` + color: ${p.theme.colors.red200}; + `} width: ${p => p.width ?? '100%'}; height: ${p => p.height ?? '60px'}; ${({shape = 'rect'}) => (shape === 'circle' ? 'border-radius: 100%;' : '')} ${({bottomGutter, theme}) => - bottomGutter ? `margin-bottom: ${theme.space[bottomGutter]};` : ''} + bottomGutter + ? css` + margin-bottom: ${theme.space[bottomGutter]}; + ` + : ''} `; diff --git a/static/app/components/platformPicker.tsx b/static/app/components/platformPicker.tsx index c7628fa15140..f2bd4f7e048d 100644 --- a/static/app/components/platformPicker.tsx +++ b/static/app/components/platformPicker.tsx @@ -1,4 +1,5 @@ import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; import {PlatformIcon} from 'platformicons'; @@ -368,7 +369,12 @@ const PlatformCard = styled( border-radius: 4px; cursor: ${p => (p.loading ? 'default' : 'pointer')}; - ${p => p.selected && p.visibleSelection && `background: ${p.theme.colors.blue100};`} + ${p => + p.selected && + p.visibleSelection && + css` + background: ${p.theme.colors.blue100}; + `} &:hover { background: ${p => p.theme.tokens.background.secondary}; diff --git a/static/app/components/profiling/flamegraph/aggregateFlamegraphTreeTable.tsx b/static/app/components/profiling/flamegraph/aggregateFlamegraphTreeTable.tsx index 59bd1eb93d94..492026a35800 100644 --- a/static/app/components/profiling/flamegraph/aggregateFlamegraphTreeTable.tsx +++ b/static/app/components/profiling/flamegraph/aggregateFlamegraphTreeTable.tsx @@ -1,4 +1,5 @@ import {useCallback, useEffect, useMemo, useState, type MouseEvent} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; @@ -526,7 +527,11 @@ const FrameBar = styled('div')<{withoutBorders?: boolean}>` width: 100%; position: relative; background-color: ${p => p.theme.tokens.background.tertiary}; - ${p => !p.withoutBorders && `border-top: 1px solid ${p.theme.tokens.border.primary};`} + ${p => + !p.withoutBorders && + css` + border-top: 1px solid ${p.theme.tokens.border.primary}; + `} flex: 1 1 100%; `; diff --git a/static/app/components/progressRing.tsx b/static/app/components/progressRing.tsx index 290d7181b0db..ae2b42a07e1e 100644 --- a/static/app/components/progressRing.tsx +++ b/static/app/components/progressRing.tsx @@ -1,5 +1,6 @@ import type {SerializedStyles, Theme} from '@emotion/react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; @@ -165,7 +166,9 @@ const RingBar = styled('circle')<{ transform-origin: 50% 50%; ${p => p.animate && - `transition: - stroke-dashoffset 200ms, - stroke 100ms;`} + css` + transition: + stroke-dashoffset 200ms, + stroke 100ms; + `} `; diff --git a/static/app/components/searchBar/index.tsx b/static/app/components/searchBar/index.tsx index be43b2db0226..e4d7600352a8 100644 --- a/static/app/components/searchBar/index.tsx +++ b/static/app/components/searchBar/index.tsx @@ -1,4 +1,5 @@ import {useCallback, useEffect, useRef, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -106,7 +107,11 @@ const FormWrap = styled('form')` `; const StyledInput = styled(InputGroup.Input)` - ${p => p.width && `width: ${p.width};`} + ${p => + p.width && + css` + width: ${p.width}; + `} `; export const SearchBarTrailingButton = styled(Button)` diff --git a/static/app/components/searchBar/searchDropdown.tsx b/static/app/components/searchBar/searchDropdown.tsx index e918ddb5d53b..ce65e6651afe 100644 --- a/static/app/components/searchBar/searchDropdown.tsx +++ b/static/app/components/searchBar/searchDropdown.tsx @@ -548,7 +548,11 @@ const SearchListItem = styled('li')<{isChild?: boolean; isDisabled?: boolean}>` padding: 4px ${p => p.theme.space.xl}; min-height: ${p => (p.isChild ? '30px' : '36px')}; - ${p => !p.isChild && `border-top: 1px solid ${p.theme.tokens.border.secondary};`} + ${p => + !p.isChild && + css` + border-top: 1px solid ${p.theme.tokens.border.secondary}; + `} ${p => { if (!p.isDisabled) { diff --git a/static/app/components/similarSpectrum.tsx b/static/app/components/similarSpectrum.tsx index 6f51342df310..bafce3b7397e 100644 --- a/static/app/components/similarSpectrum.tsx +++ b/static/app/components/similarSpectrum.tsx @@ -35,5 +35,5 @@ const SpectrumItem = styled('span')` border-radius: 2px; margin: 5px; width: 14px; - ${p => `background-color: ${SIMILARITY_SCORE_COLORS[p.colorIndex]};`}; + background-color: ${p => SIMILARITY_SCORE_COLORS[p.colorIndex]}; `; diff --git a/static/app/components/slider/index.tsx b/static/app/components/slider/index.tsx index 787998ca7541..089b8dc084d0 100644 --- a/static/app/components/slider/index.tsx +++ b/static/app/components/slider/index.tsx @@ -1,5 +1,6 @@ import {Fragment, useCallback, useImperativeHandle, useMemo, useRef} from 'react'; import isPropValid from '@emotion/is-prop-valid'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {useNumberFormatter} from '@react-aria/i18n'; import type {AriaSliderProps, AriaSliderThumbOptions} from '@react-aria/slider'; @@ -364,8 +365,16 @@ const SliderLowerTrack = styled('div')<{disabled: boolean; error: boolean}>` background: ${p => p.theme.tokens.background.accent.vibrant}; pointer-events: none; - ${p => p.error && `background: ${p.theme.tokens.background.danger.vibrant};`} - ${p => p.disabled && `background: ${p.theme.tokens.background.secondary};`} + ${p => + p.error && + css` + background: ${p.theme.tokens.background.danger.vibrant}; + `} + ${p => + p.disabled && + css` + background: ${p.theme.tokens.background.secondary}; + `} `; const SliderTick = styled('div')<{ @@ -387,13 +396,14 @@ const SliderTick = styled('div')<{ ${p => p.inSelection && - `background: ${ - p.disabled + css` + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + background: ${p.disabled ? p.theme.tokens.content.disabled : p.error ? p.theme.tokens.content.danger - : p.theme.tokens.interactive.link.accent.active - };`} + : p.theme.tokens.interactive.link.accent.active}; + `} `; const SliderTickLabel = styled('small')` diff --git a/static/app/components/tables/gridEditable/sortLink.tsx b/static/app/components/tables/gridEditable/sortLink.tsx index bfbe995775dc..51c601afa12c 100644 --- a/static/app/components/tables/gridEditable/sortLink.tsx +++ b/static/app/components/tables/gridEditable/sortLink.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {LocationDescriptorObject} from 'history'; @@ -83,14 +84,24 @@ const StyledLink = styled((props: StyledLinkProps) => { color: inherit; } - ${(p: StyledLinkProps) => (p.align ? `text-align: ${p.align};` : '')} + ${(p: StyledLinkProps) => + p.align + ? css` + text-align: ${p.align}; + ` + : ''} `; const StyledNonLink = styled('div')<{align: Alignments}>` display: block; width: 100%; white-space: nowrap; - ${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')} + ${(p: {align: Alignments}) => + p.align + ? css` + text-align: ${p.align}; + ` + : ''} `; const StyledIconArrow = styled(IconArrow)` diff --git a/static/app/components/tables/gridEditable/styles.tsx b/static/app/components/tables/gridEditable/styles.tsx index 8586382d3bdc..8999daac3c9f 100644 --- a/static/app/components/tables/gridEditable/styles.tsx +++ b/static/app/components/tables/gridEditable/styles.tsx @@ -134,7 +134,14 @@ export const GridHead = styled('thead')<{sticky?: boolean}>` border-top-left-radius: ${p => p.theme.radius.md}; border-top-right-radius: ${p => p.theme.radius.md}; - ${p => (p.sticky ? `position: sticky; top: 0; z-index: ${Z_INDEX_STICKY_HEADER}` : '')} + ${p => + p.sticky + ? css` + position: sticky; + top: 0; + z-index: ${Z_INDEX_STICKY_HEADER}; + ` + : ''} `; export const GridHeadCell = styled('th')<{isFirst: boolean}>` diff --git a/static/app/components/workflowEngine/gridCell/titleCell.tsx b/static/app/components/workflowEngine/gridCell/titleCell.tsx index 5d6dc4386f56..a6702a6932f1 100644 --- a/static/app/components/workflowEngine/gridCell/titleCell.tsx +++ b/static/app/components/workflowEngine/gridCell/titleCell.tsx @@ -1,4 +1,5 @@ import {Fragment} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {LocationDescriptor} from 'history'; import * as qs from 'query-string'; @@ -130,13 +131,13 @@ const TitleBase = styled('div')<{noHover?: boolean}>` ${p => !p.noHover && - ` - &:hover { - ${NameText} { - text-decoration: underline; + css` + &:hover { + ${NameText} { + text-decoration: underline; + } } - } - `} + `} `; const TitleWrapper = TitleBase.withComponent(Link); diff --git a/static/app/stories/view/storyTableOfContents.tsx b/static/app/stories/view/storyTableOfContents.tsx index 8cd45ee7586e..0ee120f418ec 100644 --- a/static/app/stories/view/storyTableOfContents.tsx +++ b/static/app/stories/view/storyTableOfContents.tsx @@ -1,5 +1,6 @@ import {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useLocation} from 'react-router-dom'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex} from '@sentry/scraps/layout'; @@ -316,7 +317,7 @@ const StyledLink = styled('a')<{hasActiveChild: boolean; isActive: boolean}>` ${p => p.isActive && - ` + css` &::before { content: ''; display: block; diff --git a/static/app/views/alerts/list/rules/row.tsx b/static/app/views/alerts/list/rules/row.tsx index 9217bc56a924..1b27f7928402 100644 --- a/static/app/views/alerts/list/rules/row.tsx +++ b/static/app/views/alerts/list/rules/row.tsx @@ -1,4 +1,5 @@ import {useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {ActorAvatar, TeamAvatar} from '@sentry/scraps/avatar'; @@ -390,7 +391,10 @@ const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>` gap: ${p => p.theme.space.xl}; ${p => p.isIssueAlert && - `padding: ${p.theme.space['2xl']} ${p.theme.space.xl}; line-height: 2.4;`} + css` + padding: ${p.theme.space['2xl']} ${p.theme.space.xl}; + line-height: 2.4; + `} `; const AlertNameAndStatus = styled('div')` diff --git a/static/app/views/alerts/rules/metric/details/relatedTransactions.tsx b/static/app/views/alerts/rules/metric/details/relatedTransactions.tsx index e5e76d4f77e2..e766d5548508 100644 --- a/static/app/views/alerts/rules/metric/details/relatedTransactions.tsx +++ b/static/app/views/alerts/rules/metric/details/relatedTransactions.tsx @@ -1,4 +1,5 @@ import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {Location} from 'history'; @@ -146,5 +147,10 @@ const HeaderCell = styled('div')<{align: Alignments}>` display: block; width: 100%; white-space: nowrap; - ${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')} + ${(p: {align: Alignments}) => + p.align + ? css` + text-align: ${p.align}; + ` + : ''} `; diff --git a/static/app/views/detectors/components/detectorListTable/detectorTypeCell.tsx b/static/app/views/detectors/components/detectorListTable/detectorTypeCell.tsx index e567a6390cd1..0917bb05c89f 100644 --- a/static/app/views/detectors/components/detectorListTable/detectorTypeCell.tsx +++ b/static/app/views/detectors/components/detectorListTable/detectorTypeCell.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {DetectorType} from 'sentry/types/workflowEngine/detectors'; @@ -29,7 +30,7 @@ const Type = styled('div')<{disabled: boolean}>` ${p => p.disabled && - ` - color: ${p.theme.tokens.content.disabled}; - `} + css` + color: ${p.theme.tokens.content.disabled}; + `} `; diff --git a/static/app/views/discover/table/queryField.tsx b/static/app/views/discover/table/queryField.tsx index 4342d16e246e..fd9907b11dcb 100644 --- a/static/app/views/discover/table/queryField.tsx +++ b/static/app/views/discover/table/queryField.tsx @@ -1,5 +1,6 @@ import {Component, createRef, type ReactNode} from 'react'; import {withTheme, type CSSObject, type Theme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; @@ -743,7 +744,9 @@ const Container = styled('div')<{ ${p => p.tripleLayout ? 'grid-template-columns: 1fr 2fr;' - : `grid-template-columns: repeat(${p.gridColumns}, 1fr) ${p.error ? 'auto' : ''};`} + : css` + grid-template-columns: repeat(${p.gridColumns}, 1fr) ${p.error ? 'auto' : ''}; + `} gap: ${p => p.theme.space.md}; align-items: center; diff --git a/static/app/views/explore/components/table.tsx b/static/app/views/explore/components/table.tsx index 57cfd1484e71..b74a8dc649fa 100644 --- a/static/app/views/explore/components/table.tsx +++ b/static/app/views/explore/components/table.tsx @@ -1,5 +1,6 @@ import type React from 'react'; import {useCallback, useEffect, useMemo, useRef, type CSSProperties} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {COL_WIDTH_MINIMUM} from 'sentry/components/tables/gridEditable'; @@ -164,7 +165,11 @@ export const TableBodyCell = GridBodyCell; export const TableHead = GridHead; export const TableHeadCell = styled(GridHeadCell)<{align?: Alignments}>` - ${p => p.align && `justify-content: ${p.align};`} + ${p => + p.align && + css` + justify-content: ${p.align}; + `} `; export const TableHeadCellContent = styled('div')<{isFrozen?: boolean | undefined}>` display: flex; diff --git a/static/app/views/explore/conversations/components/messageToolCalls.tsx b/static/app/views/explore/conversations/components/messageToolCalls.tsx index 0e57989a97be..33f3aace1244 100644 --- a/static/app/views/explore/conversations/components/messageToolCalls.tsx +++ b/static/app/views/explore/conversations/components/messageToolCalls.tsx @@ -1,5 +1,4 @@ -import {css} from '@emotion/react'; -import {useTheme} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import {Tag} from '@sentry/scraps/badge'; import {Container, Flex} from '@sentry/scraps/layout'; diff --git a/static/app/views/explore/conversations/components/messagesPanel.tsx b/static/app/views/explore/conversations/components/messagesPanel.tsx index 8aa603205a2e..8de2a320d773 100644 --- a/static/app/views/explore/conversations/components/messagesPanel.tsx +++ b/static/app/views/explore/conversations/components/messagesPanel.tsx @@ -1,4 +1,5 @@ import {useCallback, useMemo, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -154,17 +155,17 @@ const MessageHeader = styled('div')<{role: 'user' | 'assistant'}>` ${p => p.role === 'assistant' && - ` - position: relative; - &::after { - content: ''; - position: absolute; - left: ${p.theme.space.md}; - right: ${p.theme.space.md}; - bottom: 0; - border-bottom: 1px solid ${p.theme.tokens.border.primary}; - } - `} + css` + position: relative; + &::after { + content: ''; + position: absolute; + left: ${p.theme.space.md}; + right: ${p.theme.space.md}; + bottom: 0; + border-bottom: 1px solid ${p.theme.tokens.border.primary}; + } + `} `; const MessageText = styled(Text)` @@ -185,41 +186,41 @@ const MessageBubble = styled('div')<{ ${p => p.role === 'assistant' - ? ` - background-color: ${p.theme.tokens.background.primary}; - &::after { - content: ''; - position: absolute; - inset: 0; - border: 1px solid ${p.theme.tokens.border.primary}; - border-radius: inherit; - box-sizing: border-box; - z-index: 1; - pointer-events: none; - } - ` + ? css` + background-color: ${p.theme.tokens.background.primary}; + &::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid ${p.theme.tokens.border.primary}; + border-radius: inherit; + box-sizing: border-box; + z-index: 1; + pointer-events: none; + } + ` : ''} ${p => p.isClickable && - ` - cursor: pointer; - &:hover::after { - border-color: ${p.theme.tokens.border.accent.moderate}; - border-width: 2px; - } - `} + css` + cursor: pointer; + &:hover::after { + border-color: ${p.theme.tokens.border.accent.moderate}; + border-width: 2px; + } + `} ${p => p.isSelected && - ` - &::after { - border-color: ${p.theme.tokens.focus.default}; - border-width: 2px; - } - &:hover::after { - border-color: ${p.theme.tokens.focus.default}; - } - `} + css` + &::after { + border-color: ${p.theme.tokens.focus.default}; + border-width: 2px; + } + &:hover::after { + border-color: ${p.theme.tokens.focus.default}; + } + `} `; const StyledClippedBox = styled(ClippedBox)` diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index 2ada936b13af..6c3a2390446c 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -46,11 +46,12 @@ export const LogTableRow = styled(TableRow)` ${p => p.isClickable && - ` - &:active { - background-color: ${p.theme.tokens.interactive.transparent.neutral.background.active}; - } - `} + css` + &:active { + background-color: ${p.theme.tokens.interactive.transparent.neutral.background + .active}; + } + `} &:not(:last-child) { border-bottom: 0; @@ -64,28 +65,29 @@ export const LogTableRow = styled(TableRow)` ${p => p.highlighted && - ` - &:not(thead > &) { - background-color: ${p.theme.tokens.background.transparent.warning.muted}; - color: ${p.theme.tokens.content.danger}; - - &:hover { + css` + &:not(thead > &) { background-color: ${p.theme.tokens.background.transparent.warning.muted}; + color: ${p.theme.tokens.content.danger}; + + &:hover { + background-color: ${p.theme.tokens.background.transparent.warning.muted}; + } } - } - `} + `} ${p => p.pinned && - ` - &:not(thead > &) { - background-color: ${p.theme.tokens.background.transparent.accent.muted}; + css` + &:not(thead > &) { + background-color: ${p.theme.tokens.background.transparent.accent.muted}; - &:hover { - background-color: ${p.theme.tokens.interactive.transparent.accent.selected.background.active}; + &:hover { + background-color: ${p.theme.tokens.interactive.transparent.accent.selected + .background.active}; + } } - } - `} + `} &.beforeHoverTime + &.afterHoverTime:before { border-top: 1px solid ${p => p.theme.tokens.border.accent.moderate}; @@ -170,10 +172,10 @@ export const LogTableBody = styled(TableBody)<{ ? '' : p.disableBodyPadding ? '' - : ` - padding-top: ${p.theme.space.md}; - padding-bottom: ${p.theme.space.md}; - `} + : css` + padding-top: ${p.theme.space.md}; + padding-bottom: ${p.theme.space.md}; + `} align-content: start; overflow-x: hidden; overflow-anchor: none; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx index c43cfc767659..1e2114ad2987 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {TabPanels} from '@sentry/scraps/tabs'; @@ -95,15 +96,15 @@ export const StickyTableRow = styled(SimpleTable.Row)<{ }>` ${p => p.sticky && - ` - top: 0px; - z-index: 1; - background: ${p.theme.tokens.background.primary}; - position: sticky; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - margin-right: -15px; - padding-right: calc(15px); - `} + css` + top: 0px; + z-index: 1; + background: ${p.theme.tokens.background.primary}; + position: sticky; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + margin-right: -15px; + padding-right: calc(15px); + `} `; export const DetailsContent = styled(StyledPanel)` diff --git a/static/app/views/explore/metrics/metricsOnboarding.tsx b/static/app/views/explore/metrics/metricsOnboarding.tsx index 88d93d053aa0..3aff8936237c 100644 --- a/static/app/views/explore/metrics/metricsOnboarding.tsx +++ b/static/app/views/explore/metrics/metricsOnboarding.tsx @@ -1,5 +1,6 @@ import {useEffect} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import connectDotsImg from 'sentry-images/spot/performance-connect-dots.svg'; @@ -349,10 +350,10 @@ const Preview = styled('div')<{isUnsupportedPlatform?: boolean}>` ${p => p.isUnsupportedPlatform && - ` - display: flex; - flex-direction: column; - `} + css` + display: flex; + flex-direction: column; + `} `; const Body = styled('div')<{isUnsupportedPlatform?: boolean}>` @@ -363,10 +364,10 @@ const Body = styled('div')<{isUnsupportedPlatform?: boolean}>` ${p => p.isUnsupportedPlatform && - ` - grid-auto-flow: row; - grid-auto-columns: unset; - `} + css` + grid-auto-flow: row; + grid-auto-columns: unset; + `} h4 { margin-bottom: 0; diff --git a/static/app/views/explore/multiQueryMode/queryVisualizations/table.tsx b/static/app/views/explore/multiQueryMode/queryVisualizations/table.tsx index e3b4bc14255d..9a7f82ce68eb 100644 --- a/static/app/views/explore/multiQueryMode/queryVisualizations/table.tsx +++ b/static/app/views/explore/multiQueryMode/queryVisualizations/table.tsx @@ -1,5 +1,6 @@ import {Fragment, useMemo, useRef} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex} from '@sentry/scraps/layout'; @@ -343,7 +344,11 @@ const TableBodyCell = styled(GridBodyCell)` `; const TableHeadCell = styled(GridHeadCell)<{align?: Alignments}>` - ${p => p.align && `justify-content: ${p.align};`} + ${p => + p.align && + css` + justify-content: ${p.align}; + `} font-size: ${p => p.theme.font.size.sm}; height: 33px; `; diff --git a/static/app/views/explore/profiling/landing/styles.tsx b/static/app/views/explore/profiling/landing/styles.tsx index 6d03695e9e18..1e170d1d888b 100644 --- a/static/app/views/explore/profiling/landing/styles.tsx +++ b/static/app/views/explore/profiling/landing/styles.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {HeaderTitleLegend as _HeaderTitleLegend} from 'sentry/components/charts/styles'; @@ -5,7 +6,11 @@ import {Panel} from 'sentry/components/panels/panel'; import {defined} from 'sentry/utils'; export const WidgetContainer = styled(Panel)<{height?: string}>` - ${p => defined(p.height) && `height: ${p.height};`} + ${p => + defined(p.height) && + css` + height: ${p.height}; + `} display: flex; flex-direction: column; padding-top: ${p => p.theme.space.xl}; diff --git a/static/app/views/explore/releases/list/index.tsx b/static/app/views/explore/releases/list/index.tsx index 4ee9a203a018..cf71be5ffbae 100644 --- a/static/app/views/explore/releases/list/index.tsx +++ b/static/app/views/explore/releases/list/index.tsx @@ -1,5 +1,6 @@ import {Fragment, useCallback, useEffect, useMemo} from 'react'; import {forceCheck} from 'react-lazyload'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {keepPreviousData, useQuery} from '@tanstack/react-query'; @@ -675,7 +676,7 @@ function ReleasesHeader() { const ReleasesBodySearch = styled(ExploreBodySearch)<{hasTabs: boolean}>` ${p => p.hasTabs && - ` + css` padding-bottom: 0; @media (min-width: ${p.theme.breakpoints.md}) { diff --git a/static/app/views/explore/tables/tracesTable/styles.tsx b/static/app/views/explore/tables/tracesTable/styles.tsx index 42d9d3f68c8e..9c8acd7d879b 100644 --- a/static/app/views/explore/tables/tracesTable/styles.tsx +++ b/static/app/views/explore/tables/tracesTable/styles.tsx @@ -66,12 +66,19 @@ export const StyledPanelItem = styled(PanelItem)<{ : null}; ${p => p.align === 'center' - ? ` - justify-content: space-around;` + ? css` + justify-content: space-around; + ` : p.align === 'left' || p.align === 'right' - ? `text-align: ${p.align};` + ? css` + text-align: ${p.align}; + ` : undefined} - ${p => p.span && `grid-column: auto / span ${p.span};`} + ${p => + p.span && + css` + grid-column: auto / span ${p.span}; + `} white-space: nowrap; `; diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 49208026cf9a..b6f97f109ab8 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -1,6 +1,7 @@ import type {ReactNode} from 'react'; import {Fragment, useEffect, useState} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Button, LinkButton} from '@sentry/scraps/button'; @@ -344,7 +345,11 @@ const Container = styled('div')<{area?: string}>` flex: 1; min-height: 0; overflow: hidden; - ${p => p.area && `grid-area: ${p.area};`} + ${p => + p.area && + css` + grid-area: ${p.area}; + `} `; const SetupContainer = styled('div')` diff --git a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx index fe4ac70e91d9..cfff88e3c58b 100644 --- a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx +++ b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx @@ -1,5 +1,6 @@ import {useMemo} from 'react'; import {useMatches} from 'react-router-dom'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {DrawerHeader} from '@sentry/scraps/drawer'; @@ -370,7 +371,12 @@ const NoOverflow = styled('span')` const AlignRight = styled('span')<{color?: string}>` text-align: right; width: 100%; - ${p => (p.color ? `color: ${p.color};` : '')} + ${p => + p.color + ? css` + color: ${p.color}; + ` + : ''} `; const AlignCenter = styled('span')` diff --git a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx index 0150fff5527b..0f7a5f3c2c2a 100644 --- a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx +++ b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx @@ -1,4 +1,5 @@ import {useEffect, useMemo} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {DrawerHeader} from '@sentry/scraps/drawer'; @@ -323,7 +324,12 @@ const NoOverflow = styled('span')` const AlignRight = styled('span')<{color?: string}>` text-align: right; width: 100%; - ${p => (p.color ? `color: ${p.color};` : '')} + ${p => + p.color + ? css` + color: ${p.color}; + ` + : ''} `; const ChartContainer = styled('div')` diff --git a/static/app/views/onboarding/components/onboardingStepHeading.tsx b/static/app/views/onboarding/components/onboardingStepHeading.tsx index 5331551d199b..07770d0eb753 100644 --- a/static/app/views/onboarding/components/onboardingStepHeading.tsx +++ b/static/app/views/onboarding/components/onboardingStepHeading.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {motion} from 'framer-motion'; @@ -20,7 +21,7 @@ export const OnboardingStepHeading = styled( ${p => p.step !== undefined && - ` + css` margin-left: calc(-${p.theme.space.xl} - 30px); display: inline-grid; grid-template-columns: max-content auto; diff --git a/static/app/views/performance/newTraceDetails/traceWaterfall.tsx b/static/app/views/performance/newTraceDetails/traceWaterfall.tsx index 60f7eeb4036c..16e42aabbbac 100644 --- a/static/app/views/performance/newTraceDetails/traceWaterfall.tsx +++ b/static/app/views/performance/newTraceDetails/traceWaterfall.tsx @@ -787,6 +787,5 @@ export const TraceGrid = styled('div')<{ ? 'min-content 1fr' : '1fr min-content'}; grid-template-rows: 1fr auto; - - ${p => `border-radius: ${p.theme.radius.md};`} + border-radius: ${p => p.theme.radius.md}; `; diff --git a/static/app/views/preprod/buildComparison/main/sizeCompareSelectionContent.tsx b/static/app/views/preprod/buildComparison/main/sizeCompareSelectionContent.tsx index 0970973a209a..affbf753a309 100644 --- a/static/app/views/preprod/buildComparison/main/sizeCompareSelectionContent.tsx +++ b/static/app/views/preprod/buildComparison/main/sizeCompareSelectionContent.tsx @@ -1,4 +1,5 @@ import {useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {useQuery, useMutation} from '@tanstack/react-query'; @@ -346,7 +347,7 @@ const BuildItemContainer = styled(Flex)<{isSelected: boolean}>` ${p => p.isSelected && - ` + css` background-color: ${p.theme.tokens.background.tertiary}; `} `; diff --git a/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx b/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx index abaf86e8b0da..72e55b167ccf 100644 --- a/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx +++ b/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx @@ -1,5 +1,6 @@ import type {ReactNode} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {LinkButton} from '@sentry/scraps/button'; @@ -391,9 +392,9 @@ function calculateDelta( const MetricValue = styled('span')<{$interactive?: boolean}>` ${p => p.$interactive - ? ` - text-decoration: underline dotted; - cursor: help; - ` + ? css` + text-decoration: underline dotted; + cursor: help; + ` : ''} `; diff --git a/static/app/views/preprod/snapshots/main/snapshotDiffBodies.tsx b/static/app/views/preprod/snapshots/main/snapshotDiffBodies.tsx index ae257326ff32..ce4d7bfe1997 100644 --- a/static/app/views/preprod/snapshots/main/snapshotDiffBodies.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotDiffBodies.tsx @@ -1,4 +1,5 @@ import {memo, type ReactNode, useCallback, useEffect, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; @@ -365,7 +366,12 @@ const ImageSizer = styled('div')<{$aspectRatio?: string; $naturalWidth?: number} width: ${p => (p.$naturalWidth ? `${p.$naturalWidth}px` : '100%')}; max-width: 100%; max-height: ${MAX_IMAGE_HEIGHT}px; - ${p => (p.$aspectRatio ? `aspect-ratio: ${p.$aspectRatio};` : '')} + ${p => + p.$aspectRatio + ? css` + aspect-ratio: ${p.$aspectRatio}; + ` + : ''} display: flex; justify-content: center; overflow: visible; diff --git a/static/app/views/preprod/snapshots/main/snapshotFrames.tsx b/static/app/views/preprod/snapshots/main/snapshotFrames.tsx index 82f369540004..0a034b338ed9 100644 --- a/static/app/views/preprod/snapshots/main/snapshotFrames.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotFrames.tsx @@ -1,4 +1,5 @@ import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Container, Stack} from '@sentry/scraps/layout'; @@ -87,7 +88,7 @@ const SnapshotVariantContainer = styled(Container, { })<{$fillHeight: boolean}>` ${p => p.$fillHeight && - ` + css` display: flex; flex-direction: column; flex: 1 1 0; diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx index ddd1f4d387c6..677cb130d9f6 100644 --- a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx @@ -1,6 +1,7 @@ import type React from 'react'; import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useTheme} from '@emotion/react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -812,13 +813,16 @@ const ColorTrigger = styled('button')<{color: string}>` padding: 0; ${p => p.color === TRANSPARENT_COLOR && - `background-image: linear-gradient( - to top right, - transparent calc(50% - 2px), - ${p.theme.tokens.content.danger} calc(50% - 1px), - ${p.theme.tokens.content.danger} calc(50% + 1px), - transparent calc(50% + 2px) - );`} + css` + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + background-image: linear-gradient( + to top right, + transparent calc(50% - 2px), + ${p.theme.tokens.content.danger} calc(50% - 1px), + ${p.theme.tokens.content.danger} calc(50% + 1px), + transparent calc(50% + 2px) + ); + `} &:hover { border-color: ${p => p.theme.tokens.border.accent}; @@ -857,13 +861,16 @@ const ColorSwatch = styled('button')<{color: string; selected: boolean}>` outline-offset: 1px; ${p => p.color === TRANSPARENT_COLOR && - `background-image: linear-gradient( - to top right, - transparent calc(50% - 1.5px), - ${p.theme.tokens.content.danger} calc(50% - 0.5px), - ${p.theme.tokens.content.danger} calc(50% + 0.5px), - transparent calc(50% + 1.5px) - );`} + css` + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + background-image: linear-gradient( + to top right, + transparent calc(50% - 1.5px), + ${p.theme.tokens.content.danger} calc(50% - 0.5px), + ${p.theme.tokens.content.danger} calc(50% + 0.5px), + transparent calc(50% + 1.5px) + ); + `} `; function ToolbarContainer({ diff --git a/static/app/views/seerExplorer/components/fileDiffViewer.tsx b/static/app/views/seerExplorer/components/fileDiffViewer.tsx index b7e183b8acdf..b0a305671677 100644 --- a/static/app/views/seerExplorer/components/fileDiffViewer.tsx +++ b/static/app/views/seerExplorer/components/fileDiffViewer.tsx @@ -1,4 +1,5 @@ import {Fragment, useMemo, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; @@ -194,8 +195,18 @@ const FileDiffWrapper = styled('div')<{showBorder?: boolean}>` vertical-align: middle; overflow: hidden; background-color: ${p => p.theme.tokens.background.primary}; - ${p => (p.showBorder ? `border: 1px solid ${p.theme.tokens.border.primary};` : '')} - ${p => (p.showBorder ? `border-radius: ${p.theme.radius.md};` : '')} + ${p => + p.showBorder + ? css` + border: 1px solid ${p.theme.tokens.border.primary}; + ` + : ''} + ${p => + p.showBorder + ? css` + border-radius: ${p.theme.radius.md}; + ` + : ''} `; const FileHeader = styled('div')<{collapsible?: boolean}>` @@ -263,10 +274,16 @@ const LineNumber = styled('div')<{lineType: DiffLineType}>` ${p => p.lineType === DiffLineType.ADDED && - `background-color: ${DIFF_COLORS.added}; color: ${p.theme.tokens.content.primary}`}; + css` + background-color: ${DIFF_COLORS.added}; + color: ${p.theme.tokens.content.primary}; + `}; ${p => p.lineType === DiffLineType.REMOVED && - `background-color: ${DIFF_COLORS.removed}; color: ${p.theme.tokens.content.primary}`}; + css` + background-color: ${DIFF_COLORS.removed}; + color: ${p.theme.tokens.content.primary}; + `}; & + & { padding-left: 0; @@ -284,10 +301,16 @@ const DiffContent = styled('div')<{lineType: DiffLineType}>` ${p => p.lineType === DiffLineType.ADDED && - `background-color: ${DIFF_COLORS.addedRow}; color: ${p.theme.tokens.content.primary}`}; + css` + background-color: ${DIFF_COLORS.addedRow}; + color: ${p.theme.tokens.content.primary}; + `}; ${p => p.lineType === DiffLineType.REMOVED && - `background-color: ${DIFF_COLORS.removedRow}; color: ${p.theme.tokens.content.primary}`}; + css` + background-color: ${DIFF_COLORS.removedRow}; + color: ${p.theme.tokens.content.primary}; + `}; &::before { content: ${p => diff --git a/static/app/views/settings/organizationIntegrations/integrationExternalMappings.tsx b/static/app/views/settings/organizationIntegrations/integrationExternalMappings.tsx index 925f1ce99afb..44d2f5a64bec 100644 --- a/static/app/views/settings/organizationIntegrations/integrationExternalMappings.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationExternalMappings.tsx @@ -1,4 +1,5 @@ import {Fragment, useState} from 'react'; +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -224,21 +225,22 @@ const MappingTable = styled(PanelTable)` ${p => p.isEmpty - ? ` - > :not(:nth-child(n + 5)) { - padding: ${p.theme.space.md} ${p.theme.space.xl}; - }` - : ` - > :nth-child(n + 5) { - display: flex; - align-items: center; - padding: ${p.theme.space.lg} ${p.theme.space.xl}; - } + ? css` + > :not(:nth-child(n + 5)) { + padding: ${p.theme.space.md} ${p.theme.space.xl}; + } + ` + : css` + > :nth-child(n + 5) { + display: flex; + align-items: center; + padding: ${p.theme.space.lg} ${p.theme.space.xl}; + } - > * { - padding: ${p.theme.space.md} ${p.theme.space.xl}; - } -`} + > * { + padding: ${p.theme.space.md} ${p.theme.space.xl}; + } + `} > :nth-child(4n) { padding-right: ${p => p.theme.space.md}; diff --git a/static/eslint/eslintPluginSentry/index.ts b/static/eslint/eslintPluginSentry/index.ts index d12845d027f2..1b282f93b0da 100644 --- a/static/eslint/eslintPluginSentry/index.ts +++ b/static/eslint/eslintPluginSentry/index.ts @@ -4,6 +4,7 @@ import {noDigitsInTn} from './no-digits-in-tn'; import {noDynamicTranslations} from './no-dynamic-translations'; import {noFlagComments} from './no-flag-comments'; import {noQueryDataTypeParameters} from './no-query-data-type-parameters'; +import {noRawCssInStyled} from './no-raw-css-in-styled'; import {noStaticTranslations} from './no-static-translations'; import {noStyledShortcut} from './no-styled-shortcut'; import {noUnnecessaryTypeAnnotation} from './no-unnecessary-type-annotation'; @@ -17,6 +18,7 @@ export const rules = { 'no-dynamic-translations': noDynamicTranslations, 'no-flag-comments': noFlagComments, 'no-query-data-type-parameters': noQueryDataTypeParameters, + 'no-raw-css-in-styled': noRawCssInStyled, 'no-static-translations': noStaticTranslations, 'no-styled-shortcut': noStyledShortcut, 'no-unnecessary-type-annotation': noUnnecessaryTypeAnnotation, diff --git a/static/eslint/eslintPluginSentry/no-raw-css-in-styled.spec.ts b/static/eslint/eslintPluginSentry/no-raw-css-in-styled.spec.ts new file mode 100644 index 000000000000..e2c8031bc87c --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-raw-css-in-styled.spec.ts @@ -0,0 +1,130 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {noRawCssInStyled} from './no-raw-css-in-styled'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-raw-css-in-styled', noRawCssInStyled, { + valid: [ + { + code: ` + const Foo = styled('div')\` + \${p => p.active && css\` + color: red; + \`} + \`; + `, + }, + { + code: ` + const Foo = styled('div')\` + \${p => p.active + ? css\`color: red;\` + : css\`color: blue;\` + } + \`; + `, + }, + { + code: ` + const Foo = styled('div')\` + grid-template-areas: \${p => p.vertical + ? \`'a b' 'c d'\` + : \`'a c' 'b d'\` + }; + \`; + `, + }, + { + code: ` + const x = \`border-radius: 4px;\`; + `, + }, + ], + invalid: [ + { + code: ` + const Foo = styled('div')\` + \${p => p.active && \` + color: red; + border-radius: 4px; + \`} + \`; + `, + output: ` + const Foo = styled('div')\` + \${p => p.active && css\` + color: red; + border-radius: 4px; + \`} + \`; + `, + errors: [{messageId: 'useCssTag'}], + }, + { + code: ` + const Foo = styled('div')\` + \${p => p.vertical + ? \` + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + \` + : \` + border-top-left-radius: 0; + border-top-right-radius: 0; + \` + } + \`; + `, + output: ` + const Foo = styled('div')\` + \${p => p.vertical + ? css\` + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + \` + : css\` + border-top-left-radius: 0; + border-top-right-radius: 0; + \` + } + \`; + `, + errors: [{messageId: 'useCssTag'}, {messageId: 'useCssTag'}], + }, + { + code: ` + const Foo = styled(Button)\` + \${p => p.active && \` + padding: 8px; + \`} + \`; + `, + output: ` + const Foo = styled(Button)\` + \${p => p.active && css\` + padding: 8px; + \`} + \`; + `, + errors: [{messageId: 'useCssTag'}], + }, + { + code: ` + const Foo = styled(({active, ...props}: any) => null)\` + \${p => p.active && \` + padding: 8px; + \`} + \`; + `, + output: ` + const Foo = styled(({active, ...props}: any) => null)\` + \${p => p.active && css\` + padding: 8px; + \`} + \`; + `, + errors: [{messageId: 'useCssTag'}], + }, + ], +}); diff --git a/static/eslint/eslintPluginSentry/no-raw-css-in-styled.ts b/static/eslint/eslintPluginSentry/no-raw-css-in-styled.ts new file mode 100644 index 000000000000..0966b45ea116 --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-raw-css-in-styled.ts @@ -0,0 +1,87 @@ +import {AST_NODE_TYPES, ESLintUtils} from '@typescript-eslint/utils'; +import type {TSESTree} from '@typescript-eslint/utils'; + +const CSS_DECLARATION_RE = /[\w-]+\s*:\s*[^;]+;/; + +function tagInvolvesName(node: TSESTree.Node, name: string): boolean { + if (node.type === AST_NODE_TYPES.Identifier) { + return node.name === name; + } + if (node.type === AST_NODE_TYPES.MemberExpression) { + return tagInvolvesName(node.object, name); + } + if (node.type === AST_NODE_TYPES.CallExpression) { + return tagInvolvesName(node.callee, name); + } + return false; +} + +function isStyledOrCssTemplate( + node: TSESTree.Node +): node is TSESTree.TaggedTemplateExpression { + if (node.type !== AST_NODE_TYPES.TaggedTemplateExpression) { + return false; + } + const {tag} = node; + return tagInvolvesName(tag, 'styled') || tagInvolvesName(tag, 'css'); +} + +function isInsideStyledOrCssTemplate(node: TSESTree.Node): boolean { + let current = node.parent; + while (current) { + if (isStyledOrCssTemplate(current)) { + return true; + } + current = current.parent; + } + return false; +} + +function looksLikeCssDeclarations(text: string): boolean { + return CSS_DECLARATION_RE.test(text); +} + +export const noRawCssInStyled = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow raw template literals containing CSS inside styled/css tagged templates — use the css tag instead', + }, + fixable: 'code', + schema: [], + messages: { + useCssTag: + 'Use the `css` tagged template literal instead of a raw template literal for CSS inside styled components.', + }, + }, + create(context) { + return { + TemplateLiteral(node) { + if ( + node.parent?.type === AST_NODE_TYPES.TaggedTemplateExpression && + node.parent.quasi === node + ) { + return; + } + + if (!isInsideStyledOrCssTemplate(node)) { + return; + } + + const raw = node.quasis.map(q => q.value.raw).join('__EXPR__'); + if (!looksLikeCssDeclarations(raw)) { + return; + } + + context.report({ + node, + messageId: 'useCssTag', + fix(fixer) { + return fixer.insertTextBefore(node, 'css'); + }, + }); + }, + }; + }, +}); From b33ecbbf61ff9218c7a93f62e179954dc266c3bd Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Thu, 28 May 2026 12:28:10 -0400 Subject: [PATCH 08/60] feat(api-docs): publish group details endpoint (#116119) Update the docs for the group details endpoint to use the modern drf-spec system. Co-authored-by: Claude --- api-docs/openapi.json | 3 - api-docs/paths/events/issue-details.json | 375 ------------------- src/sentry/issues/endpoints/group_details.py | 124 +++--- 3 files changed, 73 insertions(+), 429 deletions(-) delete mode 100644 api-docs/paths/events/issue-details.json diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 241c4afef517..7fa535924882 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -127,9 +127,6 @@ "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/hashes/": { "$ref": "paths/events/issue-hashes.json" }, - "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/": { - "$ref": "paths/events/issue-details.json" - }, "/api/0/organizations/{organization_id_or_slug}/releases/": { "$ref": "paths/releases/organization-releases.json" }, diff --git a/api-docs/paths/events/issue-details.json b/api-docs/paths/events/issue-details.json deleted file mode 100644 index 7bc87042b242..000000000000 --- a/api-docs/paths/events/issue-details.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "get": { - "tags": ["Events"], - "description": "Return details on an individual issue. This returns the basic stats for the issue (title, last seen, first seen), some overall numbers (number of comments, user reports) as well as the summarized event data.", - "operationId": "Retrieve an Issue", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the issue belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "issue_id", - "in": "path", - "description": "The ID of the issue to retrieve.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "collapse", - "in": "query", - "description": "Fields to remove from the response to improve query performance.", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": ["stats", "lifetime", "base", "unhandled", "filtered"] - } - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "../../components/schemas/issue.json#/IssueDetailed" - }, - "example": { - "activity": [ - { - "data": {}, - "dateCreated": "2018-11-06T21:19:55Z", - "id": "0", - "type": "first_seen", - "user": null - } - ], - "annotations": [], - "assignedTo": null, - "count": "1", - "culprit": "raven.scripts.runner in main", - "firstRelease": { - "authors": [], - "commitCount": 0, - "data": {}, - "dateCreated": "2018-11-06T21:19:55.146Z", - "dateReleased": null, - "deployCount": 0, - "firstEvent": "2018-11-06T21:19:55.271Z", - "lastCommit": null, - "lastDeploy": null, - "lastEvent": "2018-11-06T21:19:55.271Z", - "newGroups": 0, - "owner": null, - "projects": [ - { - "name": "Pump Station", - "slug": "pump-station" - } - ], - "ref": null, - "shortVersion": "1764232", - "url": null, - "version": "17642328ead24b51867165985996d04b29310337" - }, - "firstSeen": "2018-11-06T21:19:55Z", - "hasSeen": false, - "id": "1", - "isBookmarked": false, - "isPublic": false, - "isSubscribed": true, - "lastRelease": null, - "lastSeen": "2018-11-06T21:19:55Z", - "level": "error", - "logger": null, - "metadata": { - "title": "This is an example Python exception" - }, - "numComments": 0, - "participants": [], - "permalink": "https://sentry.io/the-interstellar-jurisdiction/pump-station/issues/1/", - "pluginActions": [], - "pluginContexts": [], - "pluginIssues": [], - "project": { - "id": "2", - "name": "Pump Station", - "slug": "pump-station" - }, - "seenBy": [], - "shareId": null, - "shortId": "PUMP-STATION-1", - "stats": { - "24h": [ - [1541451600.0, 557], - [1541455200.0, 473], - [1541458800.0, 914], - [1541462400.0, 991], - [1541466000.0, 925], - [1541469600.0, 881], - [1541473200.0, 182], - [1541476800.0, 490], - [1541480400.0, 820], - [1541484000.0, 322], - [1541487600.0, 836], - [1541491200.0, 565], - [1541494800.0, 758], - [1541498400.0, 880], - [1541502000.0, 677], - [1541505600.0, 381], - [1541509200.0, 814], - [1541512800.0, 329], - [1541516400.0, 446], - [1541520000.0, 731], - [1541523600.0, 111], - [1541527200.0, 926], - [1541530800.0, 772], - [1541534400.0, 400], - [1541538000.0, 943] - ], - "30d": [ - [1538870400.0, 565], - [1538956800.0, 12862], - [1539043200.0, 15617], - [1539129600.0, 10809], - [1539216000.0, 15065], - [1539302400.0, 12927], - [1539388800.0, 12994], - [1539475200.0, 13139], - [1539561600.0, 11838], - [1539648000.0, 12088], - [1539734400.0, 12338], - [1539820800.0, 12768], - [1539907200.0, 12816], - [1539993600.0, 15356], - [1540080000.0, 10910], - [1540166400.0, 12306], - [1540252800.0, 12912], - [1540339200.0, 14700], - [1540425600.0, 11890], - [1540512000.0, 11684], - [1540598400.0, 13510], - [1540684800.0, 12625], - [1540771200.0, 12811], - [1540857600.0, 13180], - [1540944000.0, 14651], - [1541030400.0, 14161], - [1541116800.0, 12612], - [1541203200.0, 14316], - [1541289600.0, 14742], - [1541376000.0, 12505], - [1541462400.0, 14180] - ] - }, - "status": "unresolved", - "statusDetails": {}, - "subscriptionDetails": null, - "tags": [], - "title": "This is an example Python exception", - "type": "default", - "userCount": 0, - "userReportCount": 0 - } - } - } - }, - "403": { - "description": "Forbidden" - } - }, - "security": [ - { - "auth_token": ["event:read"] - } - ] - }, - "put": { - "tags": ["Events"], - "description": "Updates an individual issue's attributes. Only the attributes submitted are modified.", - "operationId": "Update an Issue", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the issue belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "issue_id", - "in": "path", - "description": "The ID of the group to retrieve.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "The new status for the issues. Valid values are `\"resolved\"`, `\"resolvedInNextRelease\"`, `\"unresolved\"`, and `\"ignored\"`." - }, - "statusDetails": { - "type": "object", - "description": "Additional details about the resolution. Supported values are `\"inRelease\"`, `\"inNextRelease\"`, `\"inCommit\"`, `\"ignoreDuration\"`, `\"ignoreCount\"`, `\"ignoreWindow\"`, `\"ignoreUserCount\"`, and `\"ignoreUserWindow\"`.", - "properties": { - "inNextRelease": { - "type": "boolean", - "description": "Indicates if the issue is resolved in the next release based on the last seen release of that issue." - }, - "inRelease": { - "type": "string", - "description": "The version of the release in which the issue is resolved." - }, - "inCommit": { - "type": "string", - "description": "The commit hash in which the issue is resolved." - } - } - }, - "assignedTo": { - "type": "string", - "description": "The actor id (or username) of the user or team that should be assigned to this issue." - }, - "hasSeen": { - "type": "boolean", - "description": "In case this API call is invoked with a user context this allows changing of the flag that indicates if the user has seen the event." - }, - "isBookmarked": { - "type": "boolean", - "description": "In case this API call is invoked with a user context this allows changing of the bookmark flag." - }, - "isSubscribed": { - "type": "boolean", - "description": "In case this API call is invoked with a user context this allows the user to subscribe to workflow notications for this issue." - }, - "isPublic": { - "type": "boolean", - "description": "Sets the issue to public or private." - } - } - }, - "example": { - "status": "unresolved" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "../../components/schemas/issue.json#/IssueNoStats" - }, - "example": { - "annotations": [], - "assignedTo": null, - "count": "1", - "culprit": "raven.scripts.runner in main", - "firstSeen": "2018-11-06T21:19:55Z", - "hasSeen": false, - "id": "1", - "isBookmarked": false, - "isPublic": false, - "isSubscribed": true, - "lastSeen": "2018-11-06T21:19:55Z", - "level": "error", - "logger": null, - "metadata": { - "title": "This is an example Python exception" - }, - "numComments": 0, - "permalink": "https://sentry.io/the-interstellar-jurisdiction/pump-station/issues/1/", - "project": { - "id": "2", - "name": "Pump Station", - "slug": "pump-station" - }, - "shareId": null, - "shortId": "PUMP-STATION-1", - "status": "unresolved", - "statusDetails": {}, - "subscriptionDetails": null, - "title": "This is an example Python exception", - "type": "default", - "userCount": 0 - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["event:write"] - } - ] - }, - "delete": { - "tags": ["Events"], - "description": "Removes an individual issue.", - "operationId": "Remove an Issue", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the issue belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "issue_id", - "in": "path", - "description": "The ID of the issue to delete.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "202": { - "description": "Success" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["event:admin"] - } - ] - } -} diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index a57c255b2bae..a5b65e7479a7 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -5,11 +5,13 @@ from typing import Any from django.utils import timezone +from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response from sentry import features, tagstore, tsdb from sentry.api import client +from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.helpers.deprecation import deprecated @@ -20,9 +22,21 @@ prep_search, update_groups_with_search_fn, ) +from sentry.api.helpers.group_index.validators import GroupValidator from sentry.api.serializers import GroupSerializer, GroupSerializerSnuba, serialize +from sentry.api.serializers.models.group import BaseGroupSerializerResponse, GroupDetailsResponse from sentry.api.serializers.models.group_stream import get_actions, get_available_issue_plugins from sentry.api.serializers.models.plugin import PluginSerializer +from sentry.apidocs.constants import ( + RESPONSE_ACCEPTED, + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.examples.issue_examples import IssueExamples +from sentry.apidocs.parameters import GlobalParams, IssueParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.integrations.api.serializers.models.external_issue import ExternalIssueSerializer from sentry.integrations.models.external_issue import ExternalIssue @@ -57,12 +71,14 @@ def get_group_global_count(group: Group) -> str: return str(group.times_seen_with_pending) +@extend_schema(tags=["Events"]) @cell_silo_endpoint class GroupDetailsEndpoint(GroupEndpoint): + owner = ApiOwner.ISSUES publish_status = { - "DELETE": ApiPublishStatus.PRIVATE, - "GET": ApiPublishStatus.PRIVATE, - "PUT": ApiPublishStatus.PRIVATE, + "DELETE": ApiPublishStatus.PUBLIC, + "GET": ApiPublishStatus.PUBLIC, + "PUT": ApiPublishStatus.PUBLIC, } enforce_rate_limit = True rate_limits = RateLimitConfig( @@ -134,19 +150,29 @@ def __group_hourly_daily_stats( return hourly_stats, daily_stats + @extend_schema( + operation_id="Retrieve an Issue", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + IssueParams.ISSUES_OR_GROUPS, + IssueParams.ISSUE_ID, + GlobalParams.ENVIRONMENT, + IssueParams.GROUP_DETAILS_EXPAND, + IssueParams.GROUP_DETAILS_COLLAPSE, + ], + responses={ + 200: inline_sentry_response_serializer("GroupDetailsResponse", GroupDetailsResponse), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=IssueExamples.GROUP_DETAILS, + ) @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-details"]) def get(self, request: Request, group: Group) -> Response: """ - Retrieve an Issue - ````````````````` - - Return details on an individual issue. This returns the basic stats for - the issue (title, last seen, first seen), some overall numbers (number - of comments, user reports) as well as the summarized event data. - - :pparam string organization_id_or_slug: the id or slug of the organization. - :pparam string issue_id: the ID of the issue to retrieve. - :auth: required + Return details on an individual issue, including its basic stats, comment + and user-report counts, and a summary of the latest event. """ from sentry.utils import snuba @@ -314,41 +340,29 @@ def get(self, request: Request, group: Group) -> Response: ) raise + @extend_schema( + operation_id="Update an Issue", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + IssueParams.ISSUES_OR_GROUPS, + IssueParams.ISSUE_ID, + ], + request=GroupValidator, + responses={ + 200: inline_sentry_response_serializer( + "GroupUpdateResponse", BaseGroupSerializerResponse + ), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-details"]) def put(self, request: Request, group: Group) -> Response: """ - Update an Issue - ``````````````` - - Updates an individual issue's attributes. Only the attributes submitted + Update an individual issue's attributes. Only the attributes submitted are modified. - - :pparam string issue_id: the ID of the group to retrieve. - :param string status: the new status for the issue. Valid values - are ``"resolved"``, ``resolvedInNextRelease``, - ``"unresolved"``, and ``"ignored"``. - :param map statusDetails: additional details about the resolution. - Valid values are ``"inRelease"``, ``"inNextRelease"``, - ``"inCommit"``, ``"ignoreDuration"``, ``"ignoreCount"``, - ``"ignoreWindow"``, ``"ignoreUserCount"``, and - ``"ignoreUserWindow"``. - :param string assignedTo: the user or team that should be assigned to - this issue. Can be of the form ``""``, - ``"user:"``, ``""``, - ``""``, or ``"team:"``. - :param string assignedBy: ``"suggested_assignee"`` | ``"assignee_selector"`` - :param boolean hasSeen: in case this API call is invoked with a user - context this allows changing of the flag - that indicates if the user has seen the - event. - :param boolean isBookmarked: in case this API call is invoked with a - user context this allows changing of - the bookmark flag. - :param boolean isSubscribed: - :param boolean isPublic: sets the issue to public or private. - :param string substatus: the new substatus for the issues. Valid values - defined in GroupSubStatus. - :auth: required """ try: discard = request.data.get("discard") @@ -395,16 +409,24 @@ def put(self, request: Request, group: Group) -> Response: ) return Response(e.body, status=e.status_code) + @extend_schema( + operation_id="Remove an Issue", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + IssueParams.ISSUES_OR_GROUPS, + IssueParams.ISSUE_ID, + ], + responses={ + 202: RESPONSE_ACCEPTED, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-details"]) def delete(self, request: Request, group: Group) -> Response: """ - Remove an Issue - ``````````````` - - Removes an individual issue. - - :pparam string issue_id: the ID of the issue to delete. - :auth: required + Asynchronously queue an individual issue for deletion. """ from sentry.utils import snuba From 50b25c34281197f0d6ed7c55f17880d8d9b94fce Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 28 May 2026 13:33:49 -0300 Subject: [PATCH 09/60] ref(metrics): Metric detail action menu tweaks (#116292) This PR refactors how the metric samples table renders based off of different sources. For the metrics page nothing has changed, however: * Issue Detail & Trace Waterfall: * Rename view similar spans, to "View connected traces". * Add an "open in explore" button, which is just a shortcut to explore with that metric series open. * Issue Details: * Remove the add filter behavior Closes LOGS-826 --------- Co-authored-by: OpenAI Codex --- .../events/metrics/metricsDrawer.tsx | 2 +- .../events/metrics/metricsSection.tsx | 5 +- .../metrics/metricInfoTabs/aggregatesTab.tsx | 2 + .../metricInfoTabs/metricInfoTabStyles.tsx | 48 ++++++-- .../metricInfoTabs/metricsSamplesTable.tsx | 28 +++-- .../metricsSamplesTableHeader.tsx | 20 ++-- .../metricInfoTabs/metricsSamplesTableRow.tsx | 105 +++++++++++++----- .../metrics/metricPanel/index.spec.tsx | 72 ++++++++++-- static/app/views/explore/metrics/types.tsx | 9 ++ .../newTraceDetails/traceMetrics.tsx | 2 +- 10 files changed, 229 insertions(+), 64 deletions(-) diff --git a/static/app/components/events/metrics/metricsDrawer.tsx b/static/app/components/events/metrics/metricsDrawer.tsx index b4d7d6ab9014..d3021fe0395e 100644 --- a/static/app/components/events/metrics/metricsDrawer.tsx +++ b/static/app/components/events/metrics/metricsDrawer.tsx @@ -64,7 +64,7 @@ export function MetricsDrawer({event, project, group}: MetricsIssueDrawerProps)
- +
diff --git a/static/app/components/events/metrics/metricsSection.tsx b/static/app/components/events/metrics/metricsSection.tsx index da41162f5657..5c3a98e52920 100644 --- a/static/app/components/events/metrics/metricsSection.tsx +++ b/static/app/components/events/metrics/metricsSection.tsx @@ -147,7 +147,10 @@ function MetricsSectionContent({ return ( - + {result.data && result.data.length > NUMBER_ABBREVIATED_METRICS ? (
+ + + + + {submitted && isPending && Comparing…} + + {isError && ( + + + Failed to load comparison + {(error as any)?.responseJSON?.detail + ? `: ${(error as any).responseJSON.detail}` + : ''} + . + + + )} + + {data && ( + + + Summary + + + + + Legacy invoices + + + {data.summary.legacy_count} + + + + + Platform invoices + + + {data.summary.platform_count} + + + + + Legacy total + + + {formatDollars(data.summary.legacy_total_cents)} + + + + + Platform total + + + {formatDollars(data.summary.platform_total_cents)} + + + + + Total delta + + + {formatDollars( + data.summary.legacy_total_cents - data.summary.platform_total_cents + )} + + + + + Rows + + + {data.summary.row_count} + {data.summary.truncated && ( + + (showing top {data.rows.length}) + + )} + + + + + + + + + Rows (sorted by |delta|, biggest first) — queried {data.summary.queried_at} + + + + + + + Legacy + Platform + Δ % + Δ $ + + + + + {rows.length === 0 && ( + + + + )} + {rows.map(row => ( + + + + {formatDollars(row.legacy_amount)}{' '} + + ({row.legacy_invoice_count}) + + + + {formatDollars(row.platform_amount)}{' '} + + ({row.platform_invoice_count}) + + + {formatPercent(row.delta_pct)} + {formatDollars(row.delta_cents)} + + + ))} + +
OrganizationStatus
+ No invoices in this range on either side. +
+ {row.organization_slug ? ( + + {row.organization_slug} + + ) : ( + org#{row.organization_id} + )} + + {row.status} +
+
+
+
+ )} + + ); +} + +const FieldLabel = styled('label')` + font-size: ${p => p.theme.font.size.sm}; + color: ${p => p.theme.tokens.content.secondary}; +`; + +const TruncatedNote = styled(Text)` + margin-left: 8px; +`; + +const Table = styled('table')` + width: 100%; + border-collapse: collapse; + th, + td { + padding: 8px 12px; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + text-align: left; + } + th { + font-weight: 600; + background: ${p => p.theme.tokens.background.secondary}; + } +`; + +// The Table descendant rule above (\`th, td { text-align: left }\`) has +// specificity (0,1,1) which would otherwise beat these single-class +// selectors. Emotion's && doubles the class to (0,2,0) so the right-align +// wins. See https://emotion.sh/docs/styled#styling-any-component. +const RightHeader = styled('th')` + && { + text-align: right; + } +`; + +const RightCell = styled('td')` + && { + text-align: right; + } +`; diff --git a/static/gsAdmin/views/layout.tsx b/static/gsAdmin/views/layout.tsx index 9a59ecf93c12..b3c9cbc68edd 100644 --- a/static/gsAdmin/views/layout.tsx +++ b/static/gsAdmin/views/layout.tsx @@ -79,6 +79,7 @@ export function Layout() { Sentry Employees Billing Plans Invoices + Billing Platform Spike Projection Generation From aca0b60b00b80db155357cf66d29a4acec3bda3c Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Thu, 28 May 2026 14:20:39 -0400 Subject: [PATCH 32/60] feat(explore): space out heat maps y-axis labels (#116341) There are a lot of little quirks about heat maps; this particular PR addresses the fact that the y-axis is seen as categories instead of a scale. Since they're all categories, ECharts doesn't provide nice pretty spacing for it (womp womp). This PR creates that nice spacing so it's a little less hurtful on the eyes and doesn't look overly crowded. What i've done is taken into account he height of the chart and roughly how much space we want between labels and implemented our own intervals for the y-axis label. I've also opted for always showing the max and min labels so the y-axis doesn't look incomplete. i've tried to put comments throughout the code to explain my thought process but if there are better ways yall think of i'm all ears! This is what the changes look like visually: | Before | After | |--------|--------| | image | image | --- .../heatMapWidgetVisualization.tsx | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index a5a00be295d4..ab037277914e 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -32,6 +32,10 @@ import {HeatMap} from './plottables/heatMap'; import type {HeatMapPlottable} from './plottables/heatMapPlottable'; import {HEATMAP_COLORS} from './settings'; +// This is the ECharts default font size for axis labels. We need to use this number to do axis label frequency calculations +// Source: https://echarts.apache.org/en/option.html#yAxis.axisLabel.fontSize +const Y_AXIS_LABEL_FONT_SIZE = 12; + interface HeatMapWidgetVisualizationProps { /** * An single `HeatMap` object to render on the chart, and any number of other compatible Heat Map plottables. @@ -94,6 +98,9 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp return typeof value === 'number' ? value : null; } + const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; + const yAxisBucketCount = heatMapPlottable.heatMapSeries.meta.yAxis.bucketCount; + // Create tooltip formatter const formatTooltip: TooltipFormatterCallback = params => { // Only show the tooltip of the current chart. Otherwise, all tooltips @@ -114,7 +121,6 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp let formattedXValue = ECHARTS_MISSING_DATA_VALUE; const xAxisBucketSize = heatMapPlottable.heatMapSeries.meta.xAxis.bucketSize; - const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; const yAxisUnit = heatMapPlottable?.yAxisValueUnit; const yAxisValueType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE; @@ -237,7 +243,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp { + // show the first and last label + if (index === 0 || index === yAxisBucketCount - 1) { + return true; + } + // we want to make sure that there's going to be ample amount of space between each label: + // chart height / label size = number of labels that will fix with no space between + // chart height / (label size * 3) = number of labels that will fit with space between (label shown every 3 label placements) + // NOTE: this may change as we start putting heat widgets in dashboards with different chart heights + const numFittingLabels = Math.floor( + (chartRef.current?.ele.clientHeight ?? 0) / (Y_AXIS_LABEL_FONT_SIZE * 3) + ); + // show all labels if we can't find the client height + if (numFittingLabels === 0) { + return true; + } + const nthBucketToShow = Math.ceil(yAxisBucketCount / numFittingLabels); + // don't show the third last and second last labels; we want to make sure the last label + // isn't smushed up against another label + if ( + index % nthBucketToShow === 0 && + (nthBucketToShow === 1 || + (index !== yAxisBucketCount - 3 && index !== yAxisBucketCount - 2)) + ) { + return true; + } + return false; + }, + showMinLabel: true, + showMaxLabel: true, formatter: value => { // NOTE: ECharts requires a `"category"` Y-axis for heat maps, but we _know_ that we only support continuous values for the Y-axis. We need to parse the value here. return formatYAxisValue( From e6d6ba47b240938da42eeb494edaca5de9ba87ab Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Thu, 28 May 2026 14:25:12 -0400 Subject: [PATCH 33/60] ref(rpc): log from `_make_rpc_request` (#116408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the request logging from `_make_rpc_requests` to `_make_rpc_request`, which it delegates each individual request to. We were already logging one line per RPC request, and this way we will also log for callers who call `_make_rpc_request` directly. 🤖: The typing got a little nasty and I used Claude Code (Opus 4.6) to resolve the issues. --- src/sentry/utils/snuba_rpc.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/sentry/utils/snuba_rpc.py b/src/sentry/utils/snuba_rpc.py index ed95178ff4a0..9cb33cb8338c 100644 --- a/src/sentry/utils/snuba_rpc.py +++ b/src/sentry/utils/snuba_rpc.py @@ -145,19 +145,6 @@ def _make_rpc_requests( else "EndpointTimeSeries" ) endpoint_names.append(endpoint_name) - logger_extra = { - "rpc_query": json.loads(MessageToJson(request)), - "referrer": request.meta.referrer, - "organization_id": request.meta.organization_id, - "trace_item_type": request.meta.trace_item_type, - "debug": debug is not False, - } - if isinstance(debug, str): - logger_extra["debug_msg"] = debug - logger.info( - f"Running a {endpoint_name} RPC query", # noqa: LOG011 - extra=logger_extra, - ) referrers = [req.meta.referrer for req in requests] assert len(referrers) == len(requests) == len(endpoint_names), ( @@ -172,6 +159,7 @@ def _make_rpc_requests( _make_rpc_request, thread_isolation_scope=sentry_sdk.get_isolation_scope(), thread_current_scope=sentry_sdk.get_current_scope(), + debug=debug, ) with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=10 @@ -368,7 +356,26 @@ def _make_rpc_request( req: SnubaRPCRequest | CreateSubscriptionRequest, thread_isolation_scope: sentry_sdk.Scope | None = None, thread_current_scope: sentry_sdk.Scope | None = None, + debug: str | bool = False, ) -> BaseHTTPResponse: + try: + logger_extra: dict[str, object] = { + "rpc_query": json.loads(MessageToJson(req)), # type: ignore[arg-type] + "referrer": referrer, + "debug": debug is not False, + } + if isinstance(req, ProtobufMessage) and hasattr(req, "meta"): + logger_extra["organization_id"] = req.meta.organization_id + logger_extra["trace_item_type"] = req.meta.trace_item_type + if isinstance(debug, str): + logger_extra["debug_msg"] = debug + logger.info( + f"Running a {endpoint_name} RPC query", # noqa: LOG011 + extra=logger_extra, + ) + except Exception: + logger.exception("Failed to log RPC query") + thread_isolation_scope = ( sentry_sdk.get_isolation_scope() if thread_isolation_scope is None From 9c13de49d345aef14bd754e843862ee71c06e176 Mon Sep 17 00:00:00 2001 From: Sentry Bot Date: Thu, 28 May 2026 11:32:22 -0700 Subject: [PATCH 34/60] ref: bump taskbroker-client to 0.16.0 (#116411) Co-Authored-By: untitaker <837573+untitaker@users.noreply.github.com> --------- Co-authored-by: getsentry-bot <10587625+getsentry-bot@users.noreply.github.com> Co-authored-by: untitaker <837573+untitaker@users.noreply.github.com> Co-authored-by: Markus Unterwaditzer Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9a53dffc2fb..17db53166168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ dependencies = [ "statsd>=3.3.0", "structlog>=22.1.0", "symbolic>=12.14.1", - "taskbroker-client>=0.1.15,<1", + "taskbroker-client>=0.16.0,<1", "tiktoken>=0.8.0", "tokenizers>=0.22.0", "tldextract>=5.1.2", diff --git a/uv.lock b/uv.lock index 5cd7a807d436..5c0800cd5699 100644 --- a/uv.lock +++ b/uv.lock @@ -2427,7 +2427,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.1.15,<1" }, + { name = "taskbroker-client", specifier = ">=0.16.0,<1" }, { name = "tiktoken", specifier = ">=0.8.0" }, { name = "tldextract", specifier = ">=5.1.2" }, { name = "tokenizers", specifier = ">=0.22.0" }, @@ -2821,7 +2821,7 @@ wheels = [ [[package]] name = "taskbroker-client" -version = "0.1.15" +version = "0.16.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "confluent-kafka", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2838,7 +2838,7 @@ dependencies = [ { name = "zstandard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.1.15-py3-none-any.whl", hash = "sha256:62b2c6e2c0956fa4994fef7b4ca13a22e627e7ea838e5e88885cd6d42757eada" }, + { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.16.0-py3-none-any.whl", hash = "sha256:10322e6bb51a70a77ba18498d38f1b751aab4f862983381d4d218ac3b4a6d54c" }, ] [[package]] From 7624998ab351eafad0bee510c83b5440ea99fc2b Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 28 May 2026 11:39:16 -0700 Subject: [PATCH 35/60] feat(api): Type @extend_schema responses via Response[T] stub + linter (#116335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `rest_framework.response.Response` opt-in generic via type stub so mypy can statically validate the body shape against the schema declared in `@extend_schema(responses=...)`. Closes the #115752 drift class — and every variant — at every layer where it can hide. Migration is per-endpoint; no codebase-wide changes. **Response[T] via type stub** A `Generic[T]` stub for `rest_framework.response.Response` with PEP 696 `TypeVar(default=Any)` and an overloaded `__init__` (body-less or with body). Unparameterized `Response(...)` keeps working everywhere with no change. Parameterizing a method's return to `Response[Foo]` opts it into strict mypy checks at the construction call site. No new classes; no new imports. **Structural linter: decorator-T == annotation-T** `sentry.apidocs._check_response_annotation_matches_schema` extracts `T` from `@extend_schema(responses={N: inline_sentry_response_serializer("X", T)})` and from `-> Response[T]`, then asserts they match by AST name. mypy cannot see this link — without the linter, the annotation could be updated without the schema and produce a silently-wrong OpenAPI spec. Wired as a prek hook on endpoint files. **Mypy plugin: hard-error on `Any` body for parameterized Response** `tools.mypy_helpers.plugin` gains a `get_function_hook` for `rest_framework.response.Response`. When the enclosing function returns `Response[Specific]` and the body argument is `Any` (e.g. from an untyped `serialize(...)` call), the plugin emits an error. Naturally scoped — fires only on parameterized usage, leaves the bare-`Response` world untouched. `cast(T, ...)` is the sanctioned escape valve for untyped serializers pending typing. **Pilot — organization_replay_details.py** `get` migrates from `-> Response` to `-> Response[GetReplayResponse]`. No call-site changes needed: `Response({"data": ...})` and `Response(status=404)` both still work via the overloaded `__init__`. The pilot's serializer (`process_raw_response`) already returns `list[ReplayDetailsResponse]` so end-to-end coverage is real, not vacuous. **End-to-end verification** | Drift dimension | Caught by | Verified | |---|---|---| | Body has extra key | mypy core | `Extra key "extra" for TypedDict "GetReplayResponse"` | | Body bare, schema wrapped (#115752 shape) | mypy core | `incompatible type "ReplayDetailsResponse"; expected "GetReplayResponse"` | | Decorator T diverges from annotation T | linter | `decorator declares X, annotation declares Y` | | Body is `Any` in parameterized context | mypy plugin | `Response[X] body is Any — give the source a proper return type` | | `make build-spectacular-docs` output | drf-spectacular | byte-identical (2,878,873 bytes) | Bare `Response(any)` in unmigrated endpoints continues to pass silently — that's the intended migration ramp. --------- Co-authored-by: Claude --- .pre-commit-config.yaml | 7 + .../rest_framework/__init__.pyi | 0 .../rest_framework/response.pyi | 38 +++ ...heck_response_annotation_matches_schema.py | 201 +++++++++++++ .../endpoints/organization_release_meta.py | 2 +- .../endpoints/organization_replay_details.py | 4 +- ...heck_response_annotation_matches_schema.py | 270 ++++++++++++++++++ .../api/endpoints/test_integration_proxy.py | 14 +- tests/tools/mypy_helpers/test_plugin.py | 207 ++++++++++++++ tools/mypy_helpers/plugin.py | 85 ++++++ 10 files changed, 819 insertions(+), 9 deletions(-) create mode 100644 fixtures/stubs-for-mypy/rest_framework/__init__.pyi create mode 100644 fixtures/stubs-for-mypy/rest_framework/response.pyi create mode 100644 src/sentry/apidocs/_check_response_annotation_matches_schema.py create mode 100644 tests/sentry/apidocs/test_check_response_annotation_matches_schema.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23ca21bb1520..90651b70d42e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,6 +59,13 @@ repos: entry: '(^# *mypy: *ignore-errors|^# *type: *ignore|\bno_type_check\b|ignore\[import-untyped\])' language: pygrep types: [python] + - id: check-response-annotation-matches-schema + name: check @extend_schema response T matches Response[T] annotation + entry: .venv/bin/python src/sentry/apidocs/_check_response_annotation_matches_schema.py + language: system + pass_filenames: true + files: ^src/sentry/.*endpoints/.*\.py$ + require_serial: false - id: sort-mypy-weaklist name: sort mypy weaklist entry: python3 -m tools.mypy_helpers.sort_weaklist diff --git a/fixtures/stubs-for-mypy/rest_framework/__init__.pyi b/fixtures/stubs-for-mypy/rest_framework/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/rest_framework/response.pyi b/fixtures/stubs-for-mypy/rest_framework/response.pyi new file mode 100644 index 000000000000..b0b136a79fa1 --- /dev/null +++ b/fixtures/stubs-for-mypy/rest_framework/response.pyi @@ -0,0 +1,38 @@ +from collections.abc import Mapping +from typing import Any, Generic, TypeVar, overload + +from django.template.response import SimpleTemplateResponse + +T = TypeVar("T", default=Any) + +class Response(SimpleTemplateResponse, Generic[T]): + # `data` is typed as Any to mirror DRF's runtime behavior, where the + # attribute is freely reassigned by middleware and exception handlers. + # The TypedDict check still fires at the __init__ call site via the + # `data: T` parameter overload below — that's where the static guarantee + # lives. Typing the attribute as T strictly would break legitimate + # reassignment patterns (e.g. `response.data = ...` in auth flows). + data: Any + exception: bool + content_type: str | None + + @overload + def __init__( + self, + *, + status: int | None = ..., + template_name: str | None = ..., + headers: Mapping[str, str] | None = ..., + exception: bool = ..., + content_type: str | None = ..., + ) -> None: ... + @overload + def __init__( + self, + data: T, + status: int | None = ..., + template_name: str | None = ..., + headers: Mapping[str, str] | None = ..., + exception: bool = ..., + content_type: str | None = ..., + ) -> None: ... diff --git a/src/sentry/apidocs/_check_response_annotation_matches_schema.py b/src/sentry/apidocs/_check_response_annotation_matches_schema.py new file mode 100644 index 000000000000..0fe4bb93ae9c --- /dev/null +++ b/src/sentry/apidocs/_check_response_annotation_matches_schema.py @@ -0,0 +1,201 @@ +"""Lint: assert decorator-T equals annotation-T for typed endpoint responses. + +For each method decorated with + @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. + +Plain `-> Response` annotations are skipped (unmigrated endpoints). Decorator +entries that are canned constants (e.g. `RESPONSE_BAD_REQUEST`) are skipped. + +Invoke as: + python -m sentry.apidocs._check_response_annotation_matches_schema [paths...] + +Exits non-zero on any mismatch. +""" + +from __future__ import annotations + +import ast +import sys +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_PATHS = ( + "src/sentry/api/endpoints", + "src/sentry/replays/endpoints", + "src/sentry/issues/endpoints", + "src/sentry/discover/endpoints", + "src/sentry/feedback/endpoints", + "src/sentry/integrations", + "src/sentry/monitors/endpoints", + "src/sentry/preprod/api/endpoints", + "src/sentry/releases/endpoints", + "src/sentry/uptime/endpoints", +) + +SUCCESS_STATUSES = frozenset(range(200, 300)) + + +@dataclass(frozen=True) +class Mismatch: + path: Path + line: int + cls: str + method: str + status: int + decl: str + annot: str + + def __str__(self) -> str: + return ( + f"{self.path}:{self.line} {self.cls}.{self.method} status={self.status}: " + f"decorator declares `{self.decl}`, annotation declares `{self.annot}`" + ) + + +def _name_of(node: ast.expr) -> str: + """Render `Foo`, `mod.Foo`, `Foo[T]` as a stable string for equality.""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return f"{_name_of(node.value)}.{node.attr}" + if isinstance(node, ast.Subscript): + return f"{_name_of(node.value)}[{_name_of(node.slice)}]" + 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. + """ + if not isinstance(decorator, ast.Call): + 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 {} + 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] = {} + 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 + is_inline = ( + isinstance(func_v, ast.Name) and func_v.id == "inline_sentry_response_serializer" + ) 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] + 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). + """ + 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 + + +def _iter_methods(tree: ast.Module) -> Iterator[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield node.name, item + + +def check_file(path: Path) -> list[Mismatch]: + try: + source = path.read_text() + except OSError: + return [] + try: + tree = ast.parse(source) + except SyntaxError: + return [] + + mismatches: list[Mismatch] = [] + for cls_name, method in _iter_methods(tree): + annot_T = _extract_response_annotation_T(method.returns) + if annot_T is None: + continue + annot_name = _name_of(annot_T) + + decl_by_status: dict[int, ast.expr] = {} + for dec in method.decorator_list: + decl_by_status.update(_extract_decorator_responses(dec)) + + if not decl_by_status: + 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, + ) + ) + return mismatches + + +def iter_files(roots: Iterable[str]) -> Iterator[Path]: + for root in roots: + rp = Path(root) + if rp.is_file(): + if rp.suffix == ".py": + yield rp + continue + yield from rp.rglob("*.py") + + +def main(argv: list[str]) -> int: + roots = argv[1:] if len(argv) > 1 else list(DEFAULT_PATHS) + all_mismatches: list[Mismatch] = [] + for path in iter_files(roots): + all_mismatches.extend(check_file(path)) + for m in all_mismatches: + 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", + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/sentry/releases/endpoints/organization_release_meta.py b/src/sentry/releases/endpoints/organization_release_meta.py index e637ef045e47..3ffbdbc34ef0 100644 --- a/src/sentry/releases/endpoints/organization_release_meta.py +++ b/src/sentry/releases/endpoints/organization_release_meta.py @@ -120,7 +120,7 @@ def get(self, request: Request, organization, version) -> Response: "version": release.version, "versionInfo": expose_version_info(release.version_info), "projects": projects, - "newGroups": sum(project["newGroups"] for project in projects), + "newGroups": sum(project["newGroups"] or 0 for project in projects), "deployCount": release.total_deploys, "commitCount": release.commit_count, "released": release.date_released or release.date_added, diff --git a/src/sentry/replays/endpoints/organization_replay_details.py b/src/sentry/replays/endpoints/organization_replay_details.py index 6a40e0196ea8..16f229d11a27 100644 --- a/src/sentry/replays/endpoints/organization_replay_details.py +++ b/src/sentry/replays/endpoints/organization_replay_details.py @@ -243,7 +243,9 @@ class OrganizationReplayDetailsEndpoint(OrganizationReplayEndpoint): }, examples=ReplayExamples.GET_REPLAY_DETAILS, ) - def get(self, request: Request, organization: Organization, replay_id: str) -> Response: + def get( + self, request: Request, organization: Organization, replay_id: str + ) -> Response[GetReplayResponse]: """ Return details on an individual replay. """ diff --git a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py new file mode 100644 index 000000000000..7bc5d9a55120 --- /dev/null +++ b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from sentry.apidocs._check_response_annotation_matches_schema import ( + Mismatch, + check_file, + main, +) + + +def _run(source: str) -> list[Mismatch]: + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write(source) + path = Path(f.name) + try: + return check_file(path) + finally: + path.unlink() + + +def test_matching_decorator_and_annotation_passes() -> None: + 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 FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_decorator_annotation_mismatch_fires() -> None: + 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 BarResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[BarResponse]: + return Response({"y": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + m = mismatches[0] + assert m.cls == "FooEndpoint" + assert m.method == "get" + assert m.status == 200 + assert m.decl == "FooResponse" + assert m.annot == "BarResponse" + + +def test_unmigrated_endpoint_skipped() -> None: + """Plain `-> Response` (no [T]) is the unmigrated state — must not error.""" + 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 FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_method_without_extend_schema_skipped() -> None: + source = """ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class FooEndpoint: + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +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. + """ + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST +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), + 400: RESPONSE_BAD_REQUEST, + }, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + 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. + """ + 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]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_async_method_works() -> None: + 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 BarResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + async def get(self) -> Response[BarResponse]: + return Response({"y": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == "FooResponse" + assert mismatches[0].annot == "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. + """ + source = """ +from typing import TypedDict +import rest_framework.response +from drf_spectacular.utils import extend_schema +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class BarResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> rest_framework.response.Response[BarResponse]: + return rest_framework.response.Response({"y": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == "FooResponse" + assert mismatches[0].annot == "BarResponse" + + +def test_main_returns_zero_on_clean(tmp_path: Path) -> None: + (tmp_path / "ok.py").write_text( + """ +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 FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + ) + assert main(["prog", str(tmp_path)]) == 0 + + +def test_main_returns_nonzero_on_mismatch(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + (tmp_path / "drift.py").write_text( + """ +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 BarResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[BarResponse]: + return Response({"y": 1}) +""" + ) + assert main(["prog", str(tmp_path)]) == 1 + captured = capsys.readouterr() + assert "FooResponse" in captured.out + assert "BarResponse" in captured.out + assert "mismatch" in captured.err diff --git a/tests/sentry/integrations/api/endpoints/test_integration_proxy.py b/tests/sentry/integrations/api/endpoints/test_integration_proxy.py index 2bb84b8f621f..66980da29ab6 100644 --- a/tests/sentry/integrations/api/endpoints/test_integration_proxy.py +++ b/tests/sentry/integrations/api/endpoints/test_integration_proxy.py @@ -202,7 +202,7 @@ def test_proxy( prepared_request = mock_client.request.call_args.kwargs["prepared_request"] assert prepared_request.url == "https://example.com/api/chat.postMessage" - assert b"".join(proxy_response.streaming_content) == content # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == content assert proxy_response.status_code == mock_response.status_code assert proxy_response.reason_phrase == mock_response.reason assert proxy_response["Content-Type"] == mock_response.headers["Content-Type"] @@ -259,7 +259,7 @@ def test_proxy_with_different_base_url( prepared_request = mock_client.request.call_args.kwargs["prepared_request"] assert prepared_request.url == "https://foobar.example.com/api/chat.postMessage" - assert b"".join(proxy_response.streaming_content) == content # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == content assert proxy_response.status_code == mock_response.status_code assert proxy_response.reason_phrase == mock_response.reason assert proxy_response["Content-Type"] == mock_response.headers["Content-Type"] @@ -749,7 +749,7 @@ def test_proxy_with_non_json_accept_header( proxy_response = self.client.get(self.path, **headers, HTTP_ACCEPT="text/html") assert proxy_response.status_code == 200 - assert b"".join(proxy_response.streaming_content) == content # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == content assert proxy_response["Content-Type"] == "text/html" assert proxy_response["X-Arbitrary"] == "Value" @@ -790,7 +790,7 @@ def test_proxy_with_octet_stream_accept_header( ) assert proxy_response.status_code == 200 - assert b"".join(proxy_response.streaming_content) == content # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == content assert proxy_response["Content-Type"] == "application/octet-stream" @override_settings(SENTRY_SUBNET_SECRET=SENTRY_SUBNET_SECRET, SILO_MODE=SiloMode.CONTROL) @@ -825,7 +825,7 @@ def test_proxy_with_xml_accept_header( proxy_response = self.client.get(self.path, **headers, HTTP_ACCEPT="application/xml") assert proxy_response.status_code == 200 - assert b"".join(proxy_response.streaming_content) == content # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == content assert proxy_response["Content-Type"] == "application/xml" @override_settings(SENTRY_SUBNET_SECRET=SENTRY_SUBNET_SECRET, SILO_MODE=SiloMode.CONTROL) @@ -866,7 +866,7 @@ def iter_then_raise(chunk_size): ) assert proxy_response.status_code == 200 - assert b"".join(proxy_response.streaming_content) == first_chunk # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == first_chunk @override_settings(SENTRY_SUBNET_SECRET=SENTRY_SUBNET_SECRET, SILO_MODE=SiloMode.CONTROL) @patch.object(ExampleIntegration, "get_client") @@ -901,4 +901,4 @@ def iter_then_reset(chunk_size: int) -> Generator[bytes]: proxy_response = self.client.get(self.path, **headers) assert proxy_response.status_code == 200 - assert b"".join(proxy_response.streaming_content) == b"" # type: ignore[attr-defined] + assert b"".join(proxy_response.streaming_content) == b"" diff --git a/tests/tools/mypy_helpers/test_plugin.py b/tests/tools/mypy_helpers/test_plugin.py index 2373907004b1..73908c157e44 100644 --- a/tests/tools/mypy_helpers/test_plugin.py +++ b/tests/tools/mypy_helpers/test_plugin.py @@ -41,6 +41,22 @@ def call_mypy(src: str, *, plugins: list[str] | None = None) -> tuple[int, str]: with open(os.path.join(auth_dir, "model.pyi"), "w") as f: f.write("class AuthenticatedToken: ...") + # stub for rest_framework.response.Response — make it Generic[T] with a + # body-less overload, mirroring fixtures/stubs-for-mypy/rest_framework/ + # response.pyi. The Response-body-Any plugin hook needs this shape to + # see resolved T values. + rf_dir = _fill_init_pyi(tmpdir, "rest_framework") + with open(os.path.join(rf_dir, "response.pyi"), "w") as f: + f.write( + "from typing import Any, Generic, TypeVar, overload\n" + "T = TypeVar('T', default=Any)\n" + "class Response(Generic[T]):\n" + " @overload\n" + " def __init__(self, *, status: int | None = ...) -> None: ...\n" + " @overload\n" + " def __init__(self, data: T, status: int | None = ...) -> None: ...\n" + ) + ret = subprocess.run( ( *(sys.executable, "-m", "mypy"), @@ -310,3 +326,194 @@ def test_base_cache_incr_decr_version_removed() -> None: ret, out = call_mypy(src) assert ret assert out == expected + + +def test_response_any_body_unparameterized_silent() -> None: + """Bare `Response(any_value)` is the existing pattern across the codebase. + The plugin must NOT fire on it — only parameterized usage.""" + src = """\ +from typing import Any +from rest_framework.response import Response + +def untyped() -> Any: ... + +def view() -> Response: + return Response(untyped()) +""" + ret, _ = call_mypy(src) + assert ret == 0 + + +def test_response_any_body_parameterized_errors() -> None: + """`Response[Specific](untyped_call())` is the case the plugin closes — + a parameterized return whose body is `Any` from an untyped serializer.""" + src = """\ +from typing import Any, TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def untyped() -> Any: ... + +def view() -> Response[Shape]: + return Response(untyped()) +""" + ret, out = call_mypy(src) + assert ret, out + assert "body is `Any`" in out + + +def test_response_typed_body_parameterized_silent() -> None: + """Parameterized Response with a properly-typed body passes silently.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def typed() -> Shape: + return {"a": 1} + +def view() -> Response[Shape]: + return Response(typed()) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_cast_escape_valve() -> None: + """`cast(T, untyped_call())` is the sanctioned escape valve for untyped + serializers — it should suppress the plugin error.""" + src = """\ +from typing import Any, TypedDict, cast +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def untyped() -> Any: ... + +def view() -> Response[Shape]: + return Response(cast(Shape, untyped())) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_body_less_parameterized_silent() -> None: + """Error early-returns like `Response(status=404)` must keep working even + when the enclosing function returns `Response[Specific]`.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def view(x: int) -> Response[Shape]: + if x < 0: + return Response(status=404) + return Response({"a": x}) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_extra_key_drift_caught_by_core_mypy() -> None: + """Core mypy (no plugin needed) catches dict-literal drift via TypedDict + inference. Verify the plugin doesn't shadow this check.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def view() -> Response[Shape]: + return Response({"a": 1, "extra": 2}) +""" + ret, out = call_mypy(src) + assert ret, out + assert 'Extra key "extra"' in out + + +def test_response_body_less_with_any_status_kwarg() -> None: + """Regression: a body-less `Response(status=untyped())` call must NOT + spuriously fire the body-Any plugin error. The first positional slot + contains the status value, not a body.""" + src = """\ +from typing import Any, TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def get_status_code() -> Any: + return 404 + +def view() -> Response[Shape]: + return Response(status=get_status_code()) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_data_kwarg_with_any_value() -> None: + """If `Response(data=untyped())` is used (kwarg form for the body), the + plugin should still fire — `data` is the body name.""" + src = """\ +from typing import Any, TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def untyped() -> Any: ... + +def view() -> Response[Shape]: + return Response(data=untyped()) +""" + ret, out = call_mypy(src) + assert ret, out + assert "body is `Any`" in out + + +def test_response_any_body_async_view() -> None: + """Async views return `Coroutine[..., Response[T]]`. The plugin must unwrap + the Coroutine wrapper and still validate the inner Response body type.""" + src = """\ +from typing import Any, TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def untyped() -> Any: ... + +async def view() -> Response[Shape]: + return Response(untyped()) +""" + ret, out = call_mypy(src) + assert ret, out + assert "body is `Any`" in out + + +def test_response_typed_body_async_view_silent() -> None: + """Async view with a properly-typed body passes silently.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class Shape(TypedDict): + a: int + +def typed() -> Shape: + return {"a": 1} + +async def view() -> Response[Shape]: + return Response(typed()) +""" + ret, out = call_mypy(src) + assert ret == 0, out diff --git a/tools/mypy_helpers/plugin.py b/tools/mypy_helpers/plugin.py index 4874ede633b4..1ece1cfd77e8 100644 --- a/tools/mypy_helpers/plugin.py +++ b/tools/mypy_helpers/plugin.py @@ -10,6 +10,7 @@ from mypy.plugin import ( AttributeContext, ClassDefContext, + FunctionContext, FunctionSigContext, MethodContext, MethodSigContext, @@ -187,7 +188,91 @@ def _lazy_service_wrapper_attribute(ctx: AttributeContext, *, attr: str) -> Type return member +def _check_response_body_not_any(ctx: FunctionContext) -> Type: + """Hard-error when `Response[T](body)` is constructed in a context that + expects `T = ` but `body` evaluates to `Any`. + + Strategy: consult the expected return type at the call site. If the + function's declared return is `Response[X]` where `X` is concrete, and the + body argument is `Any`, error. Bottom-up `T = Any` inference is then + visible here because `expected_type` is concrete even though + `default_return_type` may have absorbed `T = Any` from the body. + + Unparameterized `Response(...)` calls (where T defaults to Any via the + stub) are unaffected — their enclosing function's expected type is also + `Response[Any]`. + """ + if not ctx.arg_types or not ctx.arg_types[0]: + return ctx.default_return_type + # Identify whether position 0 is actually the body argument. mypy populates + # `arg_names[0][0]` with the call-site keyword (or `None` for positional). + # When the body-less overload is matched (e.g. `Response(status=x)`), the + # keyword at position 0 is `"status"` — not the body. Treating it as the + # body would spuriously flag `Response(status=untyped_call())` calls. + arg_name = ctx.arg_names[0][0] if ctx.arg_names and ctx.arg_names[0] else None + if arg_name not in (None, "data"): + return ctx.default_return_type + body_type = ctx.arg_types[0][0] + if not isinstance(body_type, AnyType): + return ctx.default_return_type + if body_type.type_of_any in (TypeOfAny.special_form, TypeOfAny.from_error): + return ctx.default_return_type + + # Inspect the surrounding type-checker frame for the expected return type. + # `chk.return_types` is the stack of expected return types for enclosing + # functions. The innermost frame is the function containing this call. + chk = ctx.api.expr_checker.chk # type: ignore[attr-defined] + if not getattr(chk, "return_types", None): + return ctx.default_return_type + expected = chk.return_types[-1] + # Strip Awaitable/Coroutine wrappers (async views return + # `Coroutine[Any, Any, Response[T]]`) and union arms. + expected_instances: list[Instance] = [] + pending: list[Type] = [expected] + _ASYNC_WRAPPERS = frozenset( + { + "typing.Coroutine", + "typing.Awaitable", + "typing.AsyncGenerator", + "typing.AsyncIterator", + "typing.AsyncIterable", + } + ) + while pending: + t = pending.pop() + if isinstance(t, UnionType): + pending.extend(t.items) + elif isinstance(t, Instance): + if t.type.fullname in _ASYNC_WRAPPERS and t.args: + # Coroutine[Y, S, R] → return type R is the last type arg. + # Awaitable/AsyncGenerator/etc. — recurse into all args, the + # `Response[T]` we care about will surface from whichever slot. + pending.extend(t.args) + else: + expected_instances.append(t) + for inst in expected_instances: + if inst.type.fullname != "rest_framework.response.Response": + continue + if not inst.args: + continue + T_expected = inst.args[0] + if isinstance(T_expected, AnyType): + continue + ctx.api.fail( + f"`Response[{format_type(T_expected, ctx.api.options)}]` body is `Any` " + "— give the source a proper return type, or use `cast()` at the call site.", + ctx.context, + ) + break + return ctx.default_return_type + + class SentryMypyPlugin(Plugin): + def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None: + if fullname == "rest_framework.response.Response": + return _check_response_body_not_any + return None + def get_function_signature_hook( self, fullname: str ) -> Callable[[FunctionSigContext], FunctionLike] | None: From b051a971bd5426c2f7130d042dddbba384ebffa5 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 28 May 2026 10:40:05 -0800 Subject: [PATCH 36/60] revert changes to jest config from #116269 (#116416) --- jest.config.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index 81bfa6152746..03c34f6c609b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -323,15 +323,7 @@ const config: Config.InitialOptions = { '/tests/js/setupFramework.ts', ], testMatch: testMatch || ['/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'], - testPathIgnorePatterns: [ - '/tests/sentry/lang/javascript/', - // ESM-style helper scripts (e.g. scripts/genPlatformProductInfo.ts use - // `const __dirname = path.dirname(fileURLToPath(import.meta.url))`) that - // SWC's CJS transform redeclares — collides with Node's module wrapper. - // None of these are tests; keep them out of Jest's discovery entirely. - '/scripts/', - ], - modulePathIgnorePatterns: ['/scripts/'], + testPathIgnorePatterns: ['/tests/sentry/lang/javascript/'], unmockedModulePathPatterns: [ '/node_modules/react', From 4dd447dcc01c26486ec044bdb32965531106b4f9 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Thu, 28 May 2026 14:40:31 -0400 Subject: [PATCH 37/60] chore(typing) Fix typing errors in sentry.ratelimits (#116310) Take sentry.ratelimits and its test off the typing error ignore list. Fixes ENG-6448 --- pyproject.toml | 10 --- src/sentry/middleware/ratelimit.py | 76 +++++++++++-------- .../middleware/test_ratelimit_middleware.py | 4 +- tests/sentry/ratelimits/test_config.py | 3 +- .../ratelimits/test_redis_concurrent.py | 19 +++-- .../sentry/ratelimits/test_sliding_windows.py | 6 +- .../utils/test_above_rate_limit_check.py | 4 +- .../utils/test_enforce_rate_limit.py | 3 +- .../utils/test_get_ratelimit_key.py | 9 ++- 9 files changed, 75 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 17db53166168..8307e6340a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -391,7 +391,6 @@ ignore_missing_imports = true module = [ "sentry.api.endpoints.organization_releases", "sentry.db.postgres.base", - "sentry.middleware.ratelimit", "sentry.release_health.metrics_sessions_v2", "sentry.search.events.builder.errors", "sentry.search.events.builder.metrics", @@ -1064,7 +1063,6 @@ module = [ "sentry.middleware.locale", "sentry.middleware.placeholder", "sentry.middleware.proxy", - "sentry.middleware.ratelimit", "sentry.middleware.security", "sentry.middleware.staff", "sentry.middleware.stats", @@ -2157,14 +2155,6 @@ module = [ "tests.sentry.profiles.test_utils", "tests.sentry.projectoptions.*", "tests.sentry.quotas.*", - "tests.sentry.ratelimits", - "tests.sentry.ratelimits.test_cardinality", - "tests.sentry.ratelimits.test_config", - "tests.sentry.ratelimits.test_redis", - "tests.sentry.ratelimits.test_redis_cluster", - "tests.sentry.ratelimits.test_redis_concurrent", - "tests.sentry.ratelimits.test_sliding_windows", - "tests.sentry.ratelimits.utils.*", "tests.sentry.receivers", "tests.sentry.receivers.test_analytics", "tests.sentry.receivers.test_core", diff --git a/src/sentry/middleware/ratelimit.py b/src/sentry/middleware/ratelimit.py index f9c7797ff7bb..3f73da6612f3 100644 --- a/src/sentry/middleware/ratelimit.py +++ b/src/sentry/middleware/ratelimit.py @@ -4,6 +4,7 @@ import uuid from collections.abc import Callable from math import ceil +from typing import Any, cast import orjson import sentry_sdk @@ -20,6 +21,7 @@ get_rate_limit_value, ) from sentry.ratelimits.config import RateLimitConfig +from sentry.ratelimits.utils import EndpointFunction from sentry.types.ratelimit import RateLimit, RateLimitCategory, RateLimitMeta, RateLimitType from sentry.utils import metrics @@ -101,17 +103,22 @@ def _apply_impersonation_limits(self, rate_limit_config: RateLimitConfig) -> Rat ) def process_view( - self, request: HttpRequest, view_func, view_args, view_kwargs + self, + request: HttpRequest, + view_func: EndpointFunction, + view_args: list[Any], + view_kwargs: dict[str, Any], ) -> HttpResponseBase | None: """Check if the endpoint call will violate.""" with metrics.timer("middleware.ratelimit.process_view", sample_rate=0.01): try: # TODO: put these fields into their own object - request.will_be_rate_limited = False + setattr(request, "will_be_rate_limited", False) if settings.SENTRY_SELF_HOSTED: return None - request.rate_limit_category = None - request.rate_limit_uid = uuid.uuid4().hex + setattr(request, "rate_limit_category", None) + rate_limit_uid = uuid.uuid4().hex + setattr(request, "rate_limit_uid", rate_limit_uid) view_class = getattr(view_func, "view_class", None) if not view_class: return None @@ -138,52 +145,57 @@ def process_view( rate_limit_group = ( rate_limit_config.group if rate_limit_config else RateLimitConfig().group ) - request.rate_limit_key = get_rate_limit_key( - view_func, request, rate_limit_group, rate_limit_config + rate_limit_key = get_rate_limit_key( + view_func, + request, + rate_limit_group, + rate_limit_config, ) - if request.rate_limit_key is None: + setattr(request, "rate_limit_key", rate_limit_key) + if rate_limit_key is None: return None - category_str = request.rate_limit_key.split(":", 1)[0] - request.rate_limit_category = category_str + category_str = rate_limit_key.split(":", 1)[0] + setattr(request, "rate_limit_category", category_str) rate_limit = get_rate_limit_value( - http_method=request.method, + http_method=cast(str, request.method), category=RateLimitCategory(category_str), rate_limit_config=rate_limit_config, ) if rate_limit is None: return None - request.rate_limit_metadata = above_rate_limit_check( - request.rate_limit_key, rate_limit, request.rate_limit_uid, rate_limit_group + rate_limit_metadata = above_rate_limit_check( + rate_limit_key, rate_limit, rate_limit_uid, rate_limit_group ) + setattr(request, "rate_limit_metadata", rate_limit_metadata) # TODO: also limit by concurrent window once we have the data rate_limit_cond = ( - request.rate_limit_metadata.rate_limit_type != RateLimitType.NOT_LIMITED + rate_limit_metadata.rate_limit_type != RateLimitType.NOT_LIMITED if settings.ENFORCE_CONCURRENT_RATE_LIMITS - else request.rate_limit_metadata.rate_limit_type == RateLimitType.FIXED_WINDOW + else rate_limit_metadata.rate_limit_type == RateLimitType.FIXED_WINDOW ) if rate_limit_cond: - request.will_be_rate_limited = True + setattr(request, "will_be_rate_limited", True) logger.info( "sentry.api.rate-limit.exceeded", extra={ - "key": request.rate_limit_key, + "key": rate_limit_key, "url": request.build_absolute_uri(), - "limit": request.rate_limit_metadata.limit, - "window": request.rate_limit_metadata.window, + "limit": rate_limit_metadata.limit, + "window": rate_limit_metadata.window, }, ) - if request.rate_limit_metadata.rate_limit_type == RateLimitType.FIXED_WINDOW: + if rate_limit_metadata.rate_limit_type == RateLimitType.FIXED_WINDOW: response_text = DEFAULT_ERROR_MESSAGE.format( - limit=request.rate_limit_metadata.limit, - window=request.rate_limit_metadata.window, + limit=rate_limit_metadata.limit, + window=rate_limit_metadata.window, ) else: response_text = DEFAULT_CONCURRENT_ERROR_MESSAGE.format( - limit=request.rate_limit_metadata.concurrent_limit + limit=rate_limit_metadata.concurrent_limit ) response_json = { @@ -213,14 +225,18 @@ def process_response( response["X-Sentry-Rate-Limit-Remaining"] = rate_limit_metadata.remaining response["X-Sentry-Rate-Limit-Limit"] = rate_limit_metadata.limit response["X-Sentry-Rate-Limit-Reset"] = rate_limit_metadata.reset_time - response["X-Sentry-Rate-Limit-ConcurrentRemaining"] = ( - rate_limit_metadata.concurrent_remaining - ) - response["X-Sentry-Rate-Limit-ConcurrentLimit"] = ( - rate_limit_metadata.concurrent_limit - ) - if hasattr(request, "rate_limit_key") and hasattr(request, "rate_limit_uid"): - finish_request(request.rate_limit_key, request.rate_limit_uid) + if rate_limit_metadata.concurrent_remaining is not None: + response["X-Sentry-Rate-Limit-ConcurrentRemaining"] = ( + rate_limit_metadata.concurrent_remaining + ) + if rate_limit_metadata.concurrent_limit is not None: + response["X-Sentry-Rate-Limit-ConcurrentLimit"] = ( + rate_limit_metadata.concurrent_limit + ) + rate_limit_key = getattr(request, "rate_limit_key", None) + rate_limit_uid = getattr(request, "rate_limit_uid", None) + if rate_limit_key is not None and rate_limit_uid is not None: + finish_request(rate_limit_key, rate_limit_uid) except Exception: logging.exception("COULD NOT POPULATE RATE LIMIT HEADERS") return response diff --git a/tests/sentry/middleware/test_ratelimit_middleware.py b/tests/sentry/middleware/test_ratelimit_middleware.py index 72447186f8ac..b6e9d8f24666 100644 --- a/tests/sentry/middleware/test_ratelimit_middleware.py +++ b/tests/sentry/middleware/test_ratelimit_middleware.py @@ -1,4 +1,3 @@ -from concurrent.futures import ThreadPoolExecutor from functools import cached_property from time import sleep, time from unittest.mock import MagicMock, patch, sentinel @@ -20,6 +19,7 @@ from sentry.testutils.silo import all_silo_test, assume_test_silo_mode_of from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.users.models.user import User +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor @all_silo_test @@ -608,7 +608,7 @@ def test_request_finishes(self) -> None: def test_concurrent_request_rate_limiting(self) -> None: """test the concurrent rate limiter end to-end""" - with ThreadPoolExecutor(max_workers=4) as executor: + with ContextPropagatingThreadPoolExecutor(max_workers=4) as executor: futures = [] # dispatch more simultaneous requests to the endpoint than the concurrent limit for _ in range(CONCURRENT_RATE_LIMIT + 1): diff --git a/tests/sentry/ratelimits/test_config.py b/tests/sentry/ratelimits/test_config.py index 1ff219305745..e993609f3bcc 100644 --- a/tests/sentry/ratelimits/test_config.py +++ b/tests/sentry/ratelimits/test_config.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import TestCase, mock from sentry.ratelimits.config import RateLimitConfig, get_default_rate_limits_for_group @@ -9,7 +10,7 @@ class TestRateLimitConfig(TestCase): "sentry.ratelimits.config._get_group_defaults", return_value={"blz": {RateLimitCategory.ORGANIZATION: RateLimit(limit=420, window=69)}}, ) - def test_grouping(self, *m) -> None: + def test_grouping(self, *m: Any) -> None: config = RateLimitConfig(group="blz") assert config.get_rate_limit("GET", RateLimitCategory.ORGANIZATION) == RateLimit( limit=420, window=69 diff --git a/tests/sentry/ratelimits/test_redis_concurrent.py b/tests/sentry/ratelimits/test_redis_concurrent.py index ad4b39477a9e..7ac444c0ab5d 100644 --- a/tests/sentry/ratelimits/test_redis_concurrent.py +++ b/tests/sentry/ratelimits/test_redis_concurrent.py @@ -1,11 +1,16 @@ import time import uuid -from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta +from typing import Any from unittest import TestCase, mock -from sentry.ratelimits.concurrent import DEFAULT_MAX_TTL_SECONDS, ConcurrentRateLimiter +from sentry.ratelimits.concurrent import ( + DEFAULT_MAX_TTL_SECONDS, + ConcurrentLimitInfo, + ConcurrentRateLimiter, +) from sentry.testutils.helpers.datetime import freeze_time +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor class ConcurrentLimiterTest(TestCase): @@ -36,11 +41,11 @@ def test_add_and_remove(self) -> None: def test_fails_open(self) -> None: class FakeClient: - def __init__(self, real_client): + def __init__(self, real_client: Any) -> None: self._client = real_client - def __getattr__(self, name): - def fail(*args, **kwargs): + def __getattr__(self, name: str) -> Any: + def fail(*args: Any, **kwargs: Any) -> Any: raise Exception("OH NO") return fail @@ -75,14 +80,14 @@ def test_finish_non_existent(self) -> None: self.backend.finish_request("fasdlfkdsalfkjlasdkjlasdkjflsakj", "fsdlkajflsdakjsda") def test_concurrent(self) -> None: - def do_request(): + def do_request() -> ConcurrentLimitInfo: uid = uuid.uuid4().hex meta = self.backend.start_request("foo", 3, uid) time.sleep(0.2) self.backend.finish_request("foo", uid) return meta - with ThreadPoolExecutor(max_workers=4) as executor: + with ContextPropagatingThreadPoolExecutor(max_workers=4) as executor: futures = [] for _ in range(4): futures.append(executor.submit(do_request)) diff --git a/tests/sentry/ratelimits/test_sliding_windows.py b/tests/sentry/ratelimits/test_sliding_windows.py index e86c4e4fb223..effe8f6de03e 100644 --- a/tests/sentry/ratelimits/test_sliding_windows.py +++ b/tests/sentry/ratelimits/test_sliding_windows.py @@ -9,14 +9,14 @@ @pytest.fixture -def limiter(): +def limiter() -> RedisSlidingWindowRateLimiter: return RedisSlidingWindowRateLimiter() TIMESTAMP_OFFSET = 100 -def test_empty_quota(limiter) -> None: +def test_empty_quota(limiter: RedisSlidingWindowRateLimiter) -> None: quotas = [ Quota( window_seconds=10, @@ -36,7 +36,7 @@ def test_empty_quota(limiter) -> None: assert resp == [GrantedQuota(prefix="foo", granted=0, reached_quotas=quotas)] -def test_basic(limiter) -> None: +def test_basic(limiter: RedisSlidingWindowRateLimiter) -> None: quotas = [ Quota( window_seconds=10, diff --git a/tests/sentry/ratelimits/utils/test_above_rate_limit_check.py b/tests/sentry/ratelimits/utils/test_above_rate_limit_check.py index 7230407127eb..aa2600bef847 100644 --- a/tests/sentry/ratelimits/utils/test_above_rate_limit_check.py +++ b/tests/sentry/ratelimits/utils/test_above_rate_limit_check.py @@ -1,5 +1,4 @@ import uuid -from concurrent.futures import ThreadPoolExecutor from time import sleep, time from unittest import TestCase @@ -9,6 +8,7 @@ from sentry.ratelimits.config import RateLimitConfig from sentry.testutils.helpers.datetime import freeze_time from sentry.types.ratelimit import RateLimit, RateLimitMeta, RateLimitType +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor class RatelimitMiddlewareTest(TestCase): @@ -76,7 +76,7 @@ def do_request() -> RateLimitMeta: finish_request("foo", uid) return meta - with ThreadPoolExecutor(max_workers=4) as executor: + with ContextPropagatingThreadPoolExecutor(max_workers=4) as executor: futures = [] for _ in range(4): futures.append(executor.submit(do_request)) diff --git a/tests/sentry/ratelimits/utils/test_enforce_rate_limit.py b/tests/sentry/ratelimits/utils/test_enforce_rate_limit.py index f6afe253f277..bcfcecd84751 100644 --- a/tests/sentry/ratelimits/utils/test_enforce_rate_limit.py +++ b/tests/sentry/ratelimits/utils/test_enforce_rate_limit.py @@ -2,6 +2,7 @@ from django.urls import re_path from rest_framework import status from rest_framework.permissions import AllowAny +from rest_framework.request import Request from rest_framework.response import Response from sentry.api.base import Endpoint @@ -18,7 +19,7 @@ class RateLimitTestEndpoint(Endpoint): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=1, window=100)}} ) - def get(self, request): + def get(self, request: Request) -> Response: return Response({"ok": True}) diff --git a/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py b/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py index aa856e5402fd..f6fc96d787ad 100644 --- a/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py +++ b/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py @@ -1,7 +1,10 @@ from django.contrib.auth.models import AnonymousUser from django.contrib.sessions.backends.base import SessionBase +from django.http import HttpRequest from django.test import RequestFactory from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response from sentry.api.base import Endpoint from sentry.auth.services.auth import AuthenticatedToken @@ -33,7 +36,7 @@ class APITestEndpoint(Endpoint): }, ) - def get(self, request): + def get(self, request: Request) -> Response: raise NotImplementedError @@ -47,7 +50,7 @@ def setUp(self) -> None: self.rate_limit_config.group if self.rate_limit_config else RateLimitConfig().group ) - def _populate_public_integration_request(self, request) -> None: + def _populate_public_integration_request(self, request: HttpRequest) -> None: install = self.create_sentry_app_installation(organization=self.organization) token = install.api_token @@ -55,7 +58,7 @@ def _populate_public_integration_request(self, request) -> None: request.user = User.objects.get(id=install.sentry_app.proxy_user_id) request.auth = AuthenticatedToken.from_token(token) - def _populate_internal_integration_request(self, request) -> None: + def _populate_internal_integration_request(self, request: HttpRequest) -> None: internal_integration = self.create_internal_integration( name="my_app", organization=self.organization, From 20547acda19127d153fa3826d813411eb20ed5d9 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Thu, 28 May 2026 11:47:52 -0700 Subject: [PATCH 38/60] chore(cell): Renames proxy region metric tag to cell for clarity (#116402) --- src/sentry/hybridcloud/apigateway/proxy.py | 2 +- src/sentry/hybridcloud/apigateway_async/proxy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/hybridcloud/apigateway/proxy.py b/src/sentry/hybridcloud/apigateway/proxy.py index ba5115b736d3..6981fc0ef113 100644 --- a/src/sentry/hybridcloud/apigateway/proxy.py +++ b/src/sentry/hybridcloud/apigateway/proxy.py @@ -124,7 +124,7 @@ def proxy_error_embed_request( def proxy_cell_request(request: HttpRequest, cell: Cell, url_name: str) -> HttpResponseBase: """Take a django request object and proxy it to a cell silo""" - metric_tags = {"region": cell.name, "url_name": url_name} + metric_tags = {"destination_cell": cell.name, "url_name": url_name} circuit_breaker: CircuitBreaker | None = None # TODO(mark) remove rollout options if options.get("apigateway.proxy.circuit-breaker.enabled"): diff --git a/src/sentry/hybridcloud/apigateway_async/proxy.py b/src/sentry/hybridcloud/apigateway_async/proxy.py index 21b5adbc578f..dd44e7a76260 100644 --- a/src/sentry/hybridcloud/apigateway_async/proxy.py +++ b/src/sentry/hybridcloud/apigateway_async/proxy.py @@ -150,7 +150,7 @@ async def proxy_cell_request( url_name: str, ) -> HttpResponseBase: """Take a django request object and proxy it to a cell silo""" - metric_tags = {"region": cell.name, "url_name": url_name} + metric_tags = {"destination_cell": cell.name, "url_name": url_name} target_url = urljoin(cell.address, request.path) content_encoding = request.headers.get("Content-Encoding") From f77192e3920ea7f24c5720233437d20288e97e09 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Thu, 28 May 2026 14:58:55 -0400 Subject: [PATCH 39/60] feat(trace): Add `debug` param to trace item details endpoint (#116151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In general, but especially during our span streaming rollout, it's useful for debugging to be able to see the underlying data for a particular span before it is transformed by the Sentry API and frontend. This PR adds a **superuser-only** `debug` flag on `ProjectTraceItemDetailsEndpoint` that adds the **raw** request and result from EAP's `TraceItemDetails` RPC into the API response's `meta` dictionary. Follows the established pattern from `OrganizationEventsEndpointBase`: https://github.com/getsentry/sentry/blob/49fb05049cf9179001e3a9779e15700cd6cc6b40/src/sentry/api/bases/organization_events.py#L208 https://github.com/getsentry/sentry/blob/49fb05049cf9179001e3a9779e15700cd6cc6b40/src/sentry/api/bases/organization_events.py#L446-L448 This will be hooked up to a superuser-only button on the span details pane in the trace waterfall, similar to how we have the link to see raw transaction JSON for transactions (except limited to superusers) - see https://github.com/getsentry/sentry/pull/116131. _This PR replaces the approach in https://github.com/getsentry/sentry/pull/116127._ --- 🤖: Claude Code (Opus 4.6) used to generate the code, with human editing + review. All words my own. --- .../endpoints/project_trace_item_details.py | 9 +++- src/sentry/utils/snuba_rpc.py | 6 ++- .../test_project_trace_item_details.py | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index 2969bb8ef075..fe655079ddbd 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -373,6 +373,7 @@ def get(request: Request, project: Project, item_id: str) -> Response: end = example_end serialized = serializer.validated_data + debug = request.user.is_superuser and request.GET.get("debug", False) trace_id = serialized.get("trace_id") item_type = serialized.get("item_type") sentry_sdk.set_tag("trace_item_details.item_type", item_type) @@ -416,7 +417,7 @@ def get(request: Request, project: Project, item_id: str) -> Response: trace_id=trace_id, ) - resp = MessageToDict(trace_item_details_rpc(req)) + resp = MessageToDict(trace_item_details_rpc(req, debug=debug)) include_arrays = features.has( "organizations:trace-item-details-array-fields", @@ -439,4 +440,10 @@ def get(request: Request, project: Project, item_id: str) -> Response: "links": serialize_links(resp["attributes"]), } + if debug: + resp_dict["meta"]["debug_info"] = { + "raw_response": resp, + "raw_request": MessageToDict(req), + } + return Response(resp_dict) diff --git a/src/sentry/utils/snuba_rpc.py b/src/sentry/utils/snuba_rpc.py index 9cb33cb8338c..7724afeaf0fc 100644 --- a/src/sentry/utils/snuba_rpc.py +++ b/src/sentry/utils/snuba_rpc.py @@ -273,13 +273,15 @@ def trace_item_stats_rpc(req: TraceItemStatsRequest) -> TraceItemStatsResponse: return response -def trace_item_details_rpc(req: TraceItemDetailsRequest) -> TraceItemDetailsResponse: +def trace_item_details_rpc( + req: TraceItemDetailsRequest, debug: str | bool = False +) -> TraceItemDetailsResponse: """ An RPC which requests all of the details about a specific trace item. For example, you might say "give me all of the attributes for the log with id 1234" or "give me all of the attributes for the span with id 12345 and trace_id 34567" """ - resp = _make_rpc_request("EndpointTraceItemDetails", "v1", req.meta.referrer, req) + resp = _make_rpc_request("EndpointTraceItemDetails", "v1", req.meta.referrer, req, debug=debug) response = TraceItemDetailsResponse() response.ParseFromString(resp.data) return response diff --git a/tests/snuba/api/endpoints/test_project_trace_item_details.py b/tests/snuba/api/endpoints/test_project_trace_item_details.py index 4ce37d52e3f2..1fbdf1544f18 100644 --- a/tests/snuba/api/endpoints/test_project_trace_item_details.py +++ b/tests/snuba/api/endpoints/test_project_trace_item_details.py @@ -574,6 +574,48 @@ def test_attachment(self) -> None: "timestamp": mock.ANY, } + def test_debug_param_as_superuser(self) -> None: + superuser = self.create_user(is_superuser=True) + self.create_member(user=superuser, organization=self.organization) + self.login_as(user=superuser, superuser=True) + + log = self.create_ourlog( + {"body": "debug test", "trace_id": self.trace_uuid}, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + response = self.do_request("logs", item_id, extra_data={"debug": "true"}) + assert response.status_code == 200, response.content + assert "debug_info" in response.data["meta"] + assert "raw_response" in response.data["meta"]["debug_info"] + assert "raw_request" in response.data["meta"]["debug_info"] + assert "itemId" in response.data + assert "attributes" in response.data + + raw_attrs = { + a["name"]: a["value"] + for a in response.data["meta"]["debug_info"]["raw_response"]["attributes"] + } + assert raw_attrs["sentry.body"] == {"valStr": "debug test"} + + def test_debug_param_as_regular_user(self) -> None: + regular_user = self.create_user(is_superuser=False) + self.create_member(user=regular_user, organization=self.organization) + self.login_as(user=regular_user) + + log = self.create_ourlog( + {"body": "debug test", "trace_id": self.trace_uuid}, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + response = self.do_request("logs", item_id, extra_data={"debug": "true"}) + assert response.status_code == 200, response.content + assert "debug_info" not in response.data.get("meta", {}) + def test_with_timestamp(self) -> None: log = self.create_ourlog( { From ed3c249836bd12c3436f2cf2e71d76a91638c98f Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Thu, 28 May 2026 15:06:50 -0400 Subject: [PATCH 40/60] fix(logs): Go back to prefetch query (#114893) As mentioned by tkdodo, we don't want to setup query observers when we don't need to, it's better to just have prefetches only when a hover happens. Reverts the fetch change made in https://github.com/getsentry/sentry/pull/98120 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Cursor --- .../explore/hooks/useTraceItemDetails.tsx | 167 +++++++++--------- .../logs/tables/logsInfiniteTable.spec.tsx | 14 ++ .../explore/logs/tables/logsTableRow.spec.tsx | 21 +-- .../explore/logs/tables/logsTableRow.tsx | 11 +- .../traceApi/useTraceRootEvent.tsx | 4 +- 5 files changed, 119 insertions(+), 98 deletions(-) diff --git a/static/app/views/explore/hooks/useTraceItemDetails.tsx b/static/app/views/explore/hooks/useTraceItemDetails.tsx index b2bace50d265..dba69ea5bedc 100644 --- a/static/app/views/explore/hooks/useTraceItemDetails.tsx +++ b/static/app/views/explore/hooks/useTraceItemDetails.tsx @@ -1,14 +1,14 @@ -import {useState} from 'react'; +import {useRef, useState} from 'react'; import {useHover} from '@react-aria/interactions'; import {captureException} from '@sentry/react'; +import {skipToken, useQuery, useQueryClient} from '@tanstack/react-query'; import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import type {Meta} from 'sentry/types/group'; import {defined} from 'sentry/utils'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {normalizeTimestampToSeconds} from 'sentry/utils/dates'; -import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjectFromId} from 'sentry/utils/useProjectFromId'; import {useProjects} from 'sentry/utils/useProjects'; @@ -133,76 +133,70 @@ export function useTraceItemDetails(props: UseTraceItemDetailsProps) { ? {timestamp: normalizeTimestampToSeconds(props.timestamp)} : normalizeDateTimeParams(selection.datetime); - const queryParams: TraceItemDetailsQueryParams = { - referrer: props.referrer, - ...timeQueryParams, - traceItemType: props.traceItemType, - traceId: props.traceId, - }; - - const result = useApiQuery( - traceItemDetailsQueryKey({ - urlParams: { - organizationSlug: organization.slug, - projectSlug: project?.slug ?? '', - traceItemId: props.traceItemId, - }, - queryParams, + const result = useQuery({ + ...traceItemDetailsApiOptions({ + organizationSlug: organization.slug, + projectSlug: project?.slug ?? '', + traceItemId: props.traceItemId, + traceItemType: props.traceItemType, + referrer: props.referrer, + traceId: props.traceId, + ...timeQueryParams, }), - { - enabled, - retry: shouldRetryHandler, - retryDelay: getRetryDelay, - staleTime: Infinity, - } - ); + enabled, + retry: shouldRetryHandler, + retryDelay: getRetryDelay, + }); return result; } -function traceItemDetailsQueryKey({ - urlParams, - queryParams, -}: { - queryParams: TraceItemDetailsQueryParams; - urlParams: TraceItemDetailsUrlParams; -}): ApiQueryKey { - const query: TraceItemDetailsApiQuery = { - item_type: queryParams.traceItemType, - referrer: queryParams.referrer, - trace_id: queryParams.traceId, - }; - - if (queryParams.timestamp === undefined) { - if (defined(queryParams.statsPeriod)) { - query.statsPeriod = queryParams.statsPeriod; - } - if (defined(queryParams.start)) { - query.start = queryParams.start; - } - if (defined(queryParams.end)) { - query.end = queryParams.end; - } - if (defined(queryParams.utc)) { - query.utc = queryParams.utc; - } - } else { - query.timestamp = queryParams.timestamp; - } +function traceItemDetailsApiOptions({ + organizationSlug, + projectSlug, + traceItemId, + traceItemType, + referrer, + traceId, + timestamp, + statsPeriod, + start, + end, + utc, +}: TraceItemDetailsUrlParams & TraceItemDetailsQueryParams) { + const timeQuery: Partial = + timestamp === undefined + ? { + ...(defined(statsPeriod) ? {statsPeriod} : {}), + ...(defined(start) ? {start} : {}), + ...(defined(end) ? {end} : {}), + ...(defined(utc) ? {utc} : {}), + } + : {timestamp}; - return [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/trace-items/$itemId/', { - path: { - organizationIdOrSlug: urlParams.organizationSlug, - projectIdOrSlug: urlParams.projectSlug, - itemId: urlParams.traceItemId, + return apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/trace-items/$itemId/', + { + path: + organizationSlug && projectSlug && traceItemId + ? { + organizationIdOrSlug: organizationSlug, + projectIdOrSlug: projectSlug, + itemId: traceItemId, + } + : skipToken, + query: { + item_type: traceItemType, + referrer, + trace_id: traceId, + ...timeQuery, }, - }), - {query}, - ]; + staleTime: Infinity, + } + ); } -export function useFetchTraceItemDetailsOnHover({ +export function usePrefetchTraceItemDetailsOnHover({ traceItemId, projectId, traceId, @@ -227,16 +221,13 @@ export function useFetchTraceItemDetailsOnHover({ */ hoverPrefetchDisabled?: boolean; }) { - const [timeoutReached, setTimeoutReached] = useState(false); - const traceItemsResult = useTraceItemDetails({ - projectId, - traceItemId, - traceId, - traceItemType, - referrer, - timestamp, - enabled: timeoutReached, - }); + const organization = useOrganization(); + const {selection} = usePageFilters(); + const project = useProjectFromId({project_id: projectId}); + const projectRef = useRef(project); + projectRef.current = project; + const queryClient = useQueryClient(); + const [traceItemMeta, setTraceItemMeta] = useState(); const {hoverProps} = useHover({ onHoverStart: () => { @@ -244,7 +235,28 @@ export function useFetchTraceItemDetailsOnHover({ clearTimeout(sharedHoverTimeoutRef.current); } sharedHoverTimeoutRef.current = setTimeout(() => { - setTimeoutReached(true); + const currentProject = projectRef.current; + if (!currentProject?.slug) { + return; + } + const timeQueryParams = defined(timestamp) + ? {timestamp: normalizeTimestampToSeconds(timestamp)} + : normalizeDateTimeParams(selection.datetime); + const options = traceItemDetailsApiOptions({ + organizationSlug: organization.slug, + projectSlug: currentProject.slug, + traceItemId, + traceItemType, + referrer, + traceId, + ...timeQueryParams, + }); + queryClient.fetchQuery(options).then( + response => { + setTraceItemMeta(response?.json?.meta); + }, + () => {} + ); }, timeout); }, onHoverEnd: () => { @@ -255,8 +267,5 @@ export function useFetchTraceItemDetailsOnHover({ isDisabled: hoverPrefetchDisabled, }); - return { - hoverProps, - traceItemsResult, - }; + return {hoverProps, traceItemMeta}; } diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx index efd95faf1938..d1cd79545d07 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx @@ -193,6 +193,20 @@ describe('LogsInfiniteTable', () => { method: 'GET', body: {}, }); + + for (const log of mockLogsData) { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/trace-items/${log[OurLogKnownFieldKey.ID]}/`, + method: 'GET', + body: { + itemId: log[OurLogKnownFieldKey.ID], + links: null, + meta: {}, + timestamp: log[OurLogKnownFieldKey.TIMESTAMP], + attributes: [], + }, + }); + } }); function Wrapper({children}: {children: React.ReactNode}) { diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index 66e0d3ea4f64..6f74fbe4efe5 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -256,10 +256,13 @@ describe('logsTableRow', () => { // Prefetching is triggered after the hover timeout expect(rowDetailsMock).toHaveBeenCalledTimes(1); }); + // Flush the .then() callback that reads cached data after prefetch + await act(async () => {}); expect(rowDetailsMock.mock.calls[0]![1].query).toMatchObject({ timestamp: Math.trunc(rowDataTimestamp), }); expect(rowDetailsMock.mock.calls[0]![1].query).not.toHaveProperty('statsPeriod'); + jest.useRealTimers(); }); it('renders row details', async () => { @@ -450,10 +453,6 @@ describe('logsTableRow', () => { expect.objectContaining({enabled: true}) ); - expect(rowDetailsMock).toHaveBeenCalledTimes(0); - expect(stacktraceLinkMock).toHaveBeenCalledTimes(0); - expect(releaseMock).toHaveBeenCalledTimes(0); - // Find the hoverable code path element const codePathElement = await screen.findByTestId('hoverable-code-path'); expect(codePathElement).toBeInTheDocument(); @@ -806,6 +805,12 @@ describe('logsTableRow', () => { const logTableRow = await screen.findByTestId('log-table-row'); expect(logTableRow).toBeInTheDocument(); + const passwordCell = screen.getByTestId('log-table-cell-password'); + const customRuleCell = screen.getByTestId('log-table-cell-not_zzz_not_exact_match'); + + expect(passwordCell).toHaveTextContent('[Filtered]'); + expect(customRuleCell).toHaveTextContent('redacted2'); + expect(traceItemMock).toHaveBeenCalledTimes(0); await userEvent.hover(logTableRow); @@ -813,14 +818,6 @@ describe('logsTableRow', () => { expect(traceItemMock).toHaveBeenCalledTimes(1); }); - const passwordCell = screen.getByTestId('log-table-cell-password'); - const customRuleCell = screen.getByTestId('log-table-cell-not_zzz_not_exact_match'); - - expect(passwordCell).toBeInTheDocument(); - expect(passwordCell).toHaveTextContent('[Filtered]'); - expect(customRuleCell).toBeInTheDocument(); - expect(customRuleCell).toHaveTextContent('redacted2'); - const filteredText = screen.getByText(/Filtered/); await userEvent.hover(filteredText); diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index 3002a5fde086..48dd439682a7 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -1,6 +1,7 @@ import type {ComponentProps, SyntheticEvent} from 'react'; import {Fragment, memo, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useTheme} from '@emotion/react'; +import type {UseQueryResult} from '@tanstack/react-query'; import classNames from 'classnames'; import omit from 'lodash/omit'; @@ -23,8 +24,6 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import {FieldValueType} from 'sentry/utils/fields'; -import type {UseApiQueryResult} from 'sentry/utils/queryClient'; -import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -49,7 +48,7 @@ import type { TraceItemDetailsResponse, TraceItemResponseAttribute, } from 'sentry/views/explore/hooks/useTraceItemDetails'; -import {useFetchTraceItemDetailsOnHover} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {usePrefetchTraceItemDetailsOnHover} from 'sentry/views/explore/hooks/useTraceItemDetails'; import { DEFAULT_TRACE_ITEM_HOVER_TIMEOUT, DEFAULT_TRACE_ITEM_HOVER_TIMEOUT_WITH_AUTO_REFRESH, @@ -319,7 +318,7 @@ export const LogRowContent = memo(function LogRowContent({ const logTimestampSeconds = isRegularLogResponseItem(dataRow) ? getLogRowTimestampMillis(dataRow) / 1000 : null; - const {hoverProps, traceItemsResult} = useFetchTraceItemDetailsOnHover({ + const {hoverProps, traceItemMeta} = usePrefetchTraceItemDetailsOnHover({ traceItemId: rowId, projectId: String(dataRow[OurLogKnownFieldKey.PROJECT_ID]), traceId: String(dataRow[OurLogKnownFieldKey.TRACE_ID]), @@ -345,7 +344,7 @@ export const LogRowContent = memo(function LogRowContent({ projectSlug, meta, project, - traceItemMeta: traceItemsResult?.data?.meta, + traceItemMeta, timestampRelativeTo: embeddedOptions?.replay?.timestampRelativeTo, onReplayTimeClick: embeddedOptions?.replay?.onReplayTimeClick, logStart, @@ -780,7 +779,7 @@ function LogRowDetailsActions({ fullLogDataResult, tableDataRow, }: { - fullLogDataResult: UseApiQueryResult; + fullLogDataResult: UseQueryResult; tableDataRow: LogTableRowItem; }) { const {data, isPending, isError} = fullLogDataResult; diff --git a/static/app/views/performance/newTraceDetails/traceApi/useTraceRootEvent.tsx b/static/app/views/performance/newTraceDetails/traceApi/useTraceRootEvent.tsx index 700ebd12a753..4bd6132e2d5f 100644 --- a/static/app/views/performance/newTraceDetails/traceApi/useTraceRootEvent.tsx +++ b/static/app/views/performance/newTraceDetails/traceApi/useTraceRootEvent.tsx @@ -1,3 +1,5 @@ +import type {UseQueryResult} from '@tanstack/react-query'; + import type {EventTransaction} from 'sentry/types/event'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient'; @@ -23,7 +25,7 @@ type Params = { export type TraceRootEventQueryResults = | UseApiQueryResult - | UseApiQueryResult; + | UseQueryResult; export function useTraceRootEvent({ tree, From 1856fecfa80873fd1ac84ea696f0b8a4ff9460e7 Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Thu, 28 May 2026 15:14:43 -0400 Subject: [PATCH 41/60] tracemetrics(perf): Add client_sample_rate to high-volume metrics (#116308) Reduce metric volume by adding sentry.client_sample_rate sampling --------- Co-authored-by: Cursor --- src/sentry/processing_errors/detection.py | 2 ++ src/sentry/viewer_context.py | 7 ++++++- tests/sentry/test_viewer_context.py | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sentry/processing_errors/detection.py b/src/sentry/processing_errors/detection.py index c84d891a42d1..6fd1568b9745 100644 --- a/src/sentry/processing_errors/detection.py +++ b/src/sentry/processing_errors/detection.py @@ -152,6 +152,7 @@ def _detect_for_config( matching_error_types = sorted( config.handler_cls.error_types.intersection(filter(None, (e.get("type") for e in errors))) ) + # testing internal sampling on high volume metrics, not yet public api sentry_sdk.metrics.count( f"processing_errors.{config.slug}.event_with_errors", 1, @@ -164,6 +165,7 @@ def _detect_for_config( "event_sdk": normalized_sdk_tag_from_event(event.data), "error_type": ",".join(matching_error_types), "project_age_bucket": _project_age_bucket(event.project), + "sentry.client_sample_rate": 0.01, }, ) diff --git a/src/sentry/viewer_context.py b/src/sentry/viewer_context.py index 335f27d611dc..22c8168cc130 100644 --- a/src/sentry/viewer_context.py +++ b/src/sentry/viewer_context.py @@ -156,7 +156,12 @@ def observe_viewer_context_propagation( } ) - sentry_sdk.metrics.count("viewer_context.observation", 1, attributes=attributes) + # testing internal sampling on high volume metrics, not yet public api + attributes_with_sampling: dict[str, str | float] = { + **attributes, + "sentry.client_sample_rate": 0.001, + } + sentry_sdk.metrics.count("viewer_context.observation", 1, attributes=attributes_with_sampling) if expected and ctx is None: log_extra: dict[str, Any] = dict(extra_attributes) if extra_attributes else {} diff --git a/tests/sentry/test_viewer_context.py b/tests/sentry/test_viewer_context.py index 7da7f62ff31c..8e8855b7ca35 100644 --- a/tests/sentry/test_viewer_context.py +++ b/tests/sentry/test_viewer_context.py @@ -170,6 +170,7 @@ def test_no_context_tags_actor_none(self, patch_metrics): "has_user_id": "false", "has_org_id": "false", "expected": "true", + "sentry.client_sample_rate": 0.001, } def test_with_context_tags_actor_and_presence_flags(self, patch_metrics): From a3ff0f4baae48ee670b3c33bf4f29c9f85033bb3 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Thu, 28 May 2026 12:18:00 -0700 Subject: [PATCH 42/60] fix(ACI): Remove openIssues from Detector serializer response (#116414) This isn't used by the front end and is causing timeouts on queries for detectors with a very large number of groups. --- .../apidocs/examples/workflow_engine_examples.py | 6 ------ .../endpoints/serializers/detector_serializer.py | 14 +------------- .../test_organization_detector_details.py | 1 - .../endpoints/test_organization_detector_index.py | 1 - .../serializers/test_detector_serializer.py | 2 -- .../endpoints/test_organization_detector_index.py | 12 ------------ 6 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/sentry/apidocs/examples/workflow_engine_examples.py b/src/sentry/apidocs/examples/workflow_engine_examples.py index cec267775069..655cf6256cfc 100644 --- a/src/sentry/apidocs/examples/workflow_engine_examples.py +++ b/src/sentry/apidocs/examples/workflow_engine_examples.py @@ -250,7 +250,6 @@ class WorkflowEngineExamples: "firstSeen": "2025-07-21T14:46:07.845207Z", "lastSeen": "2026-01-12T16:16:26.355334Z", }, - "openIssues": 0, }, status_codes=["200"], response_only=True, @@ -337,7 +336,6 @@ class WorkflowEngineExamples: "firstSeen": "2026-01-09T18:48:15.250134Z", "lastSeen": "2026-01-09T18:48:15.250134Z", }, - "openIssues": 0, }, status_codes=["200"], response_only=True, @@ -394,7 +392,6 @@ class WorkflowEngineExamples: "firstSeen": "2026-01-08T21:00:59.737468Z", "lastSeen": "2026-01-08T21:23:45.723716Z", }, - "openIssues": 100, }, { "id": "234567891", @@ -455,7 +452,6 @@ class WorkflowEngineExamples: }, "enabled": True, "latestGroup": None, - "openIssues": 0, }, { "id": "1234567", @@ -519,7 +515,6 @@ class WorkflowEngineExamples: "config": {"detectionType": "static", "comparisonDelta": None}, "enabled": True, "latestGroup": None, - "openIssues": 0, }, ], status_codes=["200"], @@ -832,7 +827,6 @@ class WorkflowEngineExamples: "config": {"detectionType": "static"}, "enabled": True, "latestGroup": None, - "openIssues": 0, }, status_codes=["201"], response_only=True, diff --git a/src/sentry/workflow_engine/endpoints/serializers/detector_serializer.py b/src/sentry/workflow_engine/endpoints/serializers/detector_serializer.py index 10c7238c5326..9923df83a01a 100644 --- a/src/sentry/workflow_engine/endpoints/serializers/detector_serializer.py +++ b/src/sentry/workflow_engine/endpoints/serializers/detector_serializer.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import Any, TypedDict -from django.db.models import Count from drf_spectacular.utils import extend_schema_serializer from sentry.api.serializers import Serializer, register, serialize @@ -11,7 +10,7 @@ from sentry.api.serializers.models.group import SimpleGroupSerializer from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case from sentry.grouping.grouptype import ErrorGroupType -from sentry.models.group import Group, GroupStatus +from sentry.models.group import Group from sentry.models.options.project_option import ProjectOption from sentry.types.actor import Actor from sentry.workflow_engine.models import ( @@ -46,7 +45,6 @@ class DetectorSerializerResponse(DetectorSerializerResponseOptional): conditionGroup: dict[str, Any] | None config: dict[str, Any] enabled: bool - openIssues: int @register(Detector) @@ -144,14 +142,6 @@ def get_attrs( for option in project_options_list: configs[option.project_id][option.key] = option.value - open_issues_counts = dict( - DetectorGroup.objects.filter(detector__in=item_list) - .filter(group__status=GroupStatus.UNRESOLVED) - .values("detector_id") - .annotate(open_issues_count=Count("group")) - .values_list("detector_id", "open_issues_count") - ) - # Serialize owners owners = [item.owner for item in item_list if item.owner] owners_serialized = serialize( @@ -173,7 +163,6 @@ def get_attrs( }, ) attrs[item]["latest_group"] = latest_groups_map.get(item.id) - attrs[item]["open_issues_count"] = open_issues_counts.get(item.id, 0) if item.id in configs: attrs[item]["config"] = configs[item.id] else: @@ -216,5 +205,4 @@ def serialize( "alertRuleId": alert_rule_mapping.get("alert_rule_id"), "ruleId": alert_rule_mapping.get("rule_id"), "latestGroup": attrs.get("latest_group"), - "openIssues": attrs.get("open_issues_count", 0), } diff --git a/tests/sentry/monitors/endpoints/test_organization_detector_details.py b/tests/sentry/monitors/endpoints/test_organization_detector_details.py index 45d7588175a1..be46b28eafd9 100644 --- a/tests/sentry/monitors/endpoints/test_organization_detector_details.py +++ b/tests/sentry/monitors/endpoints/test_organization_detector_details.py @@ -88,7 +88,6 @@ def test_get_monitor_incident_detector_details(self) -> None: "alertRuleId": None, "ruleId": None, "latestGroup": None, - "openIssues": 0, } def test_update_monitor_incident_detector(self) -> None: diff --git a/tests/sentry/monitors/endpoints/test_organization_detector_index.py b/tests/sentry/monitors/endpoints/test_organization_detector_index.py index 1c11c479bab6..6076766f09af 100644 --- a/tests/sentry/monitors/endpoints/test_organization_detector_index.py +++ b/tests/sentry/monitors/endpoints/test_organization_detector_index.py @@ -78,7 +78,6 @@ def test_list_monitor_incident_detectors(self) -> None: "alertRuleId": None, "ruleId": None, "latestGroup": None, - "openIssues": 0, } diff --git a/tests/sentry/workflow_engine/endpoints/serializers/test_detector_serializer.py b/tests/sentry/workflow_engine/endpoints/serializers/test_detector_serializer.py index 68601a5e7938..d5b669a8c830 100644 --- a/tests/sentry/workflow_engine/endpoints/serializers/test_detector_serializer.py +++ b/tests/sentry/workflow_engine/endpoints/serializers/test_detector_serializer.py @@ -50,7 +50,6 @@ def test_serialize_simple(self) -> None: "alertRuleId": None, "ruleId": None, "latestGroup": None, - "openIssues": 0, } def test_serialize_full(self) -> None: @@ -183,7 +182,6 @@ def test_serialize_full(self) -> None: "alertRuleId": None, "ruleId": None, "latestGroup": mock.ANY, - "openIssues": 1, } def test_serialize_latest_group(self) -> None: diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py index 42414c0e70d1..ca29c3d0048b 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py @@ -80,11 +80,6 @@ def test_simple(self) -> None: [self.error_detector, self.issue_stream_detector, detector, detector_2] ) - # Verify openIssues field is present in serialized response - for detector_data in response.data: - assert "openIssues" in detector_data - assert isinstance(detector_data["openIssues"], int) - # Verify X-Hits header is present and correct assert "X-Hits" in response hits = int(response["X-Hits"]) @@ -361,13 +356,6 @@ def test_sort_by_open_issues(self) -> None: actual_order = [d["name"] for d in response.data] assert actual_order == expected_order - # Verify open issues counts in serialized response - open_issues_by_name = {d["name"]: d["openIssues"] for d in response.data} - assert open_issues_by_name[detector_1.name] == 2 - assert open_issues_by_name[detector_2.name] == 1 - assert open_issues_by_name[detector_3.name] == 3 - assert open_issues_by_name[detector_4.name] == 0 - # Test ascending sort (least open issues first) response2 = self.get_success_response( self.organization.slug, qs_params={"project": self.project.id, "sortBy": "openIssues"} From 88c0dca3a5b8293db9f20358c2173257226b7451 Mon Sep 17 00:00:00 2001 From: Max Topolsky <30879163+mtopo27@users.noreply.github.com> Date: Thu, 28 May 2026 15:19:09 -0400 Subject: [PATCH 43/60] feat(snapshots): Add viewport width support to snapshot testing framework (#115887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot tests can now specify a viewport width to test responsive breakpoints. CSS media queries fire at the requested width, and `#root` switches to `display: block` so percentage-based layouts fill the viewport realistically. **New `it.snapshot` API** The third argument now accepts a `SnapshotOptions` object with an optional `viewport` field. Viewport can be a named theme breakpoint (`'sm'`, `'lg'`), a pixel number, or `{width, height}`. Breakpoint values are derived from the theme tokens so they stay in sync automatically. The legacy `Record` metadata form is still supported — existing tests are unchanged. ```tsx it.snapshot('stacked-buttons', () => , { viewport: 'sm' }); it.snapshot.breakpoints(['xs', 'sm', 'lg'], 'controls', () => ); ``` **Viewport-aware rendering** When a viewport is specified, Playwright's browser context is created with that viewport size and `#root` uses `display: block` instead of `inline-block`. Viewport dimensions are written into snapshot metadata JSON (`viewport_width`, `viewport_height`) for downstream consumption — no Python changes needed since the manifest uses `extra = "allow"`. --------- Co-authored-by: Claude Opus 4 Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .../snapshots/snapshot-framework.ts | 81 +++++++++++++++++-- tests/js/sentry-test/snapshots/snapshot.ts | 19 ++++- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/tests/js/sentry-test/snapshots/snapshot-framework.ts b/tests/js/sentry-test/snapshots/snapshot-framework.ts index 6284fa4321ff..6490cd430efb 100644 --- a/tests/js/sentry-test/snapshots/snapshot-framework.ts +++ b/tests/js/sentry-test/snapshots/snapshot-framework.ts @@ -1,8 +1,21 @@ import type {ReactElement} from 'react'; +// eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access +import {lightTheme} from 'sentry/utils/theme/theme'; + import {closeBrowser, takeSnapshot} from './snapshot'; import type {SnapshotTestMetadata} from './snapshot-image-metadata'; +const BREAKPOINT_WIDTHS = Object.fromEntries( + Object.entries(lightTheme.breakpoints).map(([k, v]) => [k, parseInt(v, 10)]) +) as Record; + +type BreakpointName = keyof typeof BREAKPOINT_WIDTHS; + +type SnapshotViewport = BreakpointName | number | {width: number; height?: number}; + +type SnapshotTestInput = SnapshotTestMetadata & {viewport?: SnapshotViewport}; + interface SnapshotDetails { displayName: string; fileSlug: string; @@ -41,12 +54,40 @@ function parseSnapshotDetails(testName: string, fallbackName: string): SnapshotD return {displayName, fileSlug, group, theme: themeMatch?.[1]}; } +function resolveViewport(input: SnapshotViewport): { + label: string; + width: number; + height?: number; +} { + if (typeof input === 'string') { + const width = BREAKPOINT_WIDTHS[input]; + if (width <= 0) { + throw new Error( + `Breakpoint "${input}" resolves to ${width}px — too small for a snapshot` + ); + } + return {width, label: input}; + } + if (typeof input === 'number') { + const name = Object.entries(BREAKPOINT_WIDTHS).find(([, w]) => w === input)?.[0]; + return {width: input, label: name ?? `${input}w`}; + } + const name = Object.entries(BREAKPOINT_WIDTHS).find(([, w]) => w === input.width)?.[0]; + return {width: input.width, height: input.height, label: name ?? `${input.width}w`}; +} + function snapshotTest( name: string, renderFn: () => ReactElement, - metadata: SnapshotTestMetadata = {} + metadata: SnapshotTestInput = {} ): void { - test(`snapshot: ${name}`, async () => { + const {viewport: viewportInput, ...restMetadata} = metadata; + + const resolved = viewportInput ? resolveViewport(viewportInput) : undefined; + + const suffix = resolved ? ' @' + resolved.label : ''; + + test('snapshot: ' + name + suffix, async () => { const {testPath, currentTestName} = expect.getState(); if (!testPath) { throw new Error('Could not determine test file path'); @@ -54,14 +95,23 @@ function snapshotTest( const details = parseSnapshotDetails(currentTestName ?? '', name); + const viewportSuffix = resolved ? `@${resolved.label}` : ''; + const displayName = details.displayName; + const fileSlug = viewportSuffix + ? details.fileSlug.replace(new RegExp(`${viewportSuffix}$`, 'i'), '') + : details.fileSlug; + const finalFileSlug = resolved ? `${fileSlug}-${resolved.label}` : fileSlug; + await takeSnapshot({ - fileSlug: details.fileSlug, - displayName: details.displayName, + fileSlug: finalFileSlug, + displayName, renderFn, testFilePath: testPath, group: details.group, theme: details.theme, - metadata, + metadata: restMetadata, + viewport: resolved ? {width: resolved.width, height: resolved.height} : undefined, + viewportLabel: resolved?.label, }); }); } @@ -70,7 +120,7 @@ snapshotTest.each = function snapshotEach(table: T[]) { return ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => SnapshotTestMetadata + metadataFn?: (value: T) => SnapshotTestInput ) => { for (const value of table) { const testName = name.replace('%s', String(value)); @@ -79,6 +129,17 @@ snapshotTest.each = function snapshotEach(table: T[]) { }; }; +snapshotTest.breakpoints = function snapshotBreakpoints( + breakpoints: BreakpointName[], + name: string, + renderFn: () => ReactElement, + metadata: SnapshotTestMetadata = {} +): void { + for (const bp of breakpoints) { + snapshotTest(name, renderFn, {...metadata, viewport: bp}); + } +}; + afterAll(async () => { await closeBrowser(); }); @@ -89,12 +150,18 @@ declare global { namespace jest { interface It { snapshot: typeof snapshotTest & { + breakpoints: ( + breakpoints: BreakpointName[], + name: string, + renderFn: () => ReactElement, + metadata?: SnapshotTestMetadata + ) => void; each: ( table: T[] ) => ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => SnapshotTestMetadata + metadataFn?: (value: T) => SnapshotTestInput ) => void; }; } diff --git a/tests/js/sentry-test/snapshots/snapshot.ts b/tests/js/sentry-test/snapshots/snapshot.ts index a47108633ac4..f58f5629577e 100644 --- a/tests/js/sentry-test/snapshots/snapshot.ts +++ b/tests/js/sentry-test/snapshots/snapshot.ts @@ -41,7 +41,10 @@ function getFontFaceCSS(): string { `; } -function renderToHTML(element: ReactElement): string { +function renderToHTML( + element: ReactElement, + rootDisplay: 'inline-block' | 'block' = 'inline-block' +): string { const cache = createCache({key: 'snap'}); const {extractCriticalToChunks, constructStyleTagsFromChunks} = createEmotionServer(cache); @@ -60,7 +63,7 @@ function renderToHTML(element: ReactElement): string { @@ -108,6 +111,8 @@ interface TakeSnapshotOptions { renderFn: () => ReactElement; testFilePath: string; theme: string | undefined; + viewport?: {width: number; height?: number}; + viewportLabel?: string; } export async function takeSnapshot({ @@ -118,13 +123,18 @@ export async function takeSnapshot({ group, theme, metadata, + viewport, + viewportLabel, }: TakeSnapshotOptions): Promise { const element = renderFn(); - const fullHTML = renderToHTML(element); + const fullHTML = renderToHTML(element, viewport ? 'block' : 'inline-block'); const browser = await getBrowser(); const context = await browser.newContext({ deviceScaleFactor: 2, + ...(viewport && { + viewport: {width: viewport.width, height: viewport.height ?? 720}, + }), }); try { @@ -151,6 +161,9 @@ export async function takeSnapshot({ if (theme) { autoTags.theme = theme; } + if (viewportLabel) { + autoTags.viewport = viewportLabel; + } const tags = {...autoTags, ...metadata.tags}; const meta: SnapshotImageMetadata = { From 76d58b154858918da134d0fe066a41f98b41fd4d Mon Sep 17 00:00:00 2001 From: Sehr <58871345+sehr-m@users.noreply.github.com> Date: Thu, 28 May 2026 15:33:13 -0400 Subject: [PATCH 44/60] feat(seer explorer): add unread message count to the tab icon (#114071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For seer explorer chats, as messages come in we want to add a prefix to the tab title counting the number of unread messages. The number only updates when the chat is closed. Screenshot 2026-04-27 at 10 40 15 AM Screenshot 2026-04-27 at 10 40 21 AM Screenshot 2026-04-27 at 10 40 38 AM --- .../documentTitleManager.tsx | 23 ++++++++- .../sentryDocumentTitle/index.spec.tsx | 22 ++++++++- static/app/views/organizationLayout/index.tsx | 2 + .../drawer/explorerDrawerContent.tsx | 4 +- .../drawer/useSeerExplorerDrawer.spec.tsx | 26 ++++++++++ .../drawer/useSeerExplorerDrawer.tsx | 21 +++++---- .../useSeerExplorerDocumentTitle.tsx | 26 ++++++++++ .../hooks/useSeerExplorerPolling.tsx | 1 + .../seerExplorer/useSeerExplorerContext.tsx | 47 ++++++++++++++++++- 9 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 static/app/views/seerExplorer/components/useSeerExplorerDocumentTitle.tsx diff --git a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx index 62323d87f98e..bf3dbd47b9e5 100644 --- a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx +++ b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx @@ -12,11 +12,13 @@ interface TitleEntry { interface DocumentTitleManager { register: (id: string, text: string, order: number, noSuffix: boolean) => void; + setPrefix: (id: string, prefix: string) => void; unregister: (id: string) => void; } const DocumentTitleContext = createContext({ register: () => {}, + setPrefix: () => {}, unregister: () => {}, }); @@ -24,6 +26,8 @@ export const useDocumentTitleManager = () => useContext(DocumentTitleContext); export function DocumentTitleManager({children}: React.PropsWithChildren) { const [entries, setEntries] = useState([]); + // Maps prefix id -> prefix string (e.g. "(3) ") prepended to the document title + const [prefixes, setPrefixes] = useState>({}); const [manager] = useState(() => ({ register: (id, text, order, noSuffix) => { @@ -35,6 +39,19 @@ export function DocumentTitleManager({children}: React.PropsWithChildren) { return [...prev, {id, text, noSuffix, order}]; }); }, + // Set a prefix by id; passing an empty string removes it + setPrefix: (id, prefix) => { + setPrefixes(prev => { + if (!prefix) { + const {[id]: _, ...rest} = prev; + return rest; + } + if (prev[id] === prefix) { + return prev; + } + return {...prev, [id]: prefix}; + }); + }, unregister: id => { setEntries(prev => prev.filter(e => e.id !== id)); }, @@ -51,8 +68,10 @@ export function DocumentTitleManager({children}: React.PropsWithChildren) { if (!entry?.noSuffix) { parts.push(DEFAULT_PAGE_TITLE); } - return [...new Set([...parts])].join(SEPARATOR); - }, [entries]); + const base = [...new Set([...parts])].join(SEPARATOR); + const prefix = Object.values(prefixes).filter(Boolean).join(''); + return `${prefix}${base}`; + }, [entries, prefixes]); // write to the DOM title useEffect(() => { diff --git a/static/app/components/sentryDocumentTitle/index.spec.tsx b/static/app/components/sentryDocumentTitle/index.spec.tsx index 4640bd031594..106c305e0563 100644 --- a/static/app/components/sentryDocumentTitle/index.spec.tsx +++ b/static/app/components/sentryDocumentTitle/index.spec.tsx @@ -1,6 +1,6 @@ -import {render} from 'sentry-test/reactTestingLibrary'; +import {act, render, renderHook} from 'sentry-test/reactTestingLibrary'; -import {DocumentTitleManager} from './documentTitleManager'; +import {DocumentTitleManager, useDocumentTitleManager} from './documentTitleManager'; import {SentryDocumentTitle} from '.'; describe('SentryDocumentTitle', () => { @@ -49,6 +49,24 @@ describe('SentryDocumentTitle', () => { expect(document.title).toBe('This is a test'); }); + it('prepends and clears prefixes registered via setPrefix', () => { + const {result} = renderHook(() => useDocumentTitleManager(), { + wrapper: ({children}) => ( + + {children} + + ), + }); + + expect(document.title).toBe('page — Sentry'); + + act(() => result.current.setPrefix('badge', '(2) ')); + expect(document.title).toBe('(2) page — Sentry'); + + act(() => result.current.setPrefix('badge', '')); + expect(document.title).toBe('page — Sentry'); + }); + it('reverts to the parent title', () => { const {rerender} = render( diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index 1beb047c3eef..b80580a6d4c3 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -31,6 +31,7 @@ import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryN import {TopBar} from 'sentry/views/navigation/topBar'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; import {OrganizationContainer} from 'sentry/views/organizationContainer'; +import {useSeerExplorerDocumentTitle} from 'sentry/views/seerExplorer/components/useSeerExplorerDocumentTitle'; import {SeerExplorerChatStateProvider} from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; import {SeerExplorerSessionsProvider} from 'sentry/views/seerExplorer/seerExplorerSessionContext'; import {SeerExplorerContextProvider} from 'sentry/views/seerExplorer/useSeerExplorerContext'; @@ -84,6 +85,7 @@ function AppDrawers() { } function AppLayout({organization}: LayoutProps) { + useSeerExplorerDocumentTitle(); const hasPageFrame = useHasPageFrameFeature(); const showSuperuserWarning = isActiveSuperuser() && diff --git a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx index da7bd2ece021..2b148b8a3776 100644 --- a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx +++ b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx @@ -1,7 +1,7 @@ import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; -import {useDrawer} from '@sentry/scraps/drawer'; +import {useDrawerContentContext} from '@sentry/scraps/drawer'; import {Stack} from '@sentry/scraps/layout'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -44,7 +44,7 @@ export function ExplorerDrawerContent({ const organization = useOrganization({allowNull: true}); const {projects} = useProjects(); const user = useUser(); - const {closeDrawer} = useDrawer(); + const {onClose: closeDrawer = () => {}} = useDrawerContentContext(); const [showThinking, setShowThinking] = useState(false); diff --git a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.spec.tsx b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.spec.tsx index 32020bc04612..b5c97ced3700 100644 --- a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.spec.tsx +++ b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.spec.tsx @@ -147,6 +147,32 @@ describe('useSeerExplorerDrawer', () => { await waitFor(() => expect(queryDrawer()).not.toBeInTheDocument()); expect(result.current.isOpen).toBe(false); }); + + it('calls onClose callback when closing', async () => { + const onClose = jest.fn(); + const {result} = renderHookWithProviders(() => useSeerExplorerDrawer({onClose}), { + organization: enabledOrg, + }); + + act(() => result.current.openSeerExplorerDrawer()); + await findDrawer(); + + act(() => result.current.closeSeerExplorerDrawer()); + + await waitFor(() => expect(queryDrawer()).not.toBeInTheDocument()); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when drawer is already closed', () => { + const onClose = jest.fn(); + const {result} = renderHookWithProviders(() => useSeerExplorerDrawer({onClose}), { + organization: enabledOrg, + }); + + act(() => result.current.closeSeerExplorerDrawer()); + + expect(onClose).not.toHaveBeenCalled(); + }); }); describe('toggleSeerExplorerDrawer', () => { diff --git a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx index 79f8539b09c4..5040cfbe29da 100644 --- a/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx +++ b/static/app/views/seerExplorer/components/drawer/useSeerExplorerDrawer.tsx @@ -27,7 +27,7 @@ export type OpenSeerExplorerDrawerOptions = { startNewRun?: boolean; }; -export const useSeerExplorerDrawer = () => { +export const useSeerExplorerDrawer = (options?: {onClose?: () => void}) => { const organization = useOrganization({allowNull: true}); const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); const dispatch = useSeerExplorerChatDispatch(); @@ -39,8 +39,8 @@ export const useSeerExplorerDrawer = () => { isDrawerOpenRef.current = isDrawerOpen; }, [isDrawerOpen]); - // TODO: add effect that opens drawer and seeds run_id from URL, remove from current URL onClose - // (useSeerExplorer hook should no longer handle this) + const onCloseCallbackRef = useRef(options?.onClose); + onCloseCallbackRef.current = options?.onClose; const onOpen = useCallback(() => { trackAnalytics('seer.explorer.global_panel.opened', { @@ -50,16 +50,20 @@ export const useSeerExplorerDrawer = () => { }); }, [getPageReferrer, organization]); + const onClose = useCallback(() => { + onCloseCallbackRef.current?.(); + }, []); + const closeSeerExplorerDrawer = useCallback(() => { - // Prevent closing the global drawer if another drawer (e.g. autofix) is open if (isDrawerOpenRef.current) { closeDrawer(); + onClose(); } - }, [closeDrawer]); + }, [closeDrawer, onClose]); const openSeerExplorerDrawer = useCallback( - (options?: OpenSeerExplorerDrawerOptions) => { - const {runId: openRunId, startNewRun, initialQuery} = options ?? {}; + (drawerOptions?: OpenSeerExplorerDrawerOptions) => { + const {runId: openRunId, startNewRun, initialQuery} = drawerOptions ?? {}; if (initialQuery) { // Always start a fresh session when a query is forwarded so it @@ -88,10 +92,11 @@ export const useSeerExplorerDrawer = () => { resizable: true, mode: 'passive', onOpen, + onClose, } ); }, - [openDrawer, onOpen, dispatch, getPageReferrer] + [openDrawer, onOpen, onClose, dispatch, getPageReferrer] ); const toggleSeerExplorerDrawer = useCallback(() => { diff --git a/static/app/views/seerExplorer/components/useSeerExplorerDocumentTitle.tsx b/static/app/views/seerExplorer/components/useSeerExplorerDocumentTitle.tsx new file mode 100644 index 000000000000..909fa339493a --- /dev/null +++ b/static/app/views/seerExplorer/components/useSeerExplorerDocumentTitle.tsx @@ -0,0 +1,26 @@ +import {useEffect} from 'react'; + +import {useDocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useSeerExplorerContext} from 'sentry/views/seerExplorer/useSeerExplorerContext'; +import {isSeerExplorerEnabled} from 'sentry/views/seerExplorer/utils'; + +const PREFIX_ID = 'seer-explorer-unread'; + +/** + * Prepends an unread msg count to the document title when the drawer is closed. + */ +export function useSeerExplorerDocumentTitle() { + const organization = useOrganization({allowNull: true}); + const {unreadCount} = useSeerExplorerContext(); + const {setPrefix} = useDocumentTitleManager(); + const enabled = isSeerExplorerEnabled(organization); + + useEffect(() => { + if (!enabled) { + return; + } + setPrefix(PREFIX_ID, unreadCount > 0 ? `(${unreadCount}) ` : ''); + return () => setPrefix(PREFIX_ID, ''); + }, [enabled, setPrefix, unreadCount]); +} diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx index 8d841b873c77..d6fe3cdacbe7 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx @@ -92,6 +92,7 @@ export const useSeerExplorerPolling = ({runId}: {runId: number | null}) => { retry: false, refetchOnWindowFocus: true, enabled: !!runId && isSeerExplorerEnabled(organization), + refetchIntervalInBackground: true, refetchInterval: query => { const state = getPollingState( runId, diff --git a/static/app/views/seerExplorer/useSeerExplorerContext.tsx b/static/app/views/seerExplorer/useSeerExplorerContext.tsx index 5c22805b9690..29527e404a0d 100644 --- a/static/app/views/seerExplorer/useSeerExplorerContext.tsx +++ b/static/app/views/seerExplorer/useSeerExplorerContext.tsx @@ -12,10 +12,12 @@ import { import {useHotkeys} from '@sentry/scraps/hotkey'; import {useModal} from '@sentry/scraps/modal'; +import {getDateFromTimestampAssumeUtc} from 'sentry/utils/dates'; import { type OpenSeerExplorerDrawerOptions, useSeerExplorerDrawer, } from 'sentry/views/seerExplorer/components/drawer/useSeerExplorerDrawer'; +import {useSeerExplorerPolling} from 'sentry/views/seerExplorer/hooks/useSeerExplorerPolling'; import {useSeerExplorerChatState} from 'sentry/views/seerExplorer/seerExplorerChatStateContext'; import {useSeerExplorerDeepLink} from 'sentry/views/seerExplorer/utils'; @@ -27,6 +29,7 @@ type SeerExplorerContextValue = { openSeerExplorer: (options?: OpenSeerExplorerDrawerOptions) => void; sessionState: SeerExplorerSessionState; toggleSeerExplorer: () => void; + unreadCount: number; }; const SeerExplorerContext = createContext({ @@ -35,20 +38,60 @@ const SeerExplorerContext = createContext({ openSeerExplorer: () => {}, sessionState: 'inactive', toggleSeerExplorer: () => {}, + unreadCount: 0, }); export function SeerExplorerContextProvider({children}: {children: ReactNode}) { const {runId, chatStates} = useSeerExplorerChatState(); + const [lastViewedAt, setLastViewedAt] = useState(() => Date.now()); + const { openSeerExplorerDrawer, closeSeerExplorerDrawer, toggleSeerExplorerDrawer, isOpen, - } = useSeerExplorerDrawer(); + } = useSeerExplorerDrawer({ + onClose: () => setLastViewedAt(Date.now()), + }); + + const {apiData} = useSeerExplorerPolling({runId}); + const blocks = apiData?.session?.blocks; const pollingState = runId === null ? undefined : chatStates[runId]?.polling; const isPolling = pollingState === 'polling' || pollingState === 'polling-with-backoff'; + useEffect(() => { + setLastViewedAt(Date.now()); + }, [runId]); + + const [isWindowVisible, setIsWindowVisible] = useState( + () => document.visibilityState === 'visible' + ); + useEffect(() => { + const handler = () => { + const visible = document.visibilityState === 'visible'; + setIsWindowVisible(visible); + if (!visible) { + setLastViewedAt(Date.now()); + } + }; + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); + }, []); + + const unreadCount = useMemo(() => { + if (!blocks?.length || runId === null || (isOpen && isWindowVisible)) { + return 0; + } + return blocks.filter(block => { + if (block.message.role === 'user' || block.loading) { + return false; + } + const ts = getDateFromTimestampAssumeUtc(block.timestamp)?.getTime(); + return ts !== null && ts !== undefined && ts > lastViewedAt; + }).length; + }, [blocks, isOpen, isWindowVisible, lastViewedAt, runId]); + // Gates `thinking` / `done-thinking`: otherwise an initial fetch of a stale // runId from sessionStorage flashes polling state before the user engages. const [hasEverOpened, setHasEverOpened] = useState(false); @@ -93,6 +136,7 @@ export function SeerExplorerContextProvider({children}: {children: ReactNode}) { closeSeerExplorer: closeSeerExplorerDrawer, toggleSeerExplorer: toggleSeerExplorerDrawer, sessionState, + unreadCount, }), [ isOpen, @@ -100,6 +144,7 @@ export function SeerExplorerContextProvider({children}: {children: ReactNode}) { closeSeerExplorerDrawer, toggleSeerExplorerDrawer, sessionState, + unreadCount, ] ); From bfe9a29d19147d3bd0f7aa55ac2d6512de12bdb8 Mon Sep 17 00:00:00 2001 From: Matt Duncan <14761+mrduncan@users.noreply.github.com> Date: Thu, 28 May 2026 12:58:07 -0700 Subject: [PATCH 45/60] ref(snuba): Use metrics.timer for get_snuba_map timing (#116357) Replace the bespoke timer context manager with metrics.timer for the get_snuba_map call site only, as a canary to confirm metric values are unchanged before migrating the remaining call sites in #115279. There are monitors on some of the other call sites in #115279 and I'd like to not page anyone. --- src/sentry/utils/snuba.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index 9df4ab0bdc0e..da452f68597e 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -812,7 +812,7 @@ def _prepare_query_params(query_params: SnubaQueryParams, referrer: str | None = kwargs = deepcopy(query_params.kwargs) query_params_conditions = deepcopy(query_params.conditions) - with timer("get_snuba_map"): + with metrics.timer("snuba.client.get_snuba_map"): forward, reverse = get_snuba_translators( query_params.filter_keys, is_grouprelease=query_params.is_grouprelease ) From f6a0181147b46bcd9de3ca6557f26f021d9cc43f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 28 May 2026 13:19:37 -0700 Subject: [PATCH 46/60] ref(repositories): Mark project repo endpoint as public (#116343) Change POST /projects/{org}/{project}/repo/ from PRIVATE to PUBLIC --- src/sentry/api/endpoints/project_repo.py | 44 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/project_repo.py b/src/sentry/api/endpoints/project_repo.py index 22b3066f1a7d..1a4495278a64 100644 --- a/src/sentry/api/endpoints/project_repo.py +++ b/src/sentry/api/endpoints/project_repo.py @@ -1,5 +1,6 @@ from typing import Any +from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -8,6 +9,8 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectPermission +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_NOT_FOUND +from sentry.apidocs.parameters import GlobalParams from sentry.constants import ObjectStatus from sentry.models.project import Project from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource @@ -15,7 +18,9 @@ class ProjectRepoSerializer(serializers.Serializer[ProjectRepository]): - repositoryId = serializers.IntegerField(required=True) + repositoryId = serializers.IntegerField( + required=True, help_text="The ID of the repository to link." + ) def __init__(self, *args: Any, project: Project, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -40,15 +45,50 @@ def create(self, validated_data: dict[str, Any]) -> ProjectRepository: return project_repo +@extend_schema(tags=["Projects"]) @cell_silo_endpoint class ProjectRepoEndpoint(ProjectEndpoint): owner = ApiOwner.ISSUES publish_status = { - "POST": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PUBLIC, } permission_classes = (ProjectPermission,) + @extend_schema( + operation_id="Link a Repository to a Project", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ], + request=inline_serializer( + "ProjectRepoLinkRequest", + fields={ + "repositoryId": serializers.IntegerField( + help_text="The ID of the repository to link." + ), + }, + ), + responses={ + 201: inline_serializer( + "ProjectRepoLinkResponse", + fields={ + "id": serializers.CharField(), + "projectId": serializers.CharField(), + "repositoryId": serializers.CharField(), + "source": serializers.CharField(), + "created": serializers.BooleanField(), + }, + ), + 400: RESPONSE_BAD_REQUEST, + 404: RESPONSE_NOT_FOUND, + }, + ) def post(self, request: Request, project: Project) -> Response: + """ + Link a repository to a project. The repository must already exist + in the organization (connected via a VCS integration). Idempotent: + returns 200 if the link already exists, 201 if created. + """ serializer = ProjectRepoSerializer(data=request.data, project=project) if not serializer.is_valid(): errors = serializer.errors From f483c82bc4d97ef4fc2b078041f2f6d8ffe67a01 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 16:20:10 -0400 Subject: [PATCH 47/60] ref(integrations): Clean up integrationOrganizationLink (#116415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modernizes `IntegrationOrganizationLink` (the page that finishes a third-party install — picking which Sentry org to attach it to) in a handful of small, mechanical ways. - Both remaining `useApiQuery` calls (the orgs list and the provider config) migrate to `apiOptions.as<>()` + `useQuery`. The conditional-fetch pattern goes from `enabled: hasSelectedOrg` + `selectedOrgSlug!` to `path: hasSelectedOrg ? {...} : skipToken`, so the non-null assertion disappears. - The `AddIntegrationButton` helper is inlined into the parent and `useAddIntegration()` is lifted alongside it. The install-click handler is now a single `handleInstallClick` `useCallback` with an explicit dispatch — `provider.key === 'github' && installationId` → API pipeline modal, everything else → legacy install path. - `finishInstallation` is renamed to `finishLegacyInstallation` with a comment noting it bounces through `/extensions//configure/` into the Django-rendered `IntegrationExtensionConfigurationView`. - Local `ButtonWrapper` and `FeatureListItem` styled components replaced with `Flex` + `Text` primitives. - `InstallLink` (a `
` with a pink background, inside a red Alert,
semantically wrong for a URL) is replaced with `TextCopyInput` so the
admin URL gets a proper read-only field with a one-click copy button.
- Drops the now-unused `styled` and `getApiUrl` imports.

No behavior change beyond the `InstallLink` → `TextCopyInput` swap.
---
 .../integrationOrganizationLink/index.tsx     | 179 +++++++-----------
 1 file changed, 71 insertions(+), 108 deletions(-)

diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx
index 703850c796e9..13ef7510ca80 100644
--- a/static/app/views/integrationOrganizationLink/index.tsx
+++ b/static/app/views/integrationOrganizationLink/index.tsx
@@ -1,11 +1,12 @@
 import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
 import {skipToken, useQuery} from '@tanstack/react-query';
 
 import {Alert} from '@sentry/scraps/alert';
 import {Button} from '@sentry/scraps/button';
 import type {SelectOption} from '@sentry/scraps/compactSelect';
+import {Flex} from '@sentry/scraps/layout';
 import {Select} from '@sentry/scraps/select';
+import {Text} from '@sentry/scraps/text';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {FieldGroup} from 'sentry/components/forms/fieldGroup';
@@ -13,13 +14,13 @@ import {IdBadge} from 'sentry/components/idBadge';
 import {LoadingIndicator} from 'sentry/components/loadingIndicator';
 import {NarrowLayout} from 'sentry/components/narrowLayout';
 import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle';
+import {TextCopyInput} from 'sentry/components/textCopyInput';
 import {t, tct} from 'sentry/locale';
 import {ConfigStore} from 'sentry/stores/configStore';
 import type {Integration, IntegrationProvider} from 'sentry/types/integrations';
 import type {Organization} from 'sentry/types/organization';
 import {generateOrgSlugUrl, urlEncode} from 'sentry/utils';
 import {apiOptions} from 'sentry/utils/api/apiOptions';
-import {getApiUrl} from 'sentry/utils/api/getApiUrl';
 import {useAddIntegration} from 'sentry/utils/integrations/useAddIntegration';
 import {
   getIntegrationFeatureGate,
@@ -27,7 +28,6 @@ import {
   trackIntegrationAnalytics,
 } from 'sentry/utils/integrationUtil';
 import {singleLineRenderer} from 'sentry/utils/marked/marked';
-import {useApiQuery} from 'sentry/utils/queryClient';
 import {testableWindowLocation} from 'sentry/utils/testableWindowLocation';
 import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
 import {useLocation} from 'sentry/utils/useLocation';
@@ -80,7 +80,7 @@ export default function IntegrationOrganizationLink() {
     data: organizations = [],
     isPending: isPendingOrganizations,
     error: organizationsError,
-  } = useApiQuery([getApiUrl('/organizations/')], {staleTime: Infinity});
+  } = useQuery(apiOptions.as()('/organizations/', {staleTime: Infinity}));
 
   const hasSelectedOrg = !!selectedOrgSlug;
   const organizationQuery = useQuery(
@@ -97,30 +97,29 @@ export default function IntegrationOrganizationLink() {
     }
   }, [hasSelectedOrg, organizationQuery.error]);
 
-  const isProviderQueryEnabled = hasSelectedOrg;
-  const providerQuery = useApiQuery<{
-    providers: IntegrationProvider[];
-  }>(
-    [
-      getApiUrl('/organizations/$organizationIdOrSlug/config/integrations/', {
-        path: {organizationIdOrSlug: selectedOrgSlug!},
-      }),
-      {query: {provider_key: integrationSlug}},
-    ],
-    {staleTime: Infinity, enabled: isProviderQueryEnabled}
+  const providerQuery = useQuery(
+    apiOptions.as<{providers: IntegrationProvider[]}>()(
+      '/organizations/$organizationIdOrSlug/config/integrations/',
+      {
+        path: hasSelectedOrg ? {organizationIdOrSlug: selectedOrgSlug} : skipToken,
+        query: {provider_key: integrationSlug},
+        staleTime: Infinity,
+      }
+    )
   );
+
   const provider = providerQuery.data?.providers[0] ?? null;
+
   useEffect(() => {
     const hasEmptyProvider = !provider && !providerQuery.isPending;
-    if (isProviderQueryEnabled && (providerQuery.error || hasEmptyProvider)) {
+    if (hasSelectedOrg && (providerQuery.error || hasEmptyProvider)) {
       addErrorMessage(t('Failed to retrieve integration details'));
     }
-  }, [isProviderQueryEnabled, providerQuery.error, providerQuery.isPending, provider]);
+  }, [hasSelectedOrg, providerQuery.error, providerQuery.isPending, provider]);
 
   // These two queries are recomputed when an organization is selected
   const isPendingSelection =
-    (hasSelectedOrg && organizationQuery.isPending) ||
-    (isProviderQueryEnabled && providerQuery.isPending);
+    hasSelectedOrg && (organizationQuery.isPending || providerQuery.isPending);
 
   const selectOrganization = useCallback(
     (orgSlug: string) => {
@@ -149,11 +148,12 @@ export default function IntegrationOrganizationLink() {
     }
   }, [organizations, location.search, selectOrganization]);
 
-  const hasAccess = useMemo(() => {
-    return organization?.access.includes('org:integrations');
-  }, [organization]);
+  const hasAccess = organization?.access.includes('org:integrations');
+
+  const {startFlow} = useAddIntegration();
 
-  // used with Github to redirect to the integration detail
+  // Lands the user on the integration's settings page after a successful
+  // API-driven install. Used as `startFlow`'s `onInstall` callback.
   const onInstallWithInstallationId = useCallback(
     (data: Integration) => {
       const orgId = organization?.slug;
@@ -167,8 +167,11 @@ export default function IntegrationOrganizationLink() {
     [organization]
   );
 
-  // non-Github redirects to the extension view where the backend will finish the installation
-  const finishInstallation = useCallback(() => {
+  // 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
+  // that hasn't been migrated to the API pipeline modal yet.
+  const finishLegacyInstallation = useCallback(() => {
     // add the selected org to the query parameters and then redirect back to configure
     const query = {orgSlug: selectedOrgSlug, ...location.query};
     trackExternalAnalytics({
@@ -184,6 +187,34 @@ export default function IntegrationOrganizationLink() {
     );
   }, [integrationSlug, location.query, organization, provider, selectedOrgSlug]);
 
+  const handleInstallClick = useCallback(() => {
+    if (!provider || !organization) {
+      return;
+    }
+
+    // GitHub is the only provider currently driven through the API pipeline
+    // modal from this view — users land here after installing the Sentry
+    // GitHub App from github.com, with the `installationId` in the URL.
+    if (provider.key === 'github' && installationId) {
+      startFlow({
+        provider,
+        organization,
+        onInstall: onInstallWithInstallationId,
+        urlParams: {installation_id: installationId},
+      });
+      return;
+    }
+
+    finishLegacyInstallation();
+  }, [
+    provider,
+    organization,
+    installationId,
+    startFlow,
+    onInstallWithInstallationId,
+    finishLegacyInstallation,
+  ]);
+
   const renderAddButton = useMemo(() => {
     if (!provider || !organization) {
       return null;
@@ -194,9 +225,9 @@ export default function IntegrationOrganizationLink() {
     const featuresComponents = features.map(f => ({
       featureGate: f.featureGate,
       description: (
-        
+        
+          
+        
       ),
     }));
 
@@ -205,27 +236,20 @@ export default function IntegrationOrganizationLink() {
     return (
       
         {({disabled, disabledReason}) => (
-          
+          
+            
+            {disabled && }
+          
         )}
       
     );
-  }, [
-    installationId,
-    provider,
-    organization,
-    hasAccess,
-    onInstallWithInstallationId,
-    finishInstallation,
-  ]);
+  }, [provider, organization, hasAccess, handleInstallClick]);
 
   const renderBottom = useMemo(() => {
     const {FeatureList} = getIntegrationFeatureGate();
@@ -247,7 +271,7 @@ export default function IntegrationOrganizationLink() {
                   {organization: {organization.slug}}
                 )}
               

- {generateOrgSlugUrl(selectedOrgSlug)} + {generateOrgSlugUrl(selectedOrgSlug)} )} @@ -328,64 +352,3 @@ export default function IntegrationOrganizationLink() { ); } - -function AddIntegrationButton({ - provider, - organization, - onInstall, - installationId, - hasAccess, - disabled, - disabledReason, - finishInstallation, -}: { - disabled: boolean; - disabledReason: React.ReactNode; - finishInstallation: () => void; - hasAccess: boolean | undefined; - onInstall: (data: Integration) => void; - organization: Organization; - provider: IntegrationProvider; - installationId?: string; -}) { - const {startFlow} = useAddIntegration(); - - return ( - - - {disabled && } - - ); -} - -const InstallLink = styled('pre')` - margin-bottom: 0; - background: #fbe3e1; -`; - -const FeatureListItem = styled('span')` - line-height: 24px; -`; - -const ButtonWrapper = styled('div')` - margin-left: auto; - align-self: center; - display: flex; - flex-direction: column; - align-items: center; -`; From 20d069b13c97109ae6a54df50a0baf5a21827882 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Thu, 28 May 2026 16:23:00 -0400 Subject: [PATCH 48/60] fix(heatmaps): very small y-axis values turning into engineering notation and throwing errors (#116421) I was having an issue with the heat maps endpoint for certain metrics. Seems like some of these metrics have very small numbers for their bounds and python was converting them to engineering notation (ex. 1.24e-4) when string formatting the query. We want to keep the decimal notation because that's what the queries will correctly take in and parse out. Example: image anyways to fix it i've created a function to pre-format these strings so that they don't convert to eng notation. --- .../endpoints/organization_events_heatmap.py | 20 +++- ...ganization_events_heatmap_trace_metrics.py | 97 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_heatmap.py b/src/sentry/api/endpoints/organization_events_heatmap.py index bfcaa8c96567..946293454f8b 100644 --- a/src/sentry/api/endpoints/organization_events_heatmap.py +++ b/src/sentry/api/endpoints/organization_events_heatmap.py @@ -83,6 +83,18 @@ class OrganizationEventsHeatmapEndpoint(OrganizationEventsEndpointBase): } ) + @staticmethod + def _format_long_float(value: float) -> str: + """ + Python's default float-to-string uses scientific notation for very small + values (e.g. 8.527e-06), which the search query parser does not support. + Fixed-point with 20 decimal places produces a plain decimal string the parser can handle. + """ + if "e" in str(value) or "E" in str(value): + return f"{value:.20f}".rstrip("0").rstrip(".") + else: + return str(value) + def get(self, request: Request, organization: Organization) -> Response: """ Retrieves explore data for a given organization as a heatmap. @@ -186,17 +198,19 @@ def get(self, request: Request, organization: Organization) -> Response: upper_bound = bucket_ranges.min_value + (current_bucket + 1) * bucket_size if current_bucket == y_buckets - 1: - yAxes[lower_bound] = f"{z_function}_if(`{yAxis}:>={lower_bound}`, {yAxis})" + yAxes[lower_bound] = ( + f"{z_function}_if(`{yAxis}:>={self._format_long_float(lower_bound)}`, {yAxis})" + ) else: yAxes[lower_bound] = ( - f"{z_function}_if(`{yAxis}:>={lower_bound} AND {yAxis}:<{upper_bound}`, {yAxis})" + f"{z_function}_if(`{yAxis}:>={self._format_long_float(lower_bound)} AND {yAxis}:<{self._format_long_float(upper_bound)}`, {yAxis})" ) else: # if max == min, then just have 1 bucket bucket_size = 0 y_buckets = 1 yAxes = { - bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{bucket_ranges.min_value}`, {yAxis})" + bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{self._format_long_float(bucket_ranges.min_value)}`, {yAxis})" } result = dataset.run_timeseries_query( diff --git a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py index 2ae7dd195f90..a6a79257fc85 100644 --- a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py +++ b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py @@ -1,7 +1,9 @@ from datetime import timedelta +import pytest from django.urls import reverse +from sentry.api.endpoints.organization_events_heatmap import OrganizationEventsHeatmapEndpoint from sentry.testutils.helpers.datetime import before_now from tests.snuba.api.endpoints.test_organization_events import ( OrganizationEventsEndpointTestBase, @@ -376,3 +378,98 @@ def test_invalid_log_scale(self): ) assert response.status_code == 400, response.content assert response.data["detail"] == "logScale cannot be 1" + + def test_very_small_float_values(self) -> None: + # Values like 8.527e-06 would previously be formatted in scientific + # notation inside the query string, which the search query parser rejects. + small_values = [0.000008, 0.000009, 0.000010, 0.000011] + + trace_metrics = [] + for hour, value in enumerate(small_values): + trace_metrics.append( + self.create_trace_metric( + "foo", + value, + "counter", + timestamp=self.start + timedelta(hours=hour), + ) + ) + self.store_eap_items(trace_metrics) + + response = self._do_request( + data={ + "start": self.start, + "end": self.start + timedelta(hours=6), + "yAxis": "value", + "interval": "1h", + "yBuckets": 4, + "query": "metric.name:foo metric.type:counter", + "project": self.project.id, + "dataset": self.dataset, + }, + ) + assert response.status_code == 200, response.content + assert response.data["meta"]["yAxis"]["start"] == pytest.approx(0.000008, rel=1e-3) + assert response.data["meta"]["yAxis"]["end"] == pytest.approx(0.000011, rel=1e-3) + + def test_very_small_float_min_equals_max(self) -> None: + # When min == max and the value is very small, the single-bucket query + # must also use plain decimal notation rather than scientific notation. + trace_metrics = [ + self.create_trace_metric( + "foo", + 0.000008527, + "counter", + timestamp=self.start + timedelta(hours=hour), + ) + for hour in range(3) + ] + self.store_eap_items(trace_metrics) + + response = self._do_request( + data={ + "start": self.start, + "end": self.start + timedelta(hours=6), + "yAxis": "value", + "interval": "1h", + "yBuckets": 10, + "query": "metric.name:foo metric.type:counter", + "project": self.project.id, + "dataset": self.dataset, + }, + ) + assert response.status_code == 200, response.content + assert response.data["meta"]["yAxis"]["bucketCount"] == 1 + assert response.data["meta"]["yAxis"]["start"] == pytest.approx(0.000008527, rel=1e-3) + + +class TestFormatLongFloat: + def test_small_number_no_scientific_notation(self) -> None: + result = OrganizationEventsHeatmapEndpoint._format_long_float(8.527e-06) + assert "e" not in result + assert "E" not in result + assert result == "0.000008527" + + def test_normal_number(self) -> None: + result = OrganizationEventsHeatmapEndpoint._format_long_float(123.456) + assert "e" not in result + assert result == "123.456" + + def test_huge_number_no_scientific_notation(self) -> None: + result = OrganizationEventsHeatmapEndpoint._format_long_float( + 123456789012345678901234567890 + ) + assert "e" not in result + assert "E" not in result + assert result == "123456789012345678901234567890" + + def test_zero(self) -> None: + result = OrganizationEventsHeatmapEndpoint._format_long_float(0.0) + assert result == "0.0" + + def test_very_small_number_is_parseable(self) -> None: + # Ensure the formatted string looks like a plain decimal Python float + result = OrganizationEventsHeatmapEndpoint._format_long_float(1.23e-10) + assert "e" not in result + assert "E" not in result + float(result) # must not raise From ec73ff2cb7ba9e683a56eaf75b83acc3348d6ea4 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Thu, 28 May 2026 16:25:27 -0400 Subject: [PATCH 49/60] feat(trace-waterfall): Add "EAP JSON" debug button for superusers (#116131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In general, but especially during our span streaming rollout, it's useful for debugging to be able to see the underlying data for a particular span from EAP before it is transformed by the Sentry API and frontend. This PR adds a **superuser-only** button to the span details pane that, when clicked, opens the trace item details JSON for the span with the raw result from EAP's TraceItemDetails RPC included. The button is modelled after the existing transaction JSON button, although the use case for this one is strictly internal. Since the JSON icon is already taken, I'm using the terminal icon to try to express "debugging". Suggestions welcome (but this is internal, so it doesn't matter _too_ much.) Screenshot 2026-05-25 at 11 33 42 --- 🤖: Claude Code (Opus 4.6) used to generate the code, with human editing + review. All words my own. --- .../newTraceDetails/traceDrawer/details/styles.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx index ed488630adaa..a11747d21f43 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx @@ -44,6 +44,7 @@ import { IconJson, IconPanel, IconProfiling, + IconTerminal, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Event, EventTransaction} from 'sentry/types/event'; @@ -56,6 +57,7 @@ import {MarkedText} from 'sentry/utils/marked/markedText'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; +import {useUser} from 'sentry/utils/useUser'; import {getIsAiNode} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes'; import {getIsMCPNode} from 'sentry/views/insights/pages/mcp/utils/mcpTraceNodes'; import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics'; @@ -65,6 +67,7 @@ import { makeTraceContinuousProfilingLink, makeTransactionProfilingLink, } from 'sentry/views/performance/newTraceDetails/traceDrawer/traceProfilingLink'; +import {isEAPSpanNode} from 'sentry/views/performance/newTraceDetails/traceGuards'; import type {BaseNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/baseNode'; import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import { @@ -952,6 +955,7 @@ function NodeActions(props: { threadId?: string; }) { const organization = useOrganization(); + const user = useUser(); const params = useParams<{traceSlug?: string}>(); const transactionId = props.node.transactionId ?? ''; @@ -1004,6 +1008,16 @@ function NodeActions(props: { /> ) : null} + {user.isSuperuser && isEAPSpanNode(props.node) && params.traceSlug ? ( + + } + /> + + ) : null} {continuousProfileTarget ? ( Date: Thu, 28 May 2026 13:27:11 -0700 Subject: [PATCH 50/60] fix(preprod): Balance padding on active tag filter chips (#116417) Even out the top and bottom padding on the active tag filter chips section below the Tags disclosure header in the snapshot sidebar. Previously, the top padding was removed (`paddingTop="0"`) creating an unbalanced look. This sets equal `sm` vertical padding with `lg` horizontal padding. Co-authored-by: Claude --- .../views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx index 3470ca979dce..098de75c891b 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx @@ -407,7 +407,7 @@ const TagFilterSection = memo(function TagFilterSection({ {hasActiveFilter && ( - + {Object.entries(activeTagFilters).map(([key, value]) => ( Date: Thu, 28 May 2026 16:30:52 -0400 Subject: [PATCH 51/60] feat(workflow-engine): Implement Seer Activity handler (#116410) This will trigger the `process_workflow_activity` for relevant Seer activities, noop for unrelated activities or organizations missing the necessary flag. The rest of the workflow engine will consume the activity just fine, but we're still lacking the data condition to set up alerts and the fixes on actions to support firing properly, (M2 and M3 respectively) In practice an organization would need two flags, one for this workflow processor, one that permits creating seer activities in the first place. --------- Co-authored-by: Claude Opus 4 --- src/sentry/seer/entrypoints/operator.py | 4 +- .../workflow/workflow_activity_handlers.py | 62 +++++++++++++- src/sentry/workflow_engine/registry.py | 19 ++++- .../sentry/seer/entrypoints/test_operator.py | 20 +++++ .../test_workflow_activity_handlers.py | 81 ++++++++++++++++++- 5 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 739b1eb90331..1c2aae679743 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -35,6 +35,7 @@ from sentry.types.activity import ActivityType from sentry.users.models.user import User from sentry.users.services.user import RpcUser +from sentry.workflow_engine.registry import invoke_workflow_activity_handlers SEER_EVENT_TO_ACTIVITY_TYPE: dict[SentryAppEventType, ActivityType] = { SentryAppEventType.SEER_ROOT_CAUSE_STARTED: ActivityType.SEER_RCA_STARTED, @@ -586,12 +587,13 @@ def _create_seer_activity( if pull_requests: activity_data["pull_requests"] = pull_requests - Activity.objects.create_group_activity( + activity = Activity.objects.create_group_activity( group, activity_type, data=activity_data if activity_data else None, send_notification=False, ) + invoke_workflow_activity_handlers(group=group, activity=activity) @instrumented_task( diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py index 3df4b76102be..3eebc62cb662 100644 --- a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py @@ -1,9 +1,67 @@ +import logging + +from sentry import features from sentry.models.activity import Activity from sentry.models.group import Group +from sentry.types.activity import ActivityType +from sentry.utils import metrics +from sentry.workflow_engine.models import Detector from sentry.workflow_engine.registry import workflow_activity_registry +from sentry.workflow_engine.tasks.workflows import process_workflow_activity + +logger = logging.getLogger(__name__) + +SEER_WORKFLOW_ACTIVITIES = [ + ActivityType.SEER_RCA_STARTED, + ActivityType.SEER_RCA_COMPLETED, + ActivityType.SEER_SOLUTION_STARTED, + ActivityType.SEER_SOLUTION_COMPLETED, + ActivityType.SEER_CODING_STARTED, + ActivityType.SEER_CODING_COMPLETED, + ActivityType.SEER_PR_CREATED, +] @workflow_activity_registry.register("seer_activity") def seer_activity_handler(group: Group, activity: Activity) -> None: - # TODO(Leander): Implement this handler - pass + logging_ctx = { + "activity_type": activity.type, + "group_id": group.id, + "project_id": group.project_id, + } + + try: + activity_type = ActivityType(activity.type) + except ValueError: + logger.exception( + "workflow_engine.seer_activity_handler.invalid_activity_type", extra=logging_ctx + ) + return + logging_ctx["activity_name"] = activity_type.name + + if activity_type not in SEER_WORKFLOW_ACTIVITIES: + return + + if not features.has( + "organizations:workflow-engine-evaluate-seer-activities", group.organization + ): + return + + try: + detector = Detector.get_issue_stream_detector_for_project(group.project_id) + except Detector.DoesNotExist: + logger.exception( + "workflow_engine.seer_activity_handler.missing_detector", extra=logging_ctx + ) + return + + process_workflow_activity.delay( + activity_id=activity.id, + group_id=group.id, + detector_id=detector.id, + ) + metrics.incr( + "workflow_engine.seer_activity_handler.complete", + tags={"activity_name": activity_type.name}, + ) + logger.info("workflow_engine.seer_activity_handler.complete", extra=logging_ctx) diff --git a/src/sentry/workflow_engine/registry.py b/src/sentry/workflow_engine/registry.py index 6ec921a6ab63..ab115ae1c67b 100644 --- a/src/sentry/workflow_engine/registry.py +++ b/src/sentry/workflow_engine/registry.py @@ -1,3 +1,4 @@ +import logging from typing import Any from sentry.models.activity import Activity @@ -10,6 +11,8 @@ WorkflowActivityHandler, ) +logger = logging.getLogger(__name__) + data_source_type_registry = Registry[type[DataSourceTypeHandler[Any]]]() condition_handler_registry = Registry[type[DataConditionHandler[Any]]](enable_reverse_lookup=False) action_handler_registry = Registry[type[ActionHandler]](enable_reverse_lookup=False) @@ -17,5 +20,17 @@ def invoke_workflow_activity_handlers(group: Group, activity: Activity) -> None: - for handler in workflow_activity_registry.registrations.values(): - handler(group, activity) + for handler_key, handler in workflow_activity_registry.registrations.items(): + try: + handler(group, activity) + except Exception: + logger.exception( + "workflow_engine.invoke_workflow_activity_handlers.error", + extra={ + "group_id": group.id, + "activity_id": activity.id, + "handler_name": handler_key, + "activity_type": activity.type, + }, + ) + continue diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index e96c3148c06f..01eaf538112a 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -37,6 +37,7 @@ from sentry.sentry_apps.metrics import SentryAppEventType from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType @@ -583,6 +584,25 @@ def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_acce == "https://github.com/owner/repo/pull/42" ) + @with_feature("organizations:seer-activity-timeline") + @patch("sentry.seer.entrypoints.operator.invoke_workflow_activity_handlers") + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_invokes_workflow_activity_handlers( + self, _mock_has_access, mock_invoke + ): + event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} + + process_autofix_updates( + event_type=SentryAppEventType.SEER_ROOT_CAUSE_STARTED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + mock_invoke.assert_called_once() + call_kwargs = mock_invoke.call_args[1] + assert call_kwargs["group"] == self.group + assert call_kwargs["activity"].type == ActivityType.SEER_RCA_STARTED.value + class TestGetAutofixExplorerStatus(TestCase): @staticmethod diff --git a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py index 3a0b094b48e4..22328b223bc4 100644 --- a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py +++ b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py @@ -1,11 +1,19 @@ from unittest import mock +from unittest.mock import MagicMock from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType +from sentry.workflow_engine.handlers.workflow.workflow_activity_handlers import ( + SEER_WORKFLOW_ACTIVITIES, + seer_activity_handler, +) +from sentry.workflow_engine.models import Detector from sentry.workflow_engine.registry import ( invoke_workflow_activity_handlers, workflow_activity_registry, ) +from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType class WorkflowActivityRegistryTest(TestCase): @@ -19,20 +27,87 @@ def test_registrants(self) -> None: assert "seer_activity" in workflow_activity_registry.registrations assert len(workflow_activity_registry.registrations) == 1 - def test_invoke_handlers(self) -> None: + def test_invoke_handlers_safely(self) -> None: handler_a = mock.Mock() - handler_b = mock.Mock() + handler_b = mock.Mock(side_effect=Exception("Test error")) + handler_c = mock.Mock() with mock.patch.dict( workflow_activity_registry.registrations, - {"handler_a": handler_a, "handler_b": handler_b}, + {"handler_a": handler_a, "handler_b": handler_b, "handler_c": handler_c}, clear=True, ): invoke_workflow_activity_handlers(self.group, self.activity) handler_a.assert_called_once_with(self.group, self.activity) handler_b.assert_called_once_with(self.group, self.activity) + handler_c.assert_called_once_with(self.group, self.activity) def test_invoke_handlers_no_registrants(self) -> None: with mock.patch.dict(workflow_activity_registry.registrations, {}, clear=True): invoke_workflow_activity_handlers(self.group, self.activity) + + +class SeerActivityHandlerTest(TestCase): + def setUp(self) -> None: + self.group = self.create_group() + self.activity = self.create_group_activity( + group=self.group, type=ActivityType.SEER_PR_CREATED.value + ) + self.detector = self.create_detector(type=IssueStreamGroupType.slug, project=self.project) + + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + def test_feature_flag_disabled(self, mock_process_workflow_activity: MagicMock) -> None: + seer_activity_handler(self.group, self.activity) + mock_process_workflow_activity.delay.assert_not_called() + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + def test_all_supported_activity_types_dispatch( + self, mock_process_workflow_activity: MagicMock + ) -> None: + for activity_type in SEER_WORKFLOW_ACTIVITIES: + mock_process_workflow_activity.reset_mock() + activity = self.create_group_activity(group=self.group, type=activity_type.value) + seer_activity_handler(self.group, activity) + assert mock_process_workflow_activity.delay.called, ( + f"Task not dispatched for {activity_type.value}" + ) + mock_process_workflow_activity.delay.assert_called_once_with( + activity_id=activity.id, + group_id=self.group.id, + detector_id=self.detector.id, + ) + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + def test_skips_unsupported_activity_type( + self, mock_process_workflow_activity: MagicMock + ) -> None: + activity = self.create_group_activity(group=self.group, type=ActivityType.NOTE.value) + seer_activity_handler(self.group, activity) + + mock_process_workflow_activity.delay.assert_not_called() + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + @mock.patch( + "sentry.workflow_engine.models.Detector.get_issue_stream_detector_for_project", + side_effect=Exception("DoesNotExist"), + ) + def test_skips_when_no_issue_stream_detector( + self, mock_get_detector: MagicMock, mock_process_workflow_activity: MagicMock + ) -> None: + mock_get_detector.side_effect = Detector.DoesNotExist + + seer_activity_handler(self.group, self.activity) + + mock_process_workflow_activity.delay.assert_not_called() From b68b7f2ca6dd5b362b4bc93abfa16bced84f9ae3 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 16:42:11 -0400 Subject: [PATCH 52/60] ref(integrations): Redirect GitHub installs straight to the link page (#116412) When a user installs the Sentry GitHub App directly from github.com, GitHub redirects to `/extensions/github/setup/?setup_action=install&...` and `PipelineAdvancerView` was bouncing them onward to the legacy `/extensions/external-install///` URL, which existed only to render the React org-picker. This skips the intermediate hop and redirects straight to the picker's real URL, `/extensions//link/?installationId=`. The trampoline page's fallback URL (for the rare case where the OAuth popup has no opener) gets the same treatment. To make this `reverse()`-able rather than a hardcoded path string, the `/extensions//link/` route is registered as `sentry-integration-installation-link` (it was previously served only by the catch-all). The now-unused `integration-installation` URL is removed, and the matching carve-out in the integration request classifier middleware goes with it. The companion frontend cleanup (deleting `GitHubInstallRedirect` and its route entry) lands in a follow-up PR. --- .../middleware/integrations/classifications.py | 1 - src/sentry/web/frontend/pipeline_advancer.py | 13 ++++++++++--- src/sentry/web/urls.py | 4 ++-- .../sentry/integrations/github/test_integration.py | 8 ++++++-- .../middleware/integrations/test_parsers_defined.py | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/sentry/middleware/integrations/classifications.py b/src/sentry/middleware/integrations/classifications.py index 67096eb0d994..c49aea722cab 100644 --- a/src/sentry/middleware/integrations/classifications.py +++ b/src/sentry/middleware/integrations/classifications.py @@ -116,7 +116,6 @@ def should_operate(self, request: HttpRequest) -> bool: and not request.path.endswith("/setup/") # or match the routes for integrationOrganizationLink page (See routes.tsx) and not request.path.endswith("/link/") - and not request.path.startswith("/extensions/external-install/") ) def get_response(self, request: HttpRequest) -> HttpResponseBase: diff --git a/src/sentry/web/frontend/pipeline_advancer.py b/src/sentry/web/frontend/pipeline_advancer.py index d4e81cdc0364..4c7a00d7671b 100644 --- a/src/sentry/web/frontend/pipeline_advancer.py +++ b/src/sentry/web/frontend/pipeline_advancer.py @@ -64,9 +64,12 @@ def _render_trampoline(request: HttpRequest, pipeline: object, provider_id: str) # trampoline can navigate there if there's no opener window. installation_id = request.GET.get("installation_id") if request.GET.get("setup_action") == "install" and installation_id: - fallback_url = str( - dumps_htmlsafe(reverse("integration-installation", args=[provider_id, installation_id])) + link_url = reverse( + "sentry-integration-installation-link", + kwargs={"integration_slug": provider_id}, + query={"installationId": installation_id}, ) + fallback_url = str(dumps_htmlsafe(link_url)) else: fallback_url = "null" @@ -132,7 +135,11 @@ def handle(self, request: HttpRequest, provider_id: str) -> HttpResponseBase: and installation_id ): return self.redirect( - reverse("integration-installation", args=[provider_id, installation_id]) + reverse( + "sentry-integration-installation-link", + kwargs={"integration_slug": provider_id}, + query={"installationId": installation_id}, + ) ) if pipeline is None or not pipeline.is_valid(): diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index dd7e0b05816b..0d23a97bc7f5 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -802,9 +802,9 @@ ), ), re_path( - r"^extensions/external-install/(?P\w+)/(?P\w+)/$", + r"^extensions/(?P[^/]+)/link/$", react_page_view, - name="integration-installation", + name="sentry-integration-installation-link", ), re_path( r"^unsubscribe/(?P[^/]+)/project/(?P\d+)/$", diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index efa03bd4e7db..8190558c6fea 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -1850,7 +1850,11 @@ def _get_setup_install_url(self, installation_id: str = "12345") -> str: ) def _get_expected_redirect(self, installation_id: str = "12345") -> str: - return reverse("integration-installation", args=["github", installation_id]) + return reverse( + "sentry-integration-installation-link", + kwargs={"integration_slug": "github"}, + query={"installationId": installation_id}, + ) @responses.activate def test_no_pipeline_redirects_to_org_picker(self) -> None: @@ -1874,7 +1878,7 @@ def test_api_pipeline_renders_trampoline(self) -> None: resp = self.client.get(self._get_setup_install_url()) assert resp.status_code == 200 assert b"window.opener" in resp.content - assert b"extensions/external-install" in resp.content + assert b'"/extensions/github/link/?installationId=12345"' in resp.content @control_silo_test diff --git a/tests/sentry/middleware/integrations/test_parsers_defined.py b/tests/sentry/middleware/integrations/test_parsers_defined.py index 313b73dea819..a09628991ab9 100644 --- a/tests/sentry/middleware/integrations/test_parsers_defined.py +++ b/tests/sentry/middleware/integrations/test_parsers_defined.py @@ -29,7 +29,7 @@ def test_parsers_for_all_extension_urls() -> None: [_, provider, _trailing] = pattern.split("/", maxsplit=2) # Ignore dynamic segments or providers without middleware parsers - if provider[0] in {"(", "["} or provider in {"external-install", "cursor"}: + if provider[0] in {"(", "["} or provider in {"cursor"}: continue # Ensure the expected module exists From f30733d16f53e4c24ef1c09080615c1a11922ed7 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 16:44:38 -0400 Subject: [PATCH 53/60] ref(integrations): Drop the external-install React route (#116426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IntegrationOrganizationLink` was wired at two different URL shapes — `/extensions/external-install/:integrationSlug/:installationId` for GitHub installs initiated from github.com, and `/extensions/:integrationSlug/link/` for everything else — and used `useParams` to pull the GitHub `installationId` out of the path on one of those routes. This made the component aware of two URL shapes for the same product flow. The companion backend change in #116412 redirects GitHub-initiated installs straight to the `link/` route with `installationId` in the query string, so the external-install URL no longer needs to resolve at all. This drops the React route entry and updates the link view to read `installationId` from the query string. The link view now only handles one URL shape. Depends on #116412 landing in production first. --- static/app/router/routes.tsx | 4 ---- .../views/integrationOrganizationLink/index.tsx | 15 +++++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 283c1d98a01a..d3b00bebb763 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -164,10 +164,6 @@ function buildRoutes(): RouteObject[] { { component: errorHandler(OrganizationContainerRoute), children: [ - { - path: '/extensions/external-install/:integrationSlug/:installationId', - component: make(() => import('sentry/views/integrationOrganizationLink')), - }, { path: '/extensions/:integrationSlug/link/', component: make(() => import('sentry/views/integrationOrganizationLink')), diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx index 13ef7510ca80..ad95d551dbf3 100644 --- a/static/app/views/integrationOrganizationLink/index.tsx +++ b/static/app/views/integrationOrganizationLink/index.tsx @@ -69,11 +69,13 @@ function trackExternalAnalytics({ export default function IntegrationOrganizationLink() { const location = useLocation(); - const {integrationSlug, installationId} = useParams<{ - integrationSlug: string; - // installationId present for Github flow - installationId?: string; - }>(); + const {integrationSlug} = useParams<{integrationSlug: string}>(); + // GitHub installs forwarded here from `/extensions/external-install/...` + // carry `installationId` in the query string. + const installationId = + typeof location.query.installationId === 'string' + ? location.query.installationId + : undefined; const [selectedOrgSlug, setSelectedOrgSlug] = useState(null); const { @@ -194,7 +196,8 @@ export default function IntegrationOrganizationLink() { // GitHub is the only provider currently driven through the API pipeline // modal from this view — users land here after installing the Sentry - // GitHub App from github.com, with the `installationId` in the URL. + // GitHub App from github.com, with the `installationId` in the URL query + // (forwarded from `/extensions/external-install/...`). if (provider.key === 'github' && installationId) { startFlow({ provider, From 8939d2d618507b369dfd4a24aeb0ee7ee53e9a7f Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 16:51:56 -0400 Subject: [PATCH 54/60] feat(discord): Wire App Directory installs through the API pipeline modal (#116429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user installs the Sentry Discord app from Discord's App Directory, Discord redirects to `/extensions/discord/configure/` with `code` and `guild_id`. The configure URL forwards to `/extensions/discord/link/` (the org-picker), and from there the install needs to drive the existing API pipeline modal — there's a Discord-specific pipeline (added in #116375) that consumes those params and completes the install. This wires up the frontend side of that flow. In the org-link view (`IntegrationOrganizationLink`): - Add a `discordAppDirectoryParams` memo that returns `{code, guild_id, use_configure: '1'}` when `code` and `guild_id` are present in the URL query, else null. - Refactor `handleInstallClick` to dispatch via `gitHubAppListingParams ?? discordAppDirectoryParams` — whichever is non-null routes through `startFlow`, otherwise we fall back to the legacy install path. - Rename `onInstallWithInstallationId` → `onInstall` since both flows use it now. - Refresh the top-of-component docstring to enumerate both provider-initiated entry points. In the Discord pipeline step (`integrationDiscord`): - Make `stepData` a discriminated union of the existing OAuth shape (`{oauthUrl}`) and the new App Directory shape (`{appDirectoryInstall: true, code, guildId, state}`). - When the backend signals `appDirectoryInstall`, auto-advance via a `useEffect` (guarded by a ref so React strict mode doesn't double-POST) and render `Finishing up Discord integration installation...` instead of the OAuth button. Backend support landed in #116375. --- .../integrationDiscord/index.spec.tsx | 30 ++++++++ .../pipeline/integrationDiscord/index.tsx | 38 +++++++++- .../integrationOrganizationLink/index.tsx | 75 +++++++++++++++---- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/static/app/components/pipeline/integrationDiscord/index.spec.tsx b/static/app/components/pipeline/integrationDiscord/index.spec.tsx index a3b493216bb6..bfcbdfd1e7fd 100644 --- a/static/app/components/pipeline/integrationDiscord/index.spec.tsx +++ b/static/app/components/pipeline/integrationDiscord/index.spec.tsx @@ -94,4 +94,34 @@ describe('DiscordOAuthLoginStep', () => { expect(screen.getByRole('button', {name: 'Authorize Discord'})).toBeDisabled(); }); + + it('auto-advances when stepData indicates an App Directory install', () => { + const advance = jest.fn(); + render( + + ); + + expect(advance).toHaveBeenCalledWith({ + code: 'auth-code-456', + guildId: '9876543210', + state: 'pipeline-sig', + }); + expect(advance).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole('button', {name: 'Authorize Discord'}) + ).not.toBeInTheDocument(); + expect( + screen.getByText('Finishing up Discord integration installation...') + ).toBeInTheDocument(); + }); }); diff --git a/static/app/components/pipeline/integrationDiscord/index.tsx b/static/app/components/pipeline/integrationDiscord/index.tsx index 2d99d30ab1f6..f5894f30ef61 100644 --- a/static/app/components/pipeline/integrationDiscord/index.tsx +++ b/static/app/components/pipeline/integrationDiscord/index.tsx @@ -1,4 +1,6 @@ -import {useCallback} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; + +import {Text} from '@sentry/scraps/text'; import type {OAuthCallbackData} from 'sentry/components/pipeline/shared/oauthLoginStep'; import {OAuthLoginStep} from 'sentry/components/pipeline/shared/oauthLoginStep'; @@ -10,12 +12,24 @@ import {pipelineComplete} from 'sentry/components/pipeline/types'; import {t} from 'sentry/locale'; import type {IntegrationWithConfig} from 'sentry/types/integrations'; +type DiscordOAuthStepData = + | { + appDirectoryInstall: true; + code: string; + guildId: string; + state: string; + } + | { + appDirectoryInstall?: false; + oauthUrl?: string; + }; + function DiscordOAuthLoginStep({ stepData, advance, isAdvancing, }: PipelineStepProps< - {oauthUrl?: string}, + DiscordOAuthStepData, {code: string; guildId: string; state: string} >) { const handleOAuthCallback = useCallback( @@ -25,6 +39,26 @@ function DiscordOAuthLoginStep({ [advance] ); + // App Directory installs arrive with OAuth already complete. The backend + // signals this by returning `appDirectoryInstall` in step data along with + // the values to advance with — no popup, no user interaction. + const hasAutoAdvanced = useRef(false); + useEffect(() => { + if (!stepData?.appDirectoryInstall || hasAutoAdvanced.current) { + return; + } + hasAutoAdvanced.current = true; + advance({ + code: stepData.code, + guildId: stepData.guildId, + state: stepData.state, + }); + }, [stepData, advance]); + + if (stepData?.appDirectoryInstall) { + return {t('Finishing up Discord integration installation...')}; + } + return ( /configure/` backend endpoint. + */ export default function IntegrationOrganizationLink() { const location = useLocation(); const {integrationSlug} = useParams<{integrationSlug: string}>(); @@ -155,8 +178,9 @@ export default function IntegrationOrganizationLink() { const {startFlow} = useAddIntegration(); // Lands the user on the integration's settings page after a successful - // API-driven install. Used as `startFlow`'s `onInstall` callback. - const onInstallWithInstallationId = useCallback( + // API-driven install. Used as `startFlow`'s `onInstall` callback by both + // the GitHub App listing and Discord App Directory entry points. + const onInstall = useCallback( (data: Integration) => { const orgId = organization?.slug; const normalizedUrl = normalizeUrl( @@ -169,6 +193,31 @@ export default function IntegrationOrganizationLink() { [organization] ); + // GitHub App listing installs arrive here with `installationId` as a URL + // path segment. The install button uses this as `initialData` for the + // pipeline modal. + const gitHubAppListingParams = useMemo | null>(() => { + if (integrationSlug !== 'github' || !installationId) { + return null; + } + return {installation_id: installationId}; + }, [integrationSlug, installationId]); + + // Discord App Directory installs arrive here with `code` and `guild_id` in + // the URL query (forwarded from `/extensions/discord/configure/`). The + // install button uses these as `initialData` for the pipeline modal. + const discordAppDirectoryParams = useMemo | null>(() => { + if (integrationSlug !== 'discord') { + return null; + } + const code = location.query.code; + const guildId = location.query.guild_id; + if (typeof code !== 'string' || typeof guildId !== 'string') { + return null; + } + return {code, guild_id: guildId, use_configure: '1'}; + }, [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 @@ -194,17 +243,12 @@ export default function IntegrationOrganizationLink() { return; } - // GitHub is the only provider currently driven through the API pipeline - // modal from this view — users land here after installing the Sentry - // GitHub App from github.com, with the `installationId` in the URL query - // (forwarded from `/extensions/external-install/...`). - if (provider.key === 'github' && installationId) { - startFlow({ - provider, - organization, - onInstall: onInstallWithInstallationId, - urlParams: {installation_id: installationId}, - }); + // 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; + if (urlParams) { + startFlow({provider, organization, onInstall, urlParams}); return; } @@ -212,9 +256,10 @@ export default function IntegrationOrganizationLink() { }, [ provider, organization, - installationId, + gitHubAppListingParams, + discordAppDirectoryParams, startFlow, - onInstallWithInstallationId, + onInstall, finishLegacyInstallation, ]); From 27c2fe565d659c454adb5d5fbe0b15da6dafce9e Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Thu, 28 May 2026 13:59:49 -0700 Subject: [PATCH 55/60] fix(integrations): Use paginated jira projects endpoint in another place (#116418) #116327 used the paginated Jira project endpoint, but we were also using the unpaginated version in `_filter_active_projects`. Instead of grabbing the whole list, we can search just the project ids we have set up, also using the paginated endpoint. --- src/sentry/integrations/jira/integration.py | 27 ++++----- .../integrations/jira/test_integration.py | 59 +++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index b4c73d81c57c..06cd410a15c7 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -38,7 +38,6 @@ from sentry.issues.grouptype import GroupCategory from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.group import Group -from sentry.organizations.services.organization.service import organization_service from sentry.pipeline.views.base import PipelineView from sentry.services.eventstore.models import GroupEvent from sentry.shared_integrations.exceptions import ( @@ -164,20 +163,10 @@ def use_email_scope(cls): def get_organization_config(self) -> list[dict[str, Any]]: configuration: list[dict[str, Any]] = self._get_organization_config_default_values() - context = organization_service.get_organization_by_id( - id=self.organization_id, include_projects=False, include_teams=False - ) - assert context, "organizationcontext must exist to get org" - organization = context.organization - client = self.get_client() try: - if features.has("organizations:jira-paginated-project-config", organization): - # Use the paginated endpoint to avoid fetching all projects at once, - # which can time out for large Jira instances. The settings page - # dropdown search (typeahead) handles finding projects beyond this - # initial page via JiraSearchEndpoint. + if features.has("organizations:jira-paginated-project-config", self.organization): projects_response = client.get_projects_paginated(params={"maxResults": 50}) projects: list[JiraProjectMapping] = [ JiraProjectMapping(value=p["id"], label=p["name"]) @@ -196,7 +185,7 @@ def get_organization_config(self) -> list[dict[str, Any]]: "Unable to communicate with the Jira instance. You may need to reinstall the addon." ) - has_issue_sync = features.has("organizations:integrations-issue-sync", organization) + has_issue_sync = features.has("organizations:integrations-issue-sync", self.organization) if not has_issue_sync: for field in configuration: field["disabled"] = True @@ -398,8 +387,18 @@ def update_organization_config(self, data): self.org_integration = org_integration def _filter_active_projects(self, project_mappings: QuerySet[IntegrationExternalProject]): - project_ids_set = {p["id"] for p in self.get_client().get_projects_list()} + client = self.get_client() + if features.has("organizations:jira-paginated-project-config", self.organization): + project_ids = [pm.external_id for pm in project_mappings] + if not project_ids: + return [] + response = client.get_projects_paginated( + params={"id": project_ids, "maxResults": len(project_ids)} + ) + active_ids = {p["id"] for p in response.get("values", [])} + return [pm for pm in project_mappings if pm.external_id in active_ids] + project_ids_set = {p["id"] for p in client.get_projects_list()} return [pm for pm in project_mappings if pm.external_id in project_ids_set] def get_config_data(self): diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py index 19829ea17710..1f1db6534026 100644 --- a/tests/sentry/integrations/jira/test_integration.py +++ b/tests/sentry/integrations/jira/test_integration.py @@ -1259,6 +1259,65 @@ def test_get_config_data(self) -> None: "issues_ignored_fields": "", } + @responses.activate + def test_get_config_data_filters_via_paginated_endpoint_with_flag(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + org_integration = OrganizationIntegration.objects.get( + organization_id=self.organization.id, integration_id=integration.id + ) + + org_integration.config = { + "sync_comments": True, + "sync_forward_assignment": True, + "sync_reverse_assignment": True, + "sync_status_reverse": True, + "sync_status_forward": True, + } + org_integration.save() + + IntegrationExternalProject.objects.create( + organization_integration_id=org_integration.id, + external_id="12345", + unresolved_status="in_progress", + resolved_status="done", + ) + IntegrationExternalProject.objects.create( + organization_integration_id=org_integration.id, + external_id="67890", + unresolved_status="in_progress", + resolved_status="done", + ) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={"values": [{"id": "12345", "name": "Active Project"}]}, + ) + + installation = integration.get_installation(self.organization.id) + + with self.feature("organizations:jira-paginated-project-config"): + config = installation.get_config_data() + + assert config["sync_status_forward"] == { + "12345": {"on_resolve": "done", "on_unresolve": "in_progress"}, + } + assert len(responses.calls) == 1 + assert "rest/api/2/project/search" in responses.calls[0].request.url + assert "id=12345" in responses.calls[0].request.url + assert "id=67890" in responses.calls[0].request.url + @responses.activate def test_get_config_data_issue_keys(self) -> None: integration = self.create_provider_integration( From aea992fd09d99bae2320c8da52ba98e5d7780227 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 28 May 2026 13:17:42 -0800 Subject: [PATCH 56/60] fix(jest): exclude scripts/ from discovery and module resolution (#116413) --- jest.config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index 03c34f6c609b..81bfa6152746 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -323,7 +323,15 @@ const config: Config.InitialOptions = { '/tests/js/setupFramework.ts', ], testMatch: testMatch || ['/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'], - testPathIgnorePatterns: ['/tests/sentry/lang/javascript/'], + testPathIgnorePatterns: [ + '/tests/sentry/lang/javascript/', + // ESM-style helper scripts (e.g. scripts/genPlatformProductInfo.ts use + // `const __dirname = path.dirname(fileURLToPath(import.meta.url))`) that + // SWC's CJS transform redeclares — collides with Node's module wrapper. + // None of these are tests; keep them out of Jest's discovery entirely. + '/scripts/', + ], + modulePathIgnorePatterns: ['/scripts/'], unmockedModulePathPatterns: [ '/node_modules/react', From 75573322718a46b3e06af237ae1a592148ae4c5e Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Thu, 28 May 2026 14:18:14 -0700 Subject: [PATCH 57/60] feat(issues): Fully enable recording of Seer actions as issue activities (with option) (#116424) https://linear.app/getsentry/project/add-seer-actions-to-issue-activityaction-log-0e641e1f5dac/overview Replaces the per-org `seer-activity-timeline` feature flag check with a global `issues.record-seer-actions-as-activities` option for gating the recording of Seer actions as issue activities in the backend, using the Seer webhook. This PR enables recording Seer activities (RCA, solution, coding, PR creation) for all organizations by default, while retaining an option-based killswitch. The existing `seer-activity-timeline` feature flag is preserved and will be repurposed in a follow-up frontend PR to gate only the *display* of these activities in the issue details timeline. --- src/sentry/options/defaults.py | 9 ++ src/sentry/seer/entrypoints/operator.py | 5 +- .../sentry/seer/entrypoints/test_operator.py | 82 +++++++++---------- 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 7cbbc0beb104..1d4628b1d7cb 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -4204,3 +4204,12 @@ type=Bool, flags=FLAG_NOSTORE, ) + +# Allows the recording of Seer actions as issue activities +# https://linear.app/getsentry/project/add-seer-actions-to-issue-activityaction-log-0e641e1f5dac/overview +register( + "issues.record-seer-actions-as-activities", + default=True, + type=Bool, + flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, +) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 1c2aae679743..10fb2b8625ff 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -1,7 +1,7 @@ import logging from typing import Any -from sentry import features +from sentry import features, options from sentry.constants import DataCategory from sentry.models.activity import Activity from sentry.models.group import Group @@ -564,8 +564,7 @@ def _create_seer_activity( if not activity_type: return - organization = group.project.organization - if not features.has("organizations:seer-activity-timeline", organization): + if not options.get("issues.record-seer-actions-as-activities"): return run_id = event_payload.get("run_id") diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 01eaf538112a..99c6015e86dd 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -37,7 +37,7 @@ from sentry.sentry_apps.metrics import SentryAppEventType from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import TestCase -from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.options import override_options from sentry.types.activity import ActivityType @@ -449,12 +449,11 @@ def test_seer_event_creates_activity_rca_completed(self, _mock_has_access): }, } - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=SentryAppEventType.SEER_ROOT_CAUSE_COMPLETED, + event_payload=event_payload, + organization_id=self.organization.id, + ) activity = Activity.objects.get( group=self.group, type=ActivityType.SEER_RCA_COMPLETED.value @@ -475,12 +474,11 @@ def test_seer_event_creates_activity_solution_completed(self, _mock_has_access): }, } - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=SentryAppEventType.SEER_SOLUTION_COMPLETED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=SentryAppEventType.SEER_SOLUTION_COMPLETED, + event_payload=event_payload, + organization_id=self.organization.id, + ) activity = Activity.objects.get( group=self.group, type=ActivityType.SEER_SOLUTION_COMPLETED.value @@ -498,12 +496,11 @@ def test_seer_event_creates_activity_coding_completed(self, _mock_has_access): "code_changes": {"getsentry/sentry": [{"diff": "...", "path": "foo.py"}]}, } - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=SentryAppEventType.SEER_CODING_COMPLETED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=SentryAppEventType.SEER_CODING_COMPLETED, + event_payload=event_payload, + organization_id=self.organization.id, + ) activity = Activity.objects.get( group=self.group, type=ActivityType.SEER_CODING_COMPLETED.value @@ -516,12 +513,11 @@ def test_seer_event_creates_activity_coding_completed(self, _mock_has_access): def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access): for seer_event, expected_activity_type in SEER_EVENT_TO_ACTIVITY_TYPE.items(): event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=seer_event, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=seer_event, + event_payload=event_payload, + organization_id=self.organization.id, + ) assert Activity.objects.filter( group=self.group, type=expected_activity_type.value ).exists(), f"Activity not created for {seer_event}" @@ -530,25 +526,25 @@ def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access): def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access): event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=SentryAppEventType.ISSUE_CREATED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=SentryAppEventType.ISSUE_CREATED, + event_payload=event_payload, + organization_id=self.organization.id, + ) seer_type_values = [t.value for t in SEER_EVENT_TO_ACTIVITY_TYPE.values()] assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_feature_flag_disabled(self, _mock_has_access): + def test_create_seer_activity_option_disabled(self, _mock_has_access): event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} - process_autofix_updates( - event_type=SentryAppEventType.SEER_ROOT_CAUSE_STARTED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + with override_options({"issues.record-seer-actions-as-activities": False}): + process_autofix_updates( + event_type=SentryAppEventType.SEER_ROOT_CAUSE_STARTED, + event_payload=event_payload, + organization_id=self.organization.id, + ) seer_type_values = [t.value for t in SEER_EVENT_TO_ACTIVITY_TYPE.values()] assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @@ -570,12 +566,11 @@ def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_acce ], } - with self.feature("organizations:seer-activity-timeline"): - process_autofix_updates( - event_type=SentryAppEventType.SEER_PR_CREATED, - event_payload=event_payload, - organization_id=self.organization.id, - ) + process_autofix_updates( + event_type=SentryAppEventType.SEER_PR_CREATED, + event_payload=event_payload, + organization_id=self.organization.id, + ) activity = Activity.objects.get(group=self.group, type=ActivityType.SEER_PR_CREATED.value) assert activity.data["pull_requests"][0]["repo_name"] == "owner/repo" @@ -584,7 +579,6 @@ def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_acce == "https://github.com/owner/repo/pull/42" ) - @with_feature("organizations:seer-activity-timeline") @patch("sentry.seer.entrypoints.operator.invoke_workflow_activity_handlers") @patch.object(SeerAutofixOperator, "has_access", return_value=True) def test_create_seer_activity_invokes_workflow_activity_handlers( From 9537b8a988cd617d6a822628b3b57800267b30cb Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Thu, 28 May 2026 17:20:34 -0400 Subject: [PATCH 58/60] ref(apigateway): use a threadlocal session for proxy requests (#116054) Use a rollout-gated, thread-local `requests.Session` for synchronous API gateway proxy requests so the gateway can reuse HTTP connections without sharing a session object across request-handler threads. Same as https://github.com/getsentry/sentry/pull/115827 which we saw helped quite a bit with RPC latency, but on our api gateway cross silo requests. Slack Context: https://sentry.slack.com/archives/C0B40RRUCT0/p1779391427679709 **Rollout Control** Pooling now uses `hybridcloud.apigateway.use_pooling.rate` as a sampled rollout rate, matching the RPC pooling rollout behavior instead of treating any non-zero value as full enablement. **Cookie Handling** The pooled session uses a stateless cookie jar so backend `Set-Cookie` headers are still returned to the client, incoming request `Cookie` headers are still forwarded to the cell, and response cookies are not remembered for later requests on the same thread. --------- Co-authored-by: Codex Co-authored-by: Codex Co-authored-by: Claude --- src/sentry/hybridcloud/apigateway/proxy.py | 47 ++++++++-- src/sentry/options/defaults.py | 6 ++ .../hybridcloud/apigateway/test_proxy.py | 85 +++++++++++++++++++ 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/sentry/hybridcloud/apigateway/proxy.py b/src/sentry/hybridcloud/apigateway/proxy.py index 6981fc0ef113..2558bb71e158 100644 --- a/src/sentry/hybridcloud/apigateway/proxy.py +++ b/src/sentry/hybridcloud/apigateway/proxy.py @@ -5,7 +5,10 @@ from __future__ import annotations import logging -from collections.abc import Generator +from collections.abc import Callable, Generator +from http.cookiejar import Cookie +from threading import local +from typing import Any from urllib.parse import urljoin, urlparse from wsgiref.util import is_hop_by_hop @@ -13,12 +16,15 @@ from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse from django.http.response import HttpResponseBase from requests import Response as ExternalResponse +from requests import Session from requests import request as external_request +from requests.cookies import RequestsCookieJar from requests.exceptions import ConnectionError, Timeout from sentry import options from sentry.api.exceptions import RequestTimeout from sentry.objectstore.endpoints.organization import ChunkedEncodingDecoder, get_raw_body +from sentry.options.rollout import in_random_rollout from sentry.silo.util import ( PROXY_APIGATEWAY_HEADER, PROXY_DIRECT_LOCATION_HEADER, @@ -55,6 +61,24 @@ # stream 0.5 MB at a time PROXY_CHUNK_SIZE = 512 * 1024 +_connection = local() + + +class _StatelessCookieJar(RequestsCookieJar): + def set_cookie(self, cookie: Cookie, *args: Any, **kwargs: Any) -> None: + return None + + def extract_cookies(self, response: Any, request: Any) -> None: + return None + + +def _get_connection() -> Session: + if not hasattr(_connection, "session"): + session = Session() + session.cookies = _StatelessCookieJar() + _connection.session = session + return _connection.session + def _parse_response(response: ExternalResponse, remote_url: str) -> StreamingHttpResponse: """ @@ -62,7 +86,10 @@ def _parse_response(response: ExternalResponse, remote_url: str) -> StreamingHtt """ def stream_response() -> Generator[bytes]: - yield from response.iter_content(PROXY_CHUNK_SIZE) + try: + yield from response.iter_content(PROXY_CHUNK_SIZE) + finally: + response.close() streamed_response = StreamingHttpResponse( streaming_content=stream_response(), @@ -126,6 +153,8 @@ def proxy_cell_request(request: HttpRequest, cell: Cell, url_name: str) -> HttpR metric_tags = {"destination_cell": cell.name, "url_name": url_name} circuit_breaker: CircuitBreaker | None = None + use_pooling = in_random_rollout("hybridcloud.apigateway.use_pooling.rate") + # TODO(mark) remove rollout options if options.get("apigateway.proxy.circuit-breaker.enabled"): try: @@ -158,7 +187,6 @@ def proxy_cell_request(request: HttpRequest, cell: Cell, url_name: str) -> HttpR header_dict = clean_proxy_headers(request.headers) header_dict[PROXY_APIGATEWAY_HEADER] = "true" - # TODO: use requests session for connection pooling capabilities assert request.method is not None query_params = request.GET @@ -180,9 +208,15 @@ def proxy_cell_request(request: HttpRequest, cell: Cell, url_name: str) -> HttpR else: data = BodyWithLength(request) + # When pooling is enabled, reuse the thread-local session to keep connections + # alive across requests; otherwise issue a one-off request. + requester: Callable[..., ExternalResponse] = ( + _get_connection().request if use_pooling else external_request + ) + try: with metrics.timer("apigateway.proxy_request.duration", tags=metric_tags): - resp = external_request( + resp = requester( request.method, url=target_url, headers=header_dict, @@ -190,9 +224,8 @@ def proxy_cell_request(request: HttpRequest, cell: Cell, url_name: str) -> HttpR data=data, stream=True, timeout=timeout, - # By default, external_request will resolve any redirects for any verb except for HEAD. - # We explicitly disable this behavior to avoid misrepresenting the original sentry.io request with the - # body response of the redirect. + # By default, requests resolves redirects for every verb except HEAD. + # Disable that to avoid misrepresenting the original sentry.io request. allow_redirects=False, ) except Timeout: diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 1d4628b1d7cb..c5cdf8635650 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2488,6 +2488,12 @@ default={}, flags=FLAG_AUTOMATOR_MODIFIABLE, ) +register( + "hybridcloud.apigateway.use_pooling.rate", + default=0.0, + type=Float, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) # Webhook processing controls register( diff --git a/tests/sentry/hybridcloud/apigateway/test_proxy.py b/tests/sentry/hybridcloud/apigateway/test_proxy.py index 68bbabf46459..5a145f0e8db1 100644 --- a/tests/sentry/hybridcloud/apigateway/test_proxy.py +++ b/tests/sentry/hybridcloud/apigateway/test_proxy.py @@ -1,10 +1,16 @@ +from collections.abc import Generator +from unittest.mock import Mock from urllib.parse import urlencode import httpx +import pytest +import responses from asgiref.sync import async_to_sync from django.core.files.uploadedfile import SimpleUploadedFile from django.test.client import RequestFactory +from requests import PreparedRequest +from sentry.hybridcloud.apigateway import proxy as sync_proxy from sentry.hybridcloud.apigateway_async.proxy import proxy_request as _proxy_request from sentry.silo.util import ( INVALID_OUTBOUND_HEADERS, @@ -17,6 +23,7 @@ verify_request_body, verify_request_headers, ) +from sentry.testutils.helpers.options import override_options from sentry.testutils.helpers.response import close_streaming_response from sentry.testutils.silo import control_silo_test from sentry.utils import json @@ -25,8 +32,86 @@ url_name = "sentry-api-0-projets" +@pytest.fixture(autouse=True) +def close_sync_proxy_connection() -> Generator[None]: + # The proxy reuses a thread-local requests.Session for connection pooling. + # Reset it between tests so pooled connections and cookie state don't leak. + yield + connection = sync_proxy._connection + if hasattr(connection, "session"): + connection.session.close() + del connection.session + + +def test_sync_response_closes_upstream_after_streaming() -> None: + response = Mock() + response.headers = {"Content-Type": "application/json"} + response.iter_content.return_value = iter([b'{"proxy": true}']) + response.status_code = 200 + + streaming_response = sync_proxy._parse_response(response, "http://us.internal.sentry.io/test") + assert close_streaming_response(streaming_response) == b'{"proxy": true}' + response.close.assert_called_once_with() + + @control_silo_test(cells=[ApiGatewayTestCase.CELL], include_monolith_run=True) class ProxyTestCase(ApiGatewayTestCase): + @responses.activate + def test_sync_pooling_does_not_persist_response_cookies(self) -> None: + responses.add( + responses.GET, + "http://us.internal.sentry.io/sets-cookie", + body=json.dumps({"proxy": True}), + content_type="application/json", + headers={"Set-Cookie": "cell_session=leaked; Path=/"}, + ) + + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + assert "cell_session=leaked" not in request.headers.get("Cookie", "") + return 200, {"Content-Type": "application/json"}, json.dumps({"proxy": True}) + + responses.add_callback( + responses.GET, + "http://us.internal.sentry.io/without-cookie", + callback=request_callback, + ) + + with override_options({"hybridcloud.apigateway.use_pooling.rate": 1.0}): + first_request = RequestFactory().get("http://sentry.io/sets-cookie") + first_response = sync_proxy.proxy_request( + first_request, self.organization.slug, url_name + ) + assert first_response.status_code == 200 + assert first_response["Set-Cookie"] == "cell_session=leaked; Path=/" + close_streaming_response(first_response) + + second_request = RequestFactory().get("http://sentry.io/without-cookie") + second_response = sync_proxy.proxy_request( + second_request, self.organization.slug, url_name + ) + assert second_response.status_code == 200 + close_streaming_response(second_response) + + @responses.activate + def test_sync_pooling_preserves_incoming_request_cookies(self) -> None: + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + assert request.headers.get("Cookie") == "original=1" + return 200, {"Content-Type": "application/json"}, json.dumps({"proxy": True}) + + responses.add_callback( + responses.GET, + "http://us.internal.sentry.io/with-cookie", + callback=request_callback, + ) + + with override_options({"hybridcloud.apigateway.use_pooling.rate": 1.0}): + request = RequestFactory().get( + "http://sentry.io/with-cookie", headers={"Cookie": "original=1"} + ) + response = sync_proxy.proxy_request(request, self.organization.slug, url_name) + assert response.status_code == 200 + close_streaming_response(response) + def test_simple(self) -> None: request = RequestFactory().get("http://sentry.io/get") resp = proxy_request(request, self.organization.slug, url_name) From 12caeec8e076217694a54539904aff73f237bd81 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Thu, 28 May 2026 14:36:47 -0700 Subject: [PATCH 59/60] chore(typing): Remove 9 zero-error modules from mypy ignore list (#116430) These modules already pass strict mypy checks (disallow_any_generics, disallow_untyped_defs, strict_equality) with zero errors, so they no longer need to be in the relaxed override list. Removed: `sentry.app`, `sentry.asgi`, `sentry.locks`, `sentry.services.base`, `sentry.shared_integrations.constants`, `sentry.spans.debug_trace_logger`, `sentry.spans.segment_key`, `sentry.unmerge`, `sentry.wsgi` --- pyproject.toml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8307e6340a45..e062d8d96393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -707,8 +707,6 @@ module = [ "sentry.apidocs.schema", "sentry.apidocs.spectacular_ports", "sentry.apidocs.utils", - "sentry.app", - "sentry.asgi", "sentry.assistant.*", "sentry.attachments.*", "sentry.audit_log", @@ -1048,7 +1046,6 @@ module = [ "sentry.lang.javascript.*", "sentry.lang.native.*", "sentry.loader.*", - "sentry.locks", "sentry.logging.*", "sentry.management.*", "sentry.middleware", @@ -1381,14 +1378,12 @@ module = [ "sentry.sentry_metrics.use_case_utils", "sentry.sentry_metrics.utils", "sentry.services", - "sentry.services.base", "sentry.services.eventstore", "sentry.services.eventstore.query_preprocessing", "sentry.services.filestore.*", "sentry.services.http", "sentry.services.organization.*", "sentry.shared_integrations", - "sentry.shared_integrations.constants", "sentry.shared_integrations.response.*", "sentry.signals", "sentry.similarity.*", @@ -1408,10 +1403,8 @@ module = [ "sentry.spans.buffer_logger", "sentry.spans.consumers", "sentry.spans.consumers.process.*", - "sentry.spans.debug_trace_logger", "sentry.spans.gcp_log_analyzer", "sentry.spans.log_analyzer.*", - "sentry.spans.segment_key", "sentry.stacktraces", "sentry.stacktraces.functions", "sentry.stacktraces.processing", @@ -1525,7 +1518,6 @@ module = [ "sentry.toolbar", "sentry.toolbar.views.*", "sentry.tsdb.*", - "sentry.unmerge", "sentry.uptime", "sentry.uptime.apps", "sentry.uptime.autodetect.*", @@ -1679,7 +1671,6 @@ module = [ "sentry.web.helpers", "sentry.web.urls", "sentry.web_vitals.*", - "sentry.wsgi", "sentry_plugins", "sentry_plugins.amazon_sqs.*", "sentry_plugins.anonymizeip", From a9f7880e7f4273c1eb8383acb2976a51507054d3 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 17:46:37 -0400 Subject: [PATCH 60/60] ref(github-enterprise): Remove legacy pipeline setup views (#116436) Now that the API-driven pipeline is in place, drop the legacy `InstallationConfigView`, `InstallationForm`, and `GitHubEnterpriseInstallationRedirect` classes (and the `_make_identity_pipeline_view` helper), the associated Django template, and the legacy-flow tests. `get_pipeline_views()` now returns an empty list; `get_pipeline_api_steps()` is the only setup path. `build_integration` now reads identity exclusively from `state["oauth_data"]`. Refs [VDY-32](https://linear.app/getsentry/issue/VDY-32/migrate-integration-setup-pipelines-to-api-driven-flows) --- .../github_enterprise/integration.py | 184 +-------- .../github-enterprise-config.html | 32 -- .../github_enterprise/test_integration.py | 369 ++---------------- 3 files changed, 34 insertions(+), 551 deletions(-) delete mode 100644 src/sentry/templates/sentry/integrations/github-enterprise-config.html diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 7bca6e3c7c12..35eb55121109 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -1,12 +1,10 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence +from collections.abc import Iterable, Mapping, MutableMapping from typing import Any from urllib.parse import urlparse -from django import forms from django.http.request import HttpRequest -from django.http.response import HttpResponseBase, HttpResponseRedirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ from rest_framework.fields import BooleanField, CharField, URLField @@ -14,7 +12,6 @@ from sentry import features, http from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.identity.oauth2 import OAuth2ApiStep -from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -51,7 +48,6 @@ from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView -from sentry.pipeline.views.nested import NestedPipelineView from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED from sentry.shared_integrations.exceptions import ( ApiError, @@ -61,7 +57,6 @@ ) from sentry.utils import jwt, metrics from sentry.utils.http import absolute_uri -from sentry.web.helpers import render_to_response from .client import GitHubEnterpriseApiClient from .repository import GitHubEnterpriseRepositoryProvider @@ -654,126 +649,6 @@ def handle_post( return PipelineStepResult.advance() -class InstallationForm(forms.Form): - url = forms.CharField( - label="Installation Url", - help_text=_( - 'The "base URL" for your GitHub instance — e.g. https://github.com (for a ' - "custom GitHub App on github.com), https://acme-corp.ghe.com (GitHub Enterprise " - "Cloud), or https://github.example.com (GitHub Enterprise Server)." - ), - widget=forms.TextInput(attrs={"placeholder": "https://github.example.com"}), - ) - id = forms.CharField( - label="GitHub App ID", - help_text=_( - "The App ID of your Sentry app. This can be found on your apps configuration page." - ), - widget=forms.TextInput(attrs={"placeholder": "1"}), - ) - name = forms.CharField( - label="GitHub App Name", - help_text=_( - "The GitHub App name of your Sentry app. " - "This can be found on the apps configuration " - "page." - ), - widget=forms.TextInput(attrs={"placeholder": "our-sentry-app"}), - ) - public_link = forms.URLField( - label="Public Link (GitHub Enterprise Server only)", - help_text=_("The publicly available link for your GitHub App in GitHub Enterprise Server"), - widget=forms.TextInput(attrs={"placeholder": "https://github.example.com"}), - required=False, - assume_scheme="https", - ) - verify_ssl = forms.BooleanField( - label=_("Verify SSL"), - help_text=_( - "By default, we verify SSL certificates " - "when delivering payloads to your GitHub " - "Enterprise instance" - ), - widget=forms.CheckboxInput(), - required=False, - ) - webhook_secret = forms.CharField( - label="GitHub App Webhook Secret", - help_text=_( - "We require a webhook secret to be " - "configured. This can be generated as any " - "random string value of your choice and " - "should match your GitHub app " - "configuration." - ), - widget=forms.TextInput(attrs={"placeholder": "XXXXXXXXXXXXXXXXXXXXXXXXXXX"}), - ) - private_key = forms.CharField( - label="GitHub App Private Key", - help_text=_("The Private Key generated for your Sentry GitHub App."), - widget=forms.Textarea( - attrs={ - "rows": "60", - "placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", - } - ), - ) - client_id = forms.CharField( - label="GitHub App OAuth Client ID", widget=forms.TextInput(attrs={"placeholder": "1"}) - ) - client_secret = forms.CharField( - label="GitHub App OAuth Client Secret", - widget=forms.TextInput(attrs={"placeholder": "XXXXXXXXXXXXXXXXXXXXXXXXXXX"}), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["verify_ssl"].initial = True - - -class InstallationConfigView: - 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 - parsed = urlparse(form_data["url"]) - # Tolerate input without a scheme — `urlparse("github.com").netloc` is empty - # and the host lands in `path`. Without this, OAuth URLs become malformed - # (`https:///login/...`). - form_data["url"] = (parsed.netloc or parsed.path).strip("/").lower() - - if not form_data["public_link"]: - form_data["public_link"] = None - - pipeline.bind_state("installation_data", form_data) - - pipeline.bind_state( - "oauth_config_information", - { - "access_token_url": "https://{}/login/oauth/access_token".format( - form_data.get("url") - ), - "authorize_url": "https://{}/login/oauth/authorize".format( - form_data.get("url") - ), - "client_id": form_data.get("client_id"), - "client_secret": form_data.get("client_secret"), - "verify_ssl": form_data.get("verify_ssl"), - }, - ) - - return pipeline.next_step() - else: - form = InstallationForm() - - return render_to_response( - template="sentry/integrations/github-enterprise-config.html", - context={"form": form}, - request=request, - ) - - class GitHubEnterpriseIntegrationProvider(GitHubIntegrationProvider): key = IntegrationProviderSlug.GITHUB_ENTERPRISE.value name = "GitHub Enterprise" @@ -789,40 +664,8 @@ class GitHubEnterpriseIntegrationProvider(GitHubIntegrationProvider): ] ) - def _make_identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]: - """ - Make the nested identity provider view. It is important that this view is - not constructed until we reach this step and the - ``oauth_config_information`` is available in the pipeline state. This - method should be late bound into the pipeline vies. - """ - oauth_information = self.pipeline.fetch_state("oauth_config_information") - if oauth_information is None: - raise AssertionError("pipeline called out of order") - - identity_pipeline_config = dict( - oauth_scopes=(), - redirect_url=absolute_uri("/extensions/github-enterprise/setup/"), - **oauth_information, - ) - - return NestedPipelineView( - bind_key="identity", - provider_key=IntegrationProviderSlug.GITHUB_ENTERPRISE.value, - pipeline_cls=IdentityPipeline, - config=identity_pipeline_config, - ) - - def get_pipeline_views( - self, - ) -> Sequence[ - PipelineView[IntegrationPipeline] | Callable[[], PipelineView[IntegrationPipeline]] - ]: - return ( - InstallationConfigView(), - GitHubEnterpriseInstallationRedirect(), - lambda: self._make_identity_pipeline_view(), - ) + def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: + return [] def _make_oauth_api_step(self) -> OAuth2ApiStep: oauth_info = self.pipeline.fetch_state("oauth_config_information") @@ -894,13 +737,7 @@ def _get_ghe_installation_info(self, installation_data, access_token, installati return None def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: - # TODO: legacy views write token data to state["identity"]["data"] via - # NestedPipelineView. API steps write directly to state["oauth_data"]. - # Remove the legacy path once the old views are retired. - if "oauth_data" in state: - identity = state["oauth_data"] - else: - identity = state["identity"]["data"] + identity = state["oauth_data"] installation_data = state["installation_data"] user = get_user_info(installation_data["url"], identity["access_token"]) installation = self._get_ghe_installation_info( @@ -943,16 +780,3 @@ def setup(self): GitHubEnterpriseRepositoryProvider, id="integrations:github_enterprise", ) - - -class GitHubEnterpriseInstallationRedirect: - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - installation_data = pipeline.fetch_state(key="installation_data") - if installation_data is None: - raise AssertionError("pipeline called out of order") - - if "installation_id" in request.GET: - pipeline.bind_state("installation_id", request.GET["installation_id"]) - return pipeline.next_step() - - return HttpResponseRedirect(_get_app_install_url(installation_data)) diff --git a/src/sentry/templates/sentry/integrations/github-enterprise-config.html b/src/sentry/templates/sentry/integrations/github-enterprise-config.html deleted file mode 100644 index 9689bad470a0..000000000000 --- a/src/sentry/templates/sentry/integrations/github-enterprise-config.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "sentry/bases/modal.html" %} -{% load crispy_forms_tags %} -{% load sentry_assets %} -{% load i18n %} - -{% block wrapperclass %} narrow auth {% endblock %} -{% block modal_header_signout %} {% endblock %} - -{% block title %} {% trans "GitHub Enterprise Setup" %} | {{ block.super }} {% endblock %} - -{% block main %} -

GitHub Enterprise Configuration

- -
- {% csrf_token %} - - -

- Configure GitHub Enterprise to use with Sentry.io. -

- - {{ form|as_crispy_errors }} - - {% for field in form %} - {{ field|as_crispy_field }} - {% endfor %} - -
- -
-
-{% endblock %} diff --git a/tests/sentry/integrations/github_enterprise/test_integration.py b/tests/sentry/integrations/github_enterprise/test_integration.py index 5f4651fafc8d..94b4197a1d68 100644 --- a/tests/sentry/integrations/github_enterprise/test_integration.py +++ b/tests/sentry/integrations/github_enterprise/test_integration.py @@ -5,21 +5,17 @@ from typing import Any from unittest import mock from unittest.mock import MagicMock, patch -from urllib.parse import parse_qs, urlencode, urlparse import orjson import pytest import responses -from django.test import RequestFactory from django.urls import reverse from sentry.integrations.github_enterprise.client import GitHubEnterpriseApiClient from sentry.integrations.github_enterprise.integration import ( GitHubEnterpriseIntegration, GitHubEnterpriseIntegrationProvider, - InstallationConfigView, _api_base_url, - _get_app_install_url, get_user_info, ) from sentry.integrations.models.integration import Integration @@ -36,7 +32,6 @@ from sentry.testutils.helpers import with_feature from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode, control_silo_test -from sentry.users.models.identity import Identity, IdentityProvider, IdentityStatus class ApiBaseUrlTest(TestCase): @@ -101,23 +96,11 @@ def stub_get_jwt_enterprise(self): def setUp(self) -> None: super().setUp() - self.config = { - "url": "https://github.example.org", - "id": 2, - "name": "test-app", - "client_id": "client_id", - "client_secret": "client_secret", - "webhook_secret": "webhook_secret", - "private_key": "private_key", - "verify_ssl": True, - } self.base_url = "https://github.example.org/api/v3" - # Add attributes needed for various tests self.access_token = "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx" self.expires_at = "3000-01-01T00:00:00Z" - # These will be set up in specific tests with assume_test_silo_mode(SiloMode.CELL): self.project = self.create_project() self.group = self.create_group() @@ -170,19 +153,17 @@ def _setup_assignee_sync_test( from sentry.users.services.user.serial import serialize_rpc_user user = serialize_rpc_user(self.create_user(email=user_email)) - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) - integration.metadata.update( + self.integration.metadata.update( { "access_token": self.access_token, "expires_at": self.expires_at, } ) - integration.save() + self.integration.save() installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) group = self.create_group() @@ -192,7 +173,7 @@ def _setup_assignee_sync_test( self.create_external_user( user=user, organization=self.organization, - integration=integration, + integration=self.integration, provider=ExternalProviders.GITHUB_ENTERPRISE.value, external_name=external_name, external_id=external_id, @@ -200,218 +181,17 @@ def _setup_assignee_sync_test( external_issue = self.create_integration_external_issue( group=group, - integration=integration, + integration=self.integration, key=issue_key, ) - return user, installation, external_issue, integration, group - - @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") - @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - def assert_setup_flow( - self, - get_jwt, - _, - installation_id="install_id_1", - app_id="app_1", - user_id="user_id_1", - public_link=None, - ): - responses.reset() - resp = self.client.get(self.init_path) - assert resp.status_code == 200 - resp = self.client.post(self.init_path, data=self.config) - assert resp.status_code == 302 - redirect = urlparse(resp["Location"]) - assert redirect.scheme == "https" - if public_link: - assert resp["Location"] == public_link - else: - assert redirect.netloc == "github.example.org" - assert redirect.path == "/github-apps/test-app" - - # App installation ID is provided, mveo thr - resp = self.client.get( - "{}?{}".format(self.setup_path, urlencode({"installation_id": installation_id})) - ) - - assert resp.status_code == 302 - redirect = urlparse(resp["Location"]) - assert redirect.scheme == "https" - assert redirect.netloc == "github.example.org" - assert redirect.path == "/login/oauth/authorize" - - params = parse_qs(redirect.query) - assert params["state"] - assert params["redirect_uri"] == ["http://testserver/extensions/github-enterprise/setup/"] - assert params["response_type"] == ["code"] - assert params["client_id"] == ["client_id"] - # once we've asserted on it, switch to a singular values to make life - # easier - authorize_params = {k: v[0] for k, v in params.items()} - - access_token = "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx" - - responses.add( - responses.POST, - "https://github.example.org/login/oauth/access_token", - json={"access_token": access_token}, - ) - - responses.add( - responses.POST, - self.base_url + f"/app/installations/{installation_id}/access_tokens", - json={"token": access_token, "expires_at": "3000-01-01T00:00:00Z"}, - ) - - responses.add(responses.GET, self.base_url + "/user", json={"id": user_id}) - - responses.add( - responses.GET, - self.base_url + f"/app/installations/{installation_id}", - json={ - "id": installation_id, - "app_id": app_id, - "account": { - "login": "Test Organization", - "type": "Organization", - "avatar_url": "https://github.example.org/avatar.png", - "html_url": "https://github.example.org/Test-Organization", - }, - }, - ) - - responses.add( - responses.GET, - self.base_url + "/user/installations", - json={"installations": [{"id": installation_id}]}, - ) - - responses.add( - method=responses.GET, - url=self.base_url + "/rate_limit", - json={ - "resources": { - "graphql": { - "limit": 5000, - "used": 1, - "remaining": 4999, - "reset": 1613064000, - } - } - }, - status=200, - content_type="application/json", - ) - - resp = self.client.get( - "{}?{}".format( - self.setup_path, - urlencode({"code": "oauth-code", "state": authorize_params["state"]}), - ) - ) - - mock_access_token_request = responses.calls[0].request - req_params = parse_qs(mock_access_token_request.body) - assert req_params["grant_type"] == ["authorization_code"] - assert req_params["code"] == ["oauth-code"] - assert req_params["redirect_uri"] == [ - "http://testserver/extensions/github-enterprise/setup/" - ] - assert req_params["client_id"] == ["client_id"] - assert req_params["client_secret"] == ["client_secret"] - - assert resp.status_code == 200 - - auth_header = responses.calls[2].request.headers["Authorization"] - assert auth_header == "Bearer jwt_token_1" - - self.assertDialogSuccess(resp) - - @responses.activate - def test_basic_flow(self) -> None: - self.assert_setup_flow() - - integration = Integration.objects.get(provider=self.provider.key) - - assert integration.external_id == "github.example.org:install_id_1" - assert integration.name == "Test Organization" - assert integration.metadata == { - "access_token": None, - "expires_at": None, - "icon": "https://github.example.org/avatar.png", - "domain_name": "github.example.org/Test-Organization", - "account_type": "Organization", - "installation_id": "install_id_1", - "installation": { - "client_id": "client_id", - "client_secret": "client_secret", - "id": "2", - "name": "test-app", - "private_key": "private_key", - "public_link": None, - "url": "github.example.org", - "webhook_secret": "webhook_secret", - "verify_ssl": True, - }, - } - oi = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id - ) - assert oi.config == {} - - idp = IdentityProvider.objects.get(type="github_enterprise") - identity = Identity.objects.get(idp=idp, user=self.user, external_id="user_id_1") - assert identity.status == IdentityStatus.VALID - assert identity.data == {"access_token": "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"} - - @responses.activate - def test_basic_flow__public_link(self) -> None: - public_link = "https://github.example.org/github/apps/test-app" - self.config["public_link"] = public_link - self.assert_setup_flow(public_link=public_link) - - integration = Integration.objects.get(provider=self.provider.key) - - assert integration.external_id == "github.example.org:install_id_1" - assert integration.name == "Test Organization" - assert integration.metadata == { - "access_token": None, - "expires_at": None, - "icon": "https://github.example.org/avatar.png", - "domain_name": "github.example.org/Test-Organization", - "account_type": "Organization", - "installation_id": "install_id_1", - "installation": { - "client_id": "client_id", - "client_secret": "client_secret", - "id": "2", - "name": "test-app", - "private_key": "private_key", - "public_link": public_link, - "url": "github.example.org", - "webhook_secret": "webhook_secret", - "verify_ssl": True, - }, - } - oi = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id - ) - assert oi.config == {} - - idp = IdentityProvider.objects.get(type="github_enterprise") - identity = Identity.objects.get(idp=idp, user=self.user, external_id="user_id_1") - assert identity.status == IdentityStatus.VALID - assert identity.data == {"access_token": "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"} + return user, installation, external_issue, self.integration, group @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_repositories_search_param(self, mock_jwtm: MagicMock, _: MagicMock) -> None: - with self.tasks(): - self.assert_setup_flow() - - querystring = urlencode({"q": "fork:true org:Test Organization ex"}) + querystring = "q=fork%3Atrue+org%3ATest+Organization+ex" responses.add( responses.GET, f"{self.base_url}/search/repositories?{querystring}", @@ -432,9 +212,8 @@ def test_get_repositories_search_param(self, mock_jwtm: MagicMock, _: MagicMock) ] }, ) - integration = Integration.objects.get(provider=self.provider.key) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) result = installation.get_repositories("ex") assert result == [ @@ -456,9 +235,6 @@ def test_get_repositories_search_param(self, mock_jwtm: MagicMock, _: MagicMock) @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_stacktrace_link_file_exists(self, get_jwt: MagicMock, _: MagicMock) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) - with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -467,7 +243,7 @@ def test_get_stacktrace_link_file_exists(self, get_jwt: MagicMock, _: MagicMock) provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) path = "README.md" @@ -478,7 +254,7 @@ def test_get_stacktrace_link_file_exists(self, get_jwt: MagicMock, _: MagicMock) self.base_url + f"/repos/{repo.name}/contents/{path}?ref={version}", ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) result = installation.get_stacktrace_link(repo, path, default, version) @@ -488,9 +264,6 @@ def test_get_stacktrace_link_file_exists(self, get_jwt: MagicMock, _: MagicMock) @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_stacktrace_link_file_doesnt_exists(self, get_jwt: MagicMock, _: MagicMock) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) - with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -499,7 +272,7 @@ def test_get_stacktrace_link_file_doesnt_exists(self, get_jwt: MagicMock, _: Mag provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) path = "README.md" version = "master" @@ -510,7 +283,7 @@ def test_get_stacktrace_link_file_doesnt_exists(self, get_jwt: MagicMock, _: Mag status=404, ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) result = installation.get_stacktrace_link(repo, path, default, version) @@ -522,9 +295,6 @@ def test_get_stacktrace_link_file_doesnt_exists(self, get_jwt: MagicMock, _: Mag def test_get_stacktrace_link_use_default_if_version_404( self, get_jwt: MagicMock, _: MagicMock ) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) - with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -533,7 +303,7 @@ def test_get_stacktrace_link_use_default_if_version_404( provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) path = "README.md" version = "12345678" @@ -548,7 +318,7 @@ def test_get_stacktrace_link_use_default_if_version_404( self.base_url + f"/repos/{repo.name}/contents/{path}?ref={default}", ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) result = installation.get_stacktrace_link(repo, path, default, version) @@ -558,8 +328,6 @@ def test_get_stacktrace_link_use_default_if_version_404( @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_commit_context_all_frames(self, _: MagicMock, __: MagicMock) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -568,10 +336,10 @@ def test_get_commit_context_all_frames(self, _: MagicMock, __: MagicMock) -> Non provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) file = SourceLineInfo( @@ -634,10 +402,8 @@ def test_get_commit_context_all_frames(self, _: MagicMock, __: MagicMock) -> Non @responses.activate def test_source_url_matches(self) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) test_cases = [ @@ -652,8 +418,6 @@ def test_source_url_matches(self) -> None: @responses.activate def test_extract_branch_from_source_url(self) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -662,10 +426,10 @@ def test_extract_branch_from_source_url(self) -> None: provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) source_url = "https://github.example.org/Test-Organization/foo/blob/master/src/sentry/integrations/github/integration.py" @@ -674,8 +438,6 @@ def test_extract_branch_from_source_url(self) -> None: @responses.activate def test_extract_source_path_from_source_url(self) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( organization_id=self.organization.id, @@ -684,10 +446,10 @@ def test_extract_source_path_from_source_url(self) -> None: provider="integrations:github_enterprise", external_id=123, config={"name": "Test-Organization/foo"}, - integration_id=integration.id, + integration_id=self.integration.id, ) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) source_url = "https://github.example.org/Test-Organization/foo/blob/master/src/sentry/integrations/github/integration.py" @@ -747,14 +509,12 @@ def test_get_organization_config(self) -> None: @responses.activate def test_update_organization_config(self) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) org_integration = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id + integration=self.integration, organization_id=self.organization.id ) # Initial config should be empty @@ -773,14 +533,12 @@ def test_update_organization_config(self) -> None: @responses.activate def test_update_organization_config_preserves_existing(self) -> None: - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) org_integration = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id + integration=self.integration, organization_id=self.organization.id ) org_integration.config = { @@ -963,26 +721,23 @@ def test_sync_assignee_outbound_strips_at_symbol(self) -> None: def test_sync_assignee_outbound_with_none_user(self) -> None: """Test that assigning with no user does not make an API call""" - self.assert_setup_flow() - integration = Integration.objects.get(provider=self.provider.key) - - integration.metadata.update( + self.integration.metadata.update( { "access_token": self.access_token, "expires_at": self.expires_at, } ) - integration.save() + self.integration.save() installation = get_installation_of_type( - GitHubEnterpriseIntegration, integration, self.organization.id + GitHubEnterpriseIntegration, self.integration, self.organization.id ) group = self.create_group() external_issue = self.create_integration_external_issue( group=group, - integration=integration, + integration=self.integration, key="Test-Organization/foo#123", ) @@ -1249,70 +1004,6 @@ def test_update_comment(self) -> None: ) -class InstallationConfigViewGitHubComInstallTest(TestCase): - """The form must accept github.com and GHES installs and route correctly.""" - - def _make_post_data(self, url: str) -> dict[str, str]: - return { - "url": url, - "id": "1", - "name": "test-app", - "client_id": "abc", - "client_secret": "secret", - "webhook_secret": "whsec", - "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBA...\n-----END RSA PRIVATE KEY-----", - "public_link": "", - } - - def test_github_com_install_allowed(self) -> None: - view = InstallationConfigView() - request = RequestFactory().post("/", data=self._make_post_data("https://github.com")) - request.user = self.user - pipeline = MagicMock() - pipeline.organization = self.organization - - view.dispatch(request, pipeline) - - pipeline.bind_state.assert_any_call( - "installation_data", - mock.ANY, - ) - pipeline.next_step.assert_called_once() - - def test_ghes_install_allowed(self) -> None: - view = InstallationConfigView() - request = RequestFactory().post( - "/", data=self._make_post_data("https://github.example.org") - ) - request.user = self.user - pipeline = MagicMock() - pipeline.organization = self.organization - - view.dispatch(request, pipeline) - pipeline.next_step.assert_called_once() - - def test_app_install_redirect_uses_apps_path_for_github_com(self) -> None: - # github.com hosts the App install page at /apps/{name}, not /github-apps/{name} - # (which is the GHES convention). Wrong URL → 404 → broken install flow. - assert ( - _get_app_install_url({"url": "github.com", "name": "my-app", "public_link": None}) - == "https://github.com/apps/my-app" - ) - assert ( - _get_app_install_url( - {"url": "github.example.org", "name": "my-app", "public_link": None} - ) - == "https://github.example.org/github-apps/my-app" - ) - # public_link, when set, takes precedence regardless of host - assert ( - _get_app_install_url( - {"url": "github.com", "name": "my-app", "public_link": "https://example.com/app"} - ) - == "https://example.com/app" - ) - - class BuildIntegrationGitHubComTest(TestCase): """build_integration must produce external_id with the 'github.com:' prefix so the github_enterprise integration can coexist with the first-party `github` integration @@ -1341,7 +1032,7 @@ def test_build_integration_produces_github_com_prefixed_external_id( provider = GitHubEnterpriseIntegrationProvider() state = { - "identity": {"data": {"access_token": "token-abc"}}, + "oauth_data": {"access_token": "token-abc"}, "installation_data": { "url": "github.com", "id": "1",