From 76616bad050b69a82795a28e0f827a6b1b609c76 Mon Sep 17 00:00:00 2001 From: Matt Duncan <14761+mrduncan@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:16:03 -0700 Subject: [PATCH 01/37] feat(search): Add experimental "recommended" issue sort (#111043) Add a new experimental sort option that combines normalized [0,1] signals additively to rank issues. This new sort is only available by manually changing the query parameter, it is not shown in the UI and cannot be saved to a view. Supported signals: - Recency: exponential decay based on time since last event with a 24hr half life - Spike: ratio of recent 6hr events to total 3d events - Severity: max log level - User impact: log-scaled unique user count maxing out at 1k - Event volume: log-scaled event count maxing out at 10k - Type boost: additive per-issue type boost (ex: uptime +0.15) Unlike the existing trends sort which uses multiplicative combination (where any near-zero factor kills the score), this uses additive combination so signals contribute independently. Weights are tunable via options since the initial values here are almost certainly not ideal. These signals were chosen primarily because they all exist within snuba and not because they are perfectly optimal, for example severity should ideally be the severity we derive via ML for an issue. A natural follow on would be to do reranking based on issue/user/team metadata. These options also give us the ability to fully remove some of these signals by setting their weight to reduce the number of signals used for ranking. The default weights here were manually tuned using the issue ranking experiment in the ai-workbench. --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/options/defaults.py | 31 +++ src/sentry/search/snuba/executors.py | 104 ++++++++-- tests/snuba/search/test_backend.py | 281 +++++++++++++++++++++++++++ 3 files changed, 404 insertions(+), 12 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 028c7f36986a52..15b6342ed80098 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -928,6 +928,37 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) register("snuba.search.hits-sample-size", default=100, flags=FLAG_AUTOMATOR_MODIFIABLE) +register( + "snuba.search.recommended.recency-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.spike-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.severity-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.user-impact-weight", + default=0.05, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.event-volume-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.group-type-boost", + type=Dict, + default={7001: 0.15}, + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) register("snuba.track-outcomes-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) # The percentage of tagkeys that we want to cache. Set to 1.0 in order to cache everything, <=0.0 to stop caching diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index e71c62fdc3cc6a..32ca5b06617737 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -269,7 +269,7 @@ def _prepare_aggregations( end: datetime, having: Sequence[Sequence[Any]], aggregate_kwargs: TrendsSortWeights | None = None, - replace_trends_aggregation: bool | None = False, + use_issue_platform: bool = False, ) -> list[Any]: extra_aggregations = self.dependency_aggregations.get(sort_field, []) required_aggregations = set([sort_field, "total"] + extra_aggregations) @@ -280,8 +280,8 @@ def _prepare_aggregations( aggregations = [] for alias in required_aggregations: aggregation = self.aggregation_defs[alias] - if replace_trends_aggregation and alias == "trends": - aggregation = self.aggregation_defs["trends_issue_platform"] + if use_issue_platform and alias in ("trends", "recommended"): + aggregation = self.aggregation_defs[f"{alias}_issue_platform"] if callable(aggregation): if aggregate_kwargs: aggregation = aggregation(start, end, aggregate_kwargs.get(alias, {})) @@ -333,14 +333,10 @@ def _prepare_params_for_category( else: conditions.append(converted_filter) - if sort_field == "trends" and group_category is not GroupCategory.ERROR.value: - aggregations = self._prepare_aggregations( - sort_field, start, end, having, aggregate_kwargs, True - ) - else: - aggregations = self._prepare_aggregations( - sort_field, start, end, having, aggregate_kwargs - ) + use_issue_platform = group_category is not GroupCategory.ERROR.value + aggregations = self._prepare_aggregations( + sort_field, start, end, having, aggregate_kwargs, use_issue_platform + ) if cursor is not None: having.append((sort_field, ">=" if cursor.is_prev else "<=", cursor.value)) @@ -700,11 +696,92 @@ def trends_aggregation_impl( ] +def _recommended_aggregation( + timestamp_column: str, type_column: str | None = None +) -> Sequence[str]: + hour = 3600 + + # Recency: exponential decay based on time since last event (24hr halflife) + recency_weight = options.get("snuba.search.recommended.recency-weight") + age_hours = f"divide(minus(now(), max({timestamp_column})), {hour})" + recency = f"divide(1, pow(2, divide({age_hours}, 24)))" + + # Spike: ratio of recent 6hr events to total 3d events + spike_weight = options.get("snuba.search.recommended.spike-weight") + recent_6h = f"countIf(lessOrEquals(minus(now(), {timestamp_column}), {6 * hour}))" + total_3d = f"countIf(lessOrEquals(minus(now(), {timestamp_column}), {3 * 24 * hour}))" + spike = f"least(1.0, divide({recent_6h}, plus({total_3d}, 1)))" + + # Severity: max log level - maps fatal=1.0, error=0.75, warning=0.5, info=0.25, debug=0.0 + severity_weight = options.get("snuba.search.recommended.severity-weight") + severity = ( + "max(multiIf(" + "equals(level, 'fatal'), 1.0, " + "equals(level, 'error'), 0.75, " + "equals(level, 'warning'), 0.5, " + "equals(level, 'info'), 0.25, " + "0.0))" + ) + + # User impact: ln(uniq(tags[sentry:user]) + 1)/ln(1001) - maps 1→~0, 10→0.33, 100→0.67, 1000→1.0 + user_impact_weight = options.get("snuba.search.recommended.user-impact-weight") + user_impact = "least(1.0, divide(log(plus(uniq(tags[sentry:user]), 1)), log(1001)))" + + # Event volume: ln(count() + 1)/ln(10001) - maps 1→~0, 10→0.25, 100→0.50, 1000→0.75, 10000+→1.0 + event_volume_weight = options.get("snuba.search.recommended.event-volume-weight") + event_volume = "least(1.0, divide(log(plus(count(), 1)), log(10001)))" + + # Group type boost: additive signal per issue type + group_type_boosts = options.get("snuba.search.recommended.group-type-boost") + if group_type_boosts: + type_expr = f"any({type_column})" if type_column else "1" + conditions = [] + for type_id, boost in group_type_boosts.items(): + conditions.append(f"equals({type_expr}, {type_id}), {boost}") + type_boost = f"multiIf({', '.join(conditions)}, 0.0)" + else: + type_boost = "0.0" + + return [ + ( + f"plus(plus(plus(plus(plus(" + f"multiply({recency_weight}, {recency}), " + f"multiply({spike_weight}, {spike})), " + f"multiply({severity_weight}, {severity})), " + f"multiply({user_impact_weight}, {user_impact})), " + f"multiply({event_volume_weight}, {event_volume})), " + f"{type_boost})" + ), + "", + ] + + +def recommended_aggregation( + start: datetime, + end: datetime, + aggregate_kwargs: Any = None, +) -> Sequence[str]: + return _recommended_aggregation(timestamp_column="timestamp") + + +def recommended_issue_platform_aggregation( + start: datetime, + end: datetime, + aggregate_kwargs: Any = None, +) -> Sequence[str]: + return _recommended_aggregation( + timestamp_column="client_timestamp", type_column="occurrence_type_id" + ) + + class PostgresSnubaQueryExecutor(AbstractQueryExecutor): ISSUE_FIELD_NAME = "group_id" logger = logging.getLogger("sentry.search.postgressnuba") - dependency_aggregations = {"trends": ["last_seen", "times_seen"]} + dependency_aggregations = { + "trends": ["last_seen", "times_seen"], + "recommended": ["last_seen", "times_seen", "user_count"], + } postgres_only_fields = {*SKIP_SNUBA_FIELDS, "regressed_in_release"} # add specific fields here on top of skip_snuba_fields from the serializer sort_strategies = { @@ -712,6 +789,7 @@ class PostgresSnubaQueryExecutor(AbstractQueryExecutor): "freq": "times_seen", "new": "first_seen", "trends": "trends", + "recommended": "recommended", "user": "user_count", # We don't need a corresponding snuba field here, since this sort only happens # in Postgres @@ -723,10 +801,12 @@ class PostgresSnubaQueryExecutor(AbstractQueryExecutor): "first_seen": ["multiply(toUInt64(min(coalesce(group_first_seen, timestamp))), 1000)", ""], "last_seen": ["multiply(toUInt64(max(timestamp)), 1000)", ""], "trends": trends_aggregation, + "recommended": recommended_aggregation, # Only makes sense with WITH TOTALS, returns 1 for an individual group. "total": ["uniq", ISSUE_FIELD_NAME], "user_count": ["uniq", "tags[sentry:user]"], "trends_issue_platform": trends_issue_platform_aggregation, + "recommended_issue_platform": recommended_issue_platform_aggregation, } @property diff --git a/tests/snuba/search/test_backend.py b/tests/snuba/search/test_backend.py index f2092c6be6699c..ba982e5b71a5f9 100644 --- a/tests/snuba/search/test_backend.py +++ b/tests/snuba/search/test_backend.py @@ -14,6 +14,7 @@ NoiseConfig, PerformanceNPlusOneGroupType, PerformanceRenderBlockingAssetSpanGroupType, + ProfileFileIOGroupType, ) from sentry.issues.ingest import send_issue_occurrence_to_eventstream from sentry.issues.issue_search import convert_query_values, issue_search_config, parse_search_query @@ -3868,3 +3869,283 @@ def test_negated_long_message_search(self) -> None: # Negated search for the keyword should NOT return this group results = self.make_query(search_filter_query="!message:excludethis999") assert group_info.group not in set(results) + + +class EventsRecommendedSortTest(TestCase, SharedSnubaMixin, OccurrenceTestMixin): + @property + def backend(self): + return EventsDatasetSnubaSearchBackend() + + def test_recommended_sort_recency(self) -> None: + new_project = self.create_project(organization=self.project.organization) + base_datetime = before_now(hours=1) + + recent_event = self.store_event( + data={ + "fingerprint": ["recent-group"], + "event_id": "a" * 32, + "message": "recent issue", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": "user1@example.com"}, + }, + project_id=new_project.id, + ) + old_event = self.store_event( + data={ + "fingerprint": ["old-group"], + "event_id": "b" * 32, + "message": "old issue", + "timestamp": (base_datetime - timedelta(days=5)).isoformat(), + "level": "info", + "tags": {"sentry:user": "user2@example.com"}, + }, + project_id=new_project.id, + ) + recent_group = Group.objects.get(id=recent_event.group.id) + old_group = Group.objects.get(id=old_event.group.id) + + results = self.make_query(sort_by="recommended", projects=[new_project]) + assert list(results) == [recent_group, old_group] + + def test_recommended_sort_severity(self) -> None: + base_datetime = before_now(hours=1) + + fatal_event = self.store_event( + data={ + "fingerprint": ["fatal-group"], + "event_id": "c" * 32, + "message": "fatal issue", + "timestamp": (base_datetime - timedelta(minutes=30)).isoformat(), + "level": "fatal", + "tags": {"sentry:user": "user1@example.com"}, + }, + project_id=self.project.id, + ) + info_event = self.store_event( + data={ + "fingerprint": ["info-group"], + "event_id": "d" * 32, + "message": "info issue", + "timestamp": base_datetime.isoformat(), + "level": "info", + "tags": {"sentry:user": "user2@example.com"}, + }, + project_id=self.project.id, + ) + fatal_group = Group.objects.get(id=fatal_event.group.id) + info_group = Group.objects.get(id=info_event.group.id) + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[fatal_group.id, info_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + scores = {gid: score for gid, score in results} + # Fatal event should score higher despite being slightly older + assert scores[fatal_group.id] > scores[info_group.id] + + def test_recommended_user_impact(self) -> None: + base_datetime = before_now(hours=1) + + # Issue affecting many users + for i in range(10): + self.store_event( + data={ + "fingerprint": ["many-users-group"], + "event_id": f"a{i:031d}", + "message": "many users", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": f"user{i}@example.com"}, + }, + project_id=self.project.id, + ) + many_users_group = Group.objects.get( + project=self.project, + message="many users", + ) + + # Issue affecting one user + self.store_event( + data={ + "fingerprint": ["one-user-group"], + "event_id": "b" * 32, + "message": "one user", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": "solo@example.com"}, + }, + project_id=self.project.id, + ) + one_user_group = Group.objects.get( + project=self.project, + message="one user", + ) + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[many_users_group.id, one_user_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + scores = {gid: score for gid, score in results} + assert scores[many_users_group.id] > scores[one_user_group.id] + + def test_recommended_issue_platform(self) -> None: + base_datetime = before_now(hours=1) + + error_event = self.store_event( + data={ + "fingerprint": ["error-group"], + "event_id": "a" * 32, + "timestamp": base_datetime.isoformat(), + "message": "error event", + "level": "error", + "stacktrace": {"frames": [{"module": "group1"}]}, + }, + project_id=self.project.id, + ) + error_group = error_event.group + + profile_event_id = uuid.uuid4().hex + _, group_info = self.process_occurrence( + event_id=profile_event_id, + project_id=self.project.id, + event_data={ + "title": "some problem", + "platform": "python", + "tags": {"my_tag": "1"}, + "timestamp": before_now(minutes=1).isoformat(), + "received": before_now(minutes=1).isoformat(), + }, + ) + assert group_info is not None + profile_group = group_info.group + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[error_group.id, profile_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + # Both groups should be returned with valid scores + returned_group_ids = {gid for gid, _ in results} + assert error_group.id in returned_group_ids + assert profile_group.id in returned_group_ids + + def test_recommended_event_volume(self) -> None: + base_datetime = before_now(hours=1) + + # Store 5 events for the high-volume group + for i in range(5): + self.store_event( + data={ + "fingerprint": ["high-volume-group"], + "event_id": f"{'a' * 31}{i}", + "message": "high volume", + "timestamp": base_datetime.isoformat(), + "level": "error", + }, + project_id=self.project.id, + ) + + # Store 1 event for the low-volume group + self.store_event( + data={ + "fingerprint": ["low-volume-group"], + "event_id": "b" * 32, + "message": "low volume", + "timestamp": base_datetime.isoformat(), + "level": "error", + }, + project_id=self.project.id, + ) + + high_volume_group = Group.objects.get(project=self.project, message="high volume") + low_volume_group = Group.objects.get(project=self.project, message="low volume") + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[high_volume_group.id, low_volume_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + + scores = {group_id: score for group_id, score in results} + assert scores[high_volume_group.id] > scores[low_volume_group.id] + + def test_recommended_group_type_boost(self) -> None: + base_datetime = before_now(hours=1) + + error_event = self.store_event( + data={ + "fingerprint": ["error-group"], + "event_id": "a" * 32, + "timestamp": base_datetime.isoformat(), + "message": "error event", + "level": "error", + "stacktrace": {"frames": [{"module": "group1"}]}, + }, + project_id=self.project.id, + ) + error_group = error_event.group + + _, group_info = self.process_occurrence( + event_id=uuid.uuid4().hex, + project_id=self.project.id, + event_data={ + "title": "some problem", + "platform": "python", + "tags": {"my_tag": "1"}, + "timestamp": base_datetime.isoformat(), + "received": base_datetime.isoformat(), + }, + type=ProfileFileIOGroupType.type_id, + ) + assert group_info is not None + profile_group = group_info.group + + # Boost ProfileFileIOGroupType so it outranks the error group + boost = {ProfileFileIOGroupType.type_id: 0.5} + with self.options({"snuba.search.recommended.group-type-boost": boost}): + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[error_group.id, profile_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + + scores = {gid: score for gid, score in results} + assert scores[profile_group.id] > scores[error_group.id] From da920f88ad985d136058442782e46669136e76ec Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 09:29:19 -0700 Subject: [PATCH 02/37] feat(supergroups): Add status filter to supergroups by-group endpoint (#112216) Allow filtering groups by status (unresolved, resolved, etc.) via a query parameter so the frontend can request supergroup assignments for only the relevant subset of issues. fixes https://linear.app/getsentry/issue/ID-1440/hide-resolved-issues-from-counts-and-lists --- .../organization_supergroups_by_group.py | 21 +++++-- .../test_organization_supergroups_by_group.py | 61 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index 98f5f8d087f6a4..c3cadbddde864c 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -12,7 +12,7 @@ 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.models.group import Group +from sentry.models.group import STATUS_QUERY_CHOICES, Group from sentry.models.organization import Organization from sentry.seer.signed_seer_api import ( SeerViewerContext, @@ -55,12 +55,21 @@ def get(self, request: Request, organization: Organization) -> Response: status=status_codes.HTTP_400_BAD_REQUEST, ) - valid_group_ids = set( - Group.objects.filter( - id__in=group_ids, - project__organization=organization, - ).values_list("id", flat=True) + group_qs = Group.objects.filter( + id__in=group_ids, + project__organization=organization, ) + + status_param = request.GET.get("status") + if status_param is not None: + if status_param not in STATUS_QUERY_CHOICES: + return Response( + {"detail": "Invalid status parameter"}, + status=status_codes.HTTP_400_BAD_REQUEST, + ) + group_qs = group_qs.filter(status=STATUS_QUERY_CHOICES[status_param]) + + valid_group_ids = set(group_qs.values_list("id", flat=True)) group_ids = [gid for gid in group_ids if gid in valid_group_ids] if not group_ids: diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py new file mode 100644 index 00000000000000..7f239d45207ef3 --- /dev/null +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import orjson + +from sentry.models.group import GroupStatus +from sentry.testutils.cases import APITestCase + + +def mock_seer_response(data): + response = MagicMock() + response.status = 200 + response.data = orjson.dumps(data) + return response + + +class OrganizationSupergroupsByGroupEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-supergroups-by-group" + + def setUp(self): + super().setUp() + self.login_as(self.user) + self.unresolved_group = self.create_group( + project=self.project, status=GroupStatus.UNRESOLVED + ) + self.resolved_group = self.create_group(project=self.project, status=GroupStatus.RESOLVED) + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroups_by_group.make_supergroups_get_by_group_ids_request" + ) + def test_status_filter(self, mock_seer): + mock_seer.return_value = mock_seer_response({"supergroups": []}) + + with self.feature("organizations:top-issues-ui"): + self.get_success_response( + self.organization.slug, + group_id=[self.unresolved_group.id, self.resolved_group.id], + status="unresolved", + ) + + body = mock_seer.call_args[0][0] + assert body["group_ids"] == [self.unresolved_group.id] + + def test_status_filter_invalid(self): + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[self.unresolved_group.id], + status="bogus", + status_code=400, + ) + + def test_status_filter_all_filtered_out(self): + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[self.resolved_group.id], + status="unresolved", + status_code=404, + ) From 7ad2e7f9f52518d63d4e22ce4873a94f77c72f11 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Mon, 6 Apr 2026 09:53:00 -0700 Subject: [PATCH 03/37] chore(ACI): Add a flag to metric alert rule details GET method (#112204) We're ready to roll out the metric alert and incident GET methods so this PR puts a flag around the metric alert rule details GET method to use the backwards compatible serializer. --- src/sentry/features/temporary.py | 3 +++ src/sentry/incidents/endpoints/bases.py | 9 ++++++++- .../endpoints/organization_alert_rule_details.py | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 00f103ab2aebce..b1f1b96ab2263e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -450,6 +450,9 @@ def register_temporary_features(manager: FeatureManager) -> None: # Use workflow engine exclusively for legacy issue alert rule.get results. # See src/sentry/workflow_engine/docs/legacy_backport.md for context. manager.add("organizations:workflow-engine-issue-alert-endpoints-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use workflow engine exclusively for OrganizationAlertRuleDetailsEndpoint.get results. + # See src/sentry/workflow_engine/docs/legacy_backport.md for context. + manager.add("organizations:workflow-engine-orgalertruledetails-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable metric detector limits by plan type manager.add("organizations:workflow-engine-metric-detector-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable EventUniqueUserFrequencyConditionWithConditions special alert condition diff --git a/src/sentry/incidents/endpoints/bases.py b/src/sentry/incidents/endpoints/bases.py index c05d556977499c..6e57d9d01047e6 100644 --- a/src/sentry/incidents/endpoints/bases.py +++ b/src/sentry/incidents/endpoints/bases.py @@ -154,6 +154,10 @@ def convert_args( class WorkflowEngineOrganizationAlertRuleEndpoint(OrganizationAlertRuleEndpoint): + # Subclasses may set a per-method granular flag (e.g. for GET) that is OR'd + # with the broad workflow-engine-rule-serializers flag. + workflow_engine_method_flags: dict[str, str] = {} + def convert_args( self, request: Request, alert_rule_id: int, *args: Any, **kwargs: Any ) -> tuple[tuple[Any, ...], dict[str, Any]]: @@ -169,7 +173,10 @@ def convert_args( ): raise ResourceDoesNotExist - if features.has("organizations:workflow-engine-rule-serializers", organization): + method_flag = self.workflow_engine_method_flags.get(request.method or "") + if features.has("organizations:workflow-engine-rule-serializers", organization) or ( + method_flag is not None and features.has(method_flag, organization) + ): try: ard = AlertRuleDetector.objects.get( alert_rule_id=validated_alert_rule_id, diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_details.py b/src/sentry/incidents/endpoints/organization_alert_rule_details.py index 970ed75f5017bc..764fd14e030e0c 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_details.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_details.py @@ -333,6 +333,9 @@ def wrapper( @extend_schema(tags=["Alerts"]) @cell_silo_endpoint class OrganizationAlertRuleDetailsEndpoint(WorkflowEngineOrganizationAlertRuleEndpoint): + workflow_engine_method_flags = { + "GET": "organizations:workflow-engine-orgalertruledetails-get", + } owner = ApiOwner.ISSUES publish_status = { "DELETE": ApiPublishStatus.PUBLIC, From 254a7c17e541f831464a7563bf986ff3ca6d41a3 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 10:05:50 -0700 Subject: [PATCH 04/37] ref(issues): Remove old UI from remaining event interface files (#112264) --- .../events/interfaces/request/index.tsx | 206 ++++++++---------- .../richHttpContentClippedBoxBodySection.tsx | 82 ------- .../richHttpContentClippedBoxKeyValueList.tsx | 69 ------ .../components/events/interfaces/threads.tsx | 42 +--- 4 files changed, 100 insertions(+), 299 deletions(-) delete mode 100644 static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx delete mode 100644 static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx diff --git a/static/app/components/events/interfaces/request/index.tsx b/static/app/components/events/interfaces/request/index.tsx index 0803cab1dd2775..40f3864d90f816 100644 --- a/static/app/components/events/interfaces/request/index.tsx +++ b/static/app/components/events/interfaces/request/index.tsx @@ -7,15 +7,16 @@ import {ExternalLink} from '@sentry/scraps/link'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; import {Text} from '@sentry/scraps/text'; -import {ClippedBox} from 'sentry/components/clippedBox'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {EventDataSection} from 'sentry/components/events/eventDataSection'; +import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; import {GraphQlRequestBody} from 'sentry/components/events/interfaces/request/graphQlRequestBody'; import {getCurlCommand, getFullUrl} from 'sentry/components/events/interfaces/utils'; import { KeyValueData, type KeyValueDataContentProps, } from 'sentry/components/keyValueData'; +import {StructuredEventData} from 'sentry/components/structuredEventData'; +import {JsonEventData} from 'sentry/components/structuredEventData/jsonEventData'; import {Truncate} from 'sentry/components/truncate'; import {IconOpen} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; @@ -25,13 +26,8 @@ import {defined} from 'sentry/utils'; import {isUrl} from 'sentry/utils/string/isUrl'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; -import { - getBodyContent, - RichHttpContentClippedBoxBodySection, -} from './richHttpContentClippedBoxBodySection'; -import {RichHttpContentClippedBoxKeyValueList} from './richHttpContentClippedBoxKeyValueList'; +import {getTransformedData} from './getTransformedData'; interface RequestProps { data: EntryRequest['data']; @@ -44,49 +40,86 @@ interface RequestBodyProps extends RequestProps { type View = 'formatted' | 'curl'; -function RequestBodySection({data, event, meta}: RequestBodyProps) { - const hasStreamlinedUI = useHasStreamlinedUI(); +function getBodyContent({ + data, + meta, + inferredContentType, +}: { + data: EntryRequest['data']['data']; + inferredContentType: EntryRequest['data']['inferredContentType']; + meta: Record | undefined; +}) { + switch (inferredContentType) { + case 'application/json': + return ( + + ); + case 'application/x-www-form-urlencoded': + case 'multipart/form-data': { + const transformedData = getTransformedData(data, meta).map(d => { + const [key, value] = d.data; + return { + key, + subject: key, + value, + meta: d.meta, + }; + }); + if (!transformedData.length) { + return null; + } + + return ( + + ); + } + + default: + return ( +
+          
+        
+ ); + } +} + +function RequestBodySection({data, event, meta}: RequestBodyProps) { if (!defined(data.data)) { return null; } if (data.apiTarget === 'graphql' && typeof data.data.query === 'string') { - return hasStreamlinedUI ? ( - - {t('Body')} - - - ) : ( - - ); - } - - if (hasStreamlinedUI) { - const contentBody = getBodyContent({ - data: data.data, - meta: meta?.data, - inferredContentType: data.inferredContentType, - }); return ( {t('Body')} - {contentBody} + ); } + const contentBody = getBodyContent({ + data: data.data, + meta: meta?.data, + inferredContentType: data.inferredContentType, + }); return ( - + + {t('Body')} + {contentBody} + ); } export function Request({data, event}: RequestProps) { - const hasStreamlinedUI = useHasStreamlinedUI(); const entryIndex = event.entries.findIndex(entry => entry.type === EntryType.REQUEST); const meta = event._meta?.entries?.[entryIndex]?.data; @@ -133,99 +166,38 @@ export function Request({data, event}: RequestProps) { ); - if (hasStreamlinedUI) { - return ( - - {title} - {view === 'curl' ? ( - {getCurlCommand(data)} - ) : ( - - - - - - - - - )} - - ); - } - return ( - + {title} {view === 'curl' ? ( {getCurlCommand(data)} ) : ( - {defined(data.query) && ( - - )} - {defined(data.fragment) && ( - - -
{data.fragment}
-
-
- )} - - {defined(data.cookies) && Object.keys(data.cookies).length > 0 && ( - - )} - {defined(data.headers) && ( - - )} - {defined(data.env) && ( - - )} + + + + + +
)} -
+ ); } diff --git a/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx b/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx deleted file mode 100644 index 3f2f40293f1835..00000000000000 --- a/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {ClippedBox} from 'sentry/components/clippedBox'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; -import {StructuredEventData} from 'sentry/components/structuredEventData'; -import {JsonEventData} from 'sentry/components/structuredEventData/jsonEventData'; -import {t} from 'sentry/locale'; -import type {EntryRequest} from 'sentry/types/event'; -import {defined} from 'sentry/utils'; - -import {getTransformedData} from './getTransformedData'; - -type Props = { - data: EntryRequest['data']['data']; - inferredContentType: EntryRequest['data']['inferredContentType']; - meta: Record | undefined; -}; - -export function getBodyContent({data, meta, inferredContentType}: Props) { - switch (inferredContentType) { - case 'application/json': - return ( - - ); - case 'application/x-www-form-urlencoded': - case 'multipart/form-data': { - const transformedData = getTransformedData(data, meta).map(d => { - const [key, value] = d.data; - return { - key, - subject: key, - value, - meta: d.meta, - }; - }); - - if (!transformedData.length) { - return null; - } - - return ( - - ); - } - - default: - return ( -
-          
-        
- ); - } -} - -export function RichHttpContentClippedBoxBodySection({ - data, - meta, - inferredContentType, -}: Props) { - if (!defined(data)) { - return null; - } - - const content = getBodyContent({data, meta, inferredContentType}); - - if (!content) { - return null; - } - - return ( - - {content} - - ); -} diff --git a/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx b/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx deleted file mode 100644 index 56cfc23a7449ef..00000000000000 --- a/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import {ClippedBox} from 'sentry/components/clippedBox'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; -import type {EntryRequest} from 'sentry/types/event'; -import type {Meta} from 'sentry/types/group'; -import {defined} from 'sentry/utils'; - -import {getTransformedData} from './getTransformedData'; - -type Data = EntryRequest['data']['data']; - -type Props = { - data: Data; - title: string; - defaultCollapsed?: boolean; - isContextData?: boolean; - meta?: Meta; -}; - -export function RichHttpContentClippedBoxKeyValueList({ - data, - title, - defaultCollapsed = false, - isContextData = false, - meta, -}: Props) { - const transformedData = getTransformedData(data, meta); - - function getContent() { - // Sentry API abbreviates long query string values, sometimes resulting in - // an un-parsable querystring ... stay safe kids - try { - return ( - { - const [key, value] = d.data; - - if (!value && !d.meta) { - return null; - } - - return { - key, - subject: key, - value, - meta: d.meta, - }; - }) - .filter(defined)} - isContextData={isContextData} - /> - ); - } catch { - // TODO(TS): Types indicate that data might be an object - return
{data as any}
; - } - } - - if (!transformedData.length) { - return null; - } - - return ( - - {getContent()} - - ); -} diff --git a/static/app/components/events/interfaces/threads.tsx b/static/app/components/events/interfaces/threads.tsx index 3d3bde5dec37dc..840758a5987282 100644 --- a/static/app/components/events/interfaces/threads.tsx +++ b/static/app/components/events/interfaces/threads.tsx @@ -39,7 +39,6 @@ import {defined} from 'sentry/utils'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {setActiveThreadId} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {ExceptionContent} from './crashContent/exception'; import {StackTraceContent} from './crashContent/stackTrace'; @@ -174,7 +173,6 @@ export function Threads({data, event, projectSlug, groupingCurrentLevel, group}: () => (data.values ?? []).toSorted((a, b) => Number(b.crashed) - Number(a.crashed)), [data.values] ); - const hasStreamlinedUI = useHasStreamlinedUI(); const [activeThread, setActiveThread] = useActiveThreadState(event, threads); // Sync active thread to module store for copy functionality @@ -387,7 +385,7 @@ export function Threads({data, event, projectSlug, groupingCurrentLevel, group}: exception={exception} platform={platform} /> - {hasStreamlinedUI && group && ( + {group && ( ); - if (hasStreamlinedUI) { - // If there is only one thread, we expect the stacktrace to wrap itself in a section - return hasMoreThanOneThread ? ( - - - {threadComponent} - - - ) : ( - threadComponent - ); - } - + // If there is only one thread, we expect the stacktrace to wrap itself in a section return hasMoreThanOneThread ? ( - {threadComponent} + + + {threadComponent} + + ) : ( threadComponent ); @@ -447,16 +437,6 @@ const LockReason = styled(TextOverflow)` color: ${p => p.theme.tokens.content.secondary}; `; -const ThreadTraceWrapper = styled('div')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xl}; - padding: ${p => p.theme.space.md} ${p => p.theme.space['3xl']}; - @media (max-width: ${p => p.theme.breakpoints.md}) { - padding: ${p => p.theme.space.md} ${p => p.theme.space.xl}; - } -`; - const ThreadHeading = styled('h3')` color: ${p => p.theme.tokens.content.secondary}; font-size: ${p => p.theme.font.size.md}; From a42598b0f9468af8d405f9160f3841b7a863aa62 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 10:20:55 -0700 Subject: [PATCH 05/37] ref(issues): Remove streamlined UI from stack trace files (#112242) --- .../crashContent/exception/stackTrace.tsx | 4 - .../crashContent/stackTrace/content.spec.tsx | 94 ------------------- .../crashContent/stackTrace/content.tsx | 20 +--- .../crashContent/stackTrace/index.tsx | 4 - .../crashContent/stackTrace/nativeContent.tsx | 13 +-- .../crashContent/stackTrace/platformIcon.tsx | 28 ------ .../events/interfaces/exception.tsx | 5 +- .../events/interfaces/nativeFrame.tsx | 12 +-- .../components/events/interfaces/utils.tsx | 46 --------- .../groupPreviewTooltip/stackTracePreview.tsx | 10 +- 10 files changed, 8 insertions(+), 228 deletions(-) delete mode 100644 static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx diff --git a/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx b/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx index 1047813b513b07..e6acafb7fe1a3e 100644 --- a/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx @@ -11,7 +11,6 @@ import type {PlatformKey} from 'sentry/types/project'; import {StackType, StackView} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {isNativePlatform} from 'sentry/utils/platform'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; type Props = { chainedException: boolean; @@ -44,7 +43,6 @@ export function StackTrace({ frameSourceMapDebuggerData, stackType, }: Props) { - const hasStreamlinedUI = useHasStreamlinedUI(); if (!defined(stacktrace)) { return null; } @@ -90,7 +88,6 @@ export function StackTrace({ newestFirst={newestFirst} event={event} meta={meta} - hideIcon={hasStreamlinedUI} /> ); } @@ -107,7 +104,6 @@ export function StackTrace({ threadId={threadId} frameSourceMapDebuggerData={frameSourceMapDebuggerData} hideSourceMapDebugger={stackType === StackType.MINIFIED} - hideIcon={hasStreamlinedUI} /> ); } diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx index 65a467940673f3..92c1bd51c072a4 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx @@ -1,6 +1,5 @@ import {EventFixture} from 'sentry-fixture/event'; import {EventEntryStacktraceFixture} from 'sentry-fixture/eventEntryStacktrace'; -import {EventStacktraceFrameFixture} from 'sentry-fixture/eventStacktraceFrame'; import {GitHubIntegrationFixture} from 'sentry-fixture/githubIntegration'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; @@ -63,9 +62,6 @@ describe('StackTrace', () => { const stackTraceContent = screen.getByTestId('stack-trace-content'); expect(stackTraceContent).toBeInTheDocument(); - // platform icon - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - // frame list const frames = screen.getByTestId('frames'); expect(frames.children).toHaveLength(5); @@ -453,94 +449,4 @@ describe('StackTrace', () => { expect(frameTitles[1]).toHaveTextContent('raven/base.py in build_msg at line 303'); }); }); - - describe('platform icons', () => { - it('uses the top in-app frame file extension for mixed stack trace platforms', () => { - render( - - ); - - // foo.py is the most recent in-app frame with a valid file extension - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - }); - - it('uses frame.platform if file extension does not work', () => { - render( - - ); - - expect(screen.getByTestId('platform-icon-node')).toBeInTheDocument(); - }); - - it('falls back to the event platform if there is no other information', () => { - render( - - ); - - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - }); - }); }); diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx index 0ff4f2d4b1023a..a3f4b8c47210c7 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx @@ -1,5 +1,4 @@ import {Fragment, useState} from 'react'; -import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {DeprecatedLineProps} from 'sentry/components/events/interfaces/frame/deprecatedLine'; @@ -9,7 +8,6 @@ import { getHiddenFrameIndices, getLastFrameIndex, isRepeatedFrame, - stackTracePlatformIcon, } from 'sentry/components/events/interfaces/utils'; import {Panel} from 'sentry/components/panels/panel'; import type {Event, Frame} from 'sentry/types/event'; @@ -18,7 +16,6 @@ import type {StackTraceMechanism, StacktraceType} from 'sentry/types/stacktrace' import {defined} from 'sentry/utils'; import {OmittedFrames} from './omittedFrames'; -import {StacktracePlatformIcon} from './platformIcon'; type DefaultProps = { expandFirstFrame: boolean; @@ -32,7 +29,6 @@ type Props = { platform: PlatformKey; className?: string; frameSourceMapDebuggerData?: FrameSourceMapDebuggerData[]; - hideIcon?: boolean; hideSourceMapDebugger?: boolean; isHoverPreviewed?: boolean; lockAddress?: string; @@ -53,7 +49,6 @@ export function Content({ isHoverPreviewed = false, maxDepth, meta, - hideIcon, threadId, lockAddress, frameSourceMapDebuggerData, @@ -202,15 +197,11 @@ export function Content({ includeSystemFrames ? 'full-traceback' : 'in-app-traceback' }`; - const platformIcon = stackTracePlatformIcon(platform, data.frames ?? []); - return ( - {hideIcon ? null : } {newestFirst ? [...convertedFrames].reverse() : convertedFrames} @@ -224,18 +215,9 @@ const Wrapper = styled('div')` position: relative; `; -export const StackTraceContentPanel = styled(Panel)<{hideIcon?: boolean}>` +export const StackTraceContentPanel = styled(Panel)` position: relative; overflow: hidden; - - ${p => - !p.hideIcon && - css` - border-top-left-radius: 0; - @media (max-width: ${p.theme.breakpoints.md}) { - border-top-left-radius: ${p.theme.radius.md}; - } - `} `; const StyledList = styled('ul')` diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx index 73a6c278e7dc6b..c988cc0ec850ff 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx @@ -6,7 +6,6 @@ import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {StackView} from 'sentry/types/stacktrace'; import {isNativePlatform} from 'sentry/utils/platform'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {Content} from './content'; import {NativeContent} from './nativeContent'; @@ -39,7 +38,6 @@ export function StackTraceContent({ threadId, lockAddress, }: Props) { - const hasStreamlinedUI = useHasStreamlinedUI(); if (stackView === StackView.RAW) { return ( @@ -62,7 +60,6 @@ export function StackTraceContent({ groupingCurrentLevel={groupingCurrentLevel} meta={meta} inlined={inlined} - hideIcon={inlined || hasStreamlinedUI} maxDepth={maxDepth} /> @@ -79,7 +76,6 @@ export function StackTraceContent({ event={event} newestFirst={newestFirst} meta={meta} - hideIcon={inlined || hasStreamlinedUI} inlined={inlined} maxDepth={maxDepth} threadId={threadId} diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx index 46f6ab123b16f0..a54e825e1f2d75 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx @@ -8,7 +8,6 @@ import { getLastFrameIndex, isRepeatedFrame, parseAddress, - stackTracePlatformIcon, } from 'sentry/components/events/interfaces/utils'; import {Panel} from 'sentry/components/panels/panel'; import type {Event, Frame} from 'sentry/types/event'; @@ -18,7 +17,6 @@ import type {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {OmittedFrames} from './omittedFrames'; -import {StacktracePlatformIcon} from './platformIcon'; function isFrameUsedForGrouping( frame: Frame, @@ -40,7 +38,6 @@ type Props = { platform: PlatformKey; className?: string; groupingCurrentLevel?: Group['metadata']['current_level']; - hideIcon?: boolean; includeSystemFrames?: boolean; inlined?: boolean; isHoverPreviewed?: boolean; @@ -56,7 +53,6 @@ export function NativeContent({ newestFirst, isHoverPreviewed, inlined, - hideIcon, groupingCurrentLevel, includeSystemFrames = true, maxDepth, @@ -241,15 +237,9 @@ export function NativeContent({ return ( - {hideIcon ? null : ( - - )} {convertedFrames} @@ -261,9 +251,8 @@ const Wrapper = styled('div')` position: relative; `; -const ContentPanel = styled(Panel)<{hideIcon?: boolean}>` +const ContentPanel = styled(Panel)` position: relative; - border-top-left-radius: ${p => (p.hideIcon ? p.theme.radius.md : 0)}; overflow: hidden; `; diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx deleted file mode 100644 index c3a47e97a34388..00000000000000 --- a/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import styled from '@emotion/styled'; -import {PlatformIcon} from 'platformicons'; - -type Props = { - platform: string; -}; - -export function StacktracePlatformIcon({platform}: Props) { - return ( - - ); -} - -const StyledPlatformIcon = styled(PlatformIcon)` - position: absolute; - top: 0; - right: 100%; - border-radius: 3px 0 0 3px; - - @media (max-width: ${p => p.theme.breakpoints.md}) { - display: none; - } -`; diff --git a/static/app/components/events/interfaces/exception.tsx b/static/app/components/events/interfaces/exception.tsx index bcc2b724a51035..5d99cb27c37e65 100644 --- a/static/app/components/events/interfaces/exception.tsx +++ b/static/app/components/events/interfaces/exception.tsx @@ -10,7 +10,6 @@ import {EntryType} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; import {SectionDivider} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {ExceptionContent} from './crashContent/exception'; import {NoStackTraceMessage} from './noStackTraceMessage'; @@ -33,8 +32,6 @@ export function Exception({ groupingCurrentLevel, }: Props) { const eventHasThreads = !!event.entries.some(entry => entry.type === EntryType.THREADS); - const hasStreamlinedUI = useHasStreamlinedUI(); - // in case there are threads in the event data, we don't render the // exception block. Instead the exception is contained within the // thread interface. @@ -106,7 +103,7 @@ export function Exception({ groupingCurrentLevel={groupingCurrentLevel} meta={meta} /> - {hasStreamlinedUI && group && ( + {group && ( {data.values && data.values.length > 1 && ( diff --git a/static/app/components/events/interfaces/nativeFrame.tsx b/static/app/components/events/interfaces/nativeFrame.tsx index c5bf5419df6064..d1ec8b6e37c51e 100644 --- a/static/app/components/events/interfaces/nativeFrame.tsx +++ b/static/app/components/events/interfaces/nativeFrame.tsx @@ -46,7 +46,6 @@ import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageStat import {withSentryAppComponents} from 'sentry/utils/withSentryAppComponents'; import {SectionKey, useIssueDetails} from 'sentry/views/issueDetails/streamline/context'; import {getFoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {combineStatus} from './debugMeta/utils'; import {Context} from './frame/context'; @@ -112,8 +111,6 @@ function NativeFrame({ getFoldSectionKey(SectionKey.DEBUGMETA), debugSectionConfig?.initialCollapse ?? false ); - const hasStreamlinedUI = useHasStreamlinedUI(); - const fullStackTrace = stackView === StackView.FULL; const absolute = displayOptions.includes('absolute-addresses'); @@ -127,8 +124,7 @@ function NativeFrame({ !!frame.symbolicatorStatus && frame.symbolicatorStatus !== SymbolicatorStatus.UNKNOWN_IMAGE && !isHoverPreviewed && - // We know the debug section is rendered (only once streamline ui is enabled) - (hasStreamlinedUI ? !!debugSectionConfig : true); + !!debugSectionConfig; const leadsToApp = !frame.inApp && (nextFrame?.inApp || !nextFrame); const expandable = isExpandable({ @@ -255,10 +251,8 @@ function NativeFrame({ DebugMetaStore.updateFilter(searchTerm); } - if (hasStreamlinedUI) { - // Expand the section - setIsCollapsed(false); - } + // Expand the section + setIsCollapsed(false); // Scroll to the section document diff --git a/static/app/components/events/interfaces/utils.tsx b/static/app/components/events/interfaces/utils.tsx index a1b5b18ee6400d..32182e6b382685 100644 --- a/static/app/components/events/interfaces/utils.tsx +++ b/static/app/components/events/interfaces/utils.tsx @@ -1,4 +1,3 @@ -import partition from 'lodash/partition'; import * as qs from 'query-string'; import {getThreadException} from 'sentry/components/events/interfaces/threads/threadSelector/getThreadException'; @@ -11,7 +10,6 @@ import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {StacktraceOrder, type AvatarUser} from 'sentry/types/user'; import {defined} from 'sentry/utils'; -import {fileExtensionToPlatform, getFileExtension} from 'sentry/utils/fileExtension'; /** * Attempts to escape a string from any bash double quote special characters. @@ -299,50 +297,6 @@ export function parseAssembly(assembly: string | null) { return {name, version, culture, publicKeyToken}; } -function getFramePlatform(frame: Frame) { - const fileExtension = getFileExtension(frame.filename ?? ''); - const fileExtensionPlatform = fileExtension - ? fileExtensionToPlatform(fileExtension) - : null; - - if (fileExtensionPlatform) { - return fileExtensionPlatform; - } - - if (frame.platform) { - return frame.platform; - } - - return null; -} - -/** - * Returns the representative platform for the given stack trace frames. - * Prioritizes recent in-app frames, checking first for a matching file extension - * and then for a frame.platform attribute [1]. - * - * If none of the frames have a platform, falls back to the event platform. - * - * [1] https://develop.sentry.dev/sdk/event-payloads/stacktrace/#frame-attributes - */ -export function stackTracePlatformIcon(eventPlatform: PlatformKey, frames: Frame[]) { - const [inAppFrames, systemFrames] = partition( - // Reverse frames to get newest-first ordering - [...frames].reverse(), - frame => frame.inApp - ); - - for (const frame of [...inAppFrames, ...systemFrames]) { - const framePlatform = getFramePlatform(frame); - - if (framePlatform) { - return framePlatform; - } - } - - return eventPlatform; -} - export function isStacktraceNewestFirst() { const user = ConfigStore.get('user'); // user may not be authenticated diff --git a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx index 859867594b7b06..50058dedb92451 100644 --- a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx +++ b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx @@ -79,16 +79,10 @@ export function StackTracePreviewContent({ | Partial>; if (isNativePlatform(platform)) { - return ( - - ); + return ; } - return ; + return ; } type StackTracePreviewProps = { From 0b212a88060ec7bf5671b7ee9f76a3c6cab7c2d5 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 10:31:34 -0700 Subject: [PATCH 06/37] feat(aci): Add issue preview to cron monitor form (#112237) --- .../forms/cron/cronIssuePreview.tsx | 37 +++++++++++++++++++ .../components/forms/cron/index.spec.tsx | 29 ++++++++++++--- .../detectors/components/forms/cron/index.tsx | 2 + 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx diff --git a/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx new file mode 100644 index 00000000000000..6269b074e045e0 --- /dev/null +++ b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx @@ -0,0 +1,37 @@ +import {t} from 'sentry/locale'; +import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; +import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; +import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; +import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useCronDetectorFormField} from 'sentry/views/detectors/components/forms/cron/fields'; + +const FALLBACK_ISSUE_TITLE = t('Cron failure: …'); +const SUBTITLE = t('Your monitor is failing: A missed check-in was detected'); + +function useCronIssueTitle() { + const name = useCronDetectorFormField('name'); + + if (!name) { + return FALLBACK_ISSUE_TITLE; + } + + return t('Cron failure: %s', name); +} + +export function CronIssuePreview({step}: {step?: number}) { + const owner = useCronDetectorFormField('owner'); + const issueTitle = useCronIssueTitle(); + const assignee = ownerToActor(owner); + const {project} = useDetectorFormContext(); + + return ( + + + + ); +} diff --git a/static/app/views/detectors/components/forms/cron/index.spec.tsx b/static/app/views/detectors/components/forms/cron/index.spec.tsx index 3e6f53dd7767f5..15a8d409979b02 100644 --- a/static/app/views/detectors/components/forms/cron/index.spec.tsx +++ b/static/app/views/detectors/components/forms/cron/index.spec.tsx @@ -1,7 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -58,7 +58,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/Assign/)).toBeInTheDocument(); + expect(screen.getByText(/\d\. Assign/)).toBeInTheDocument(); expect(screen.getByText(/Description/)).toBeInTheDocument(); // Create Monitor button should be present and enabled @@ -80,8 +80,6 @@ describe('NewCronDetectorForm', () => { // Form sections should be hidden expect(screen.queryByText(/Detect/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Assign/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Description/)).not.toBeInTheDocument(); // Create Monitor button should be present but disabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -89,6 +87,27 @@ describe('NewCronDetectorForm', () => { expect(createButton).toBeDisabled(); }); + it('renders issue preview and updates title when name changes', async () => { + renderForm(); + + // Issue preview section should render with fallback title + expect(await screen.findByTestId('issue-preview-section')).toBeInTheDocument(); + expect(screen.getByText('Cron failure: …')).toBeInTheDocument(); + expect( + screen.getByText('Your monitor is failing: A missed check-in was detected') + ).toBeInTheDocument(); + + // Edit the monitor name + const title = screen.getByText('New Monitor'); + await userEvent.click(title); + const nameInput = screen.getByRole('textbox', {name: 'Monitor Name'}); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'My Cron Job{Enter}'); + + // Issue preview updates with the new name + expect(await screen.findByText('Cron failure: My Cron Job')).toBeInTheDocument(); + }); + it('shows form sections and enabled button when guide is set to "manual"', async () => { renderForm({ location: { @@ -99,7 +118,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible even with platform set, because guide is "manual" expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/Assign/)).toBeInTheDocument(); + expect(screen.getByText(/\d\. Assign/)).toBeInTheDocument(); expect(screen.getByText(/Description/)).toBeInTheDocument(); // Create Monitor button should be present and enabled diff --git a/static/app/views/detectors/components/forms/cron/index.tsx b/static/app/views/detectors/components/forms/cron/index.tsx index 2d53658f1eec1a..8cdf7c7ffd8545 100644 --- a/static/app/views/detectors/components/forms/cron/index.tsx +++ b/static/app/views/detectors/components/forms/cron/index.tsx @@ -22,6 +22,7 @@ import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDe import {NewDetectorLayout} from 'sentry/views/detectors/components/forms/newDetectorLayout'; import {useCronsUpsertGuideState} from 'sentry/views/insights/crons/components/useCronsUpsertGuideState'; +import {CronIssuePreview} from './cronIssuePreview'; import {PreviewSection} from './previewSection'; function useIsShowingPlatformGuide() { @@ -35,6 +36,7 @@ const FORM_SECTIONS = [ CronDetectorFormResolveSection, AssignSection, DescribeSection, + CronIssuePreview, AutomateSection, ]; From 1a2ec011df886cf73d7de2da5fe240c7f3b97ba3 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 10:32:39 -0700 Subject: [PATCH 07/37] feat(supergroups): Show filtered vs total events in supergroup chart (#112215) When search filters are active, individual issue rows show matching events vs total events as separate series in the chart. Supergroup rows were only showing total stats. This aggregates filtered stats separately so the supergroup row renders the same dual-bar chart with matching/total event and user counts. Move stuff out of app/utils image --- .../app/components/stream/supergroupRow.tsx | 41 +++++- .../supergroup/aggregateSupergroupStats.ts | 58 -------- static/app/views/issueList/groupListBody.tsx | 4 +- static/app/views/issueList/issueListTable.tsx | 2 +- static/app/views/issueList/overview.tsx | 2 +- .../aggregateSupergroupStats.spec.ts | 130 ++++++++++++++++++ .../supergroups/aggregateSupergroupStats.ts | 95 +++++++++++++ .../supergroups}/useSuperGroups.spec.tsx | 2 +- .../issueList/supergroups}/useSuperGroups.tsx | 0 9 files changed, 267 insertions(+), 67 deletions(-) delete mode 100644 static/app/utils/supergroup/aggregateSupergroupStats.ts create mode 100644 static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts create mode 100644 static/app/views/issueList/supergroups/aggregateSupergroupStats.ts rename static/app/{utils/supergroup => views/issueList/supergroups}/useSuperGroups.spec.tsx (96%) rename static/app/{utils/supergroup => views/issueList/supergroups}/useSuperGroups.tsx (100%) diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx index dff3ee828bbbf9..d0c1b0a972ce49 100644 --- a/static/app/components/stream/supergroupRow.tsx +++ b/static/app/components/stream/supergroupRow.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; +import {Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; @@ -13,8 +14,8 @@ import {Placeholder} from 'sentry/components/placeholder'; import {TimeSince} from 'sentry/components/timeSince'; import {IconStack} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {AggregatedSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; import {COLUMN_BREAKPOINTS} from 'sentry/views/issueList/actions/utils'; +import type {AggregatedSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats'; import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; @@ -106,7 +107,17 @@ export function SupergroupRow({ {aggregatedStats?.mergedStats && aggregatedStats.mergedStats.length > 0 ? ( - + ) : ( )} @@ -114,7 +125,14 @@ export function SupergroupRow({ {aggregatedStats ? ( - + + + {aggregatedStats.filteredEventCount !== null && ( + + )} + ) : ( )} @@ -122,7 +140,14 @@ export function SupergroupRow({ {aggregatedStats ? ( - + + + {aggregatedStats.filteredUserCount !== null && ( + + )} + ) : ( )} @@ -258,6 +283,14 @@ const PrimaryCount = styled(Count)` font-variant-numeric: tabular-nums; `; +const SecondaryCount = styled(Count)` + font-size: ${p => p.theme.font.size.sm}; + display: flex; + justify-content: flex-end; + color: ${p => p.theme.tokens.content.secondary}; + font-variant-numeric: tabular-nums; +`; + // Empty spacers to match StreamGroup column widths and keep alignment const PrioritySpacer = styled('div')` width: 64px; diff --git a/static/app/utils/supergroup/aggregateSupergroupStats.ts b/static/app/utils/supergroup/aggregateSupergroupStats.ts deleted file mode 100644 index 09e7013875168f..00000000000000 --- a/static/app/utils/supergroup/aggregateSupergroupStats.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type {TimeseriesValue} from 'sentry/types/core'; -import type {Group} from 'sentry/types/group'; - -export interface AggregatedSupergroupStats { - eventCount: number; - firstSeen: string | null; - lastSeen: string | null; - mergedStats: TimeseriesValue[]; - userCount: number; -} - -/** - * Aggregate stats from member groups for display in a supergroup row. - * Sums event/user counts, takes min firstSeen and max lastSeen, - * and point-wise sums the trend data. - */ -export function aggregateSupergroupStats( - groups: Group[], - statsPeriod: string -): AggregatedSupergroupStats | null { - if (groups.length === 0) { - return null; - } - - let eventCount = 0; - let userCount = 0; - let firstSeen: string | null = null; - let lastSeen: string | null = null; - let mergedStats: TimeseriesValue[] = []; - - for (const group of groups) { - eventCount += parseInt(group.count, 10) || 0; - userCount += group.userCount || 0; - - const gFirstSeen = group.lifetime?.firstSeen ?? group.firstSeen; - if (gFirstSeen && (!firstSeen || gFirstSeen < firstSeen)) { - firstSeen = gFirstSeen; - } - - const gLastSeen = group.lifetime?.lastSeen ?? group.lastSeen; - if (gLastSeen && (!lastSeen || gLastSeen > lastSeen)) { - lastSeen = gLastSeen; - } - - const stats = group.stats?.[statsPeriod]; - if (stats) { - if (mergedStats.length === 0) { - mergedStats = stats.map(([ts, val]) => [ts, val] as TimeseriesValue); - } else { - for (let i = 0; i < Math.min(mergedStats.length, stats.length); i++) { - mergedStats[i] = [mergedStats[i]![0], mergedStats[i]![1] + stats[i]![1]]; - } - } - } - } - - return {eventCount, userCount, firstSeen, lastSeen, mergedStats}; -} diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 1154ad5502b080..fad13644e0ff3b 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -9,13 +9,13 @@ import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; -import {aggregateSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; -import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; +import {aggregateSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +import type {SupergroupLookup} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {NoGroupsHandler} from './noGroupsHandler'; diff --git a/static/app/views/issueList/issueListTable.tsx b/static/app/views/issueList/issueListTable.tsx index b81579c6ed50ac..b3d2706fadec0d 100644 --- a/static/app/views/issueList/issueListTable.tsx +++ b/static/app/views/issueList/issueListTable.tsx @@ -10,12 +10,12 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import {DemoTourElement, DemoTourStep} from 'sentry/utils/demoMode/demoTours'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; -import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useLocation} from 'sentry/utils/useLocation'; import {IssueListActions} from 'sentry/views/issueList/actions'; import {GroupListBody} from 'sentry/views/issueList/groupListBody'; import {IssueSelectionProvider} from 'sentry/views/issueList/issueSelectionContext'; import {NewViewEmptyState} from 'sentry/views/issueList/newViewEmptyState'; +import type {SupergroupLookup} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; interface IssueListTableProps { diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index f709d8dd7c5bb1..7243ede9b5c029 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -38,7 +38,6 @@ import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useDisableRouteAnalytics} from 'sentry/utils/routeAnalytics/useDisableRouteAnalytics'; import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; @@ -49,6 +48,7 @@ import {usePrevious} from 'sentry/utils/usePrevious'; import {IssueListTable} from 'sentry/views/issueList/issueListTable'; import {IssuesDataConsentBanner} from 'sentry/views/issueList/issuesDataConsentBanner'; import {IssueViewsHeader} from 'sentry/views/issueList/issueViewsHeader'; +import {useSuperGroups} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {parseIssuePrioritySearch} from 'sentry/views/issueList/utils/parseIssuePrioritySearch'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts new file mode 100644 index 00000000000000..e67a275e9a60de --- /dev/null +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts @@ -0,0 +1,130 @@ +import {GroupFixture} from 'sentry-fixture/group'; + +import {aggregateSupergroupStats} from './aggregateSupergroupStats'; + +describe('aggregateSupergroupStats', () => { + it('returns null for empty groups', () => { + expect(aggregateSupergroupStats([], '24h')).toBeNull(); + }); + + it('sums event and user counts', () => { + const groups = [ + GroupFixture({count: '10', userCount: 3}), + GroupFixture({count: '20', userCount: 7}), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.eventCount).toBe(30); + expect(result?.userCount).toBe(10); + }); + + it('takes min firstSeen and max lastSeen', () => { + const groups = [ + GroupFixture({firstSeen: '2024-01-05T00:00:00Z', lastSeen: '2024-01-10T00:00:00Z'}), + GroupFixture({firstSeen: '2024-01-01T00:00:00Z', lastSeen: '2024-01-15T00:00:00Z'}), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.firstSeen).toBe('2024-01-01T00:00:00Z'); + expect(result?.lastSeen).toBe('2024-01-15T00:00:00Z'); + }); + + it('point-wise sums stats timeseries', () => { + const groups = [ + GroupFixture({ + stats: { + '24h': [ + [1000, 1], + [2000, 2], + ], + }, + }), + GroupFixture({ + stats: { + '24h': [ + [1000, 3], + [2000, 4], + ], + }, + }), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.mergedStats).toEqual([ + [1000, 4], + [2000, 6], + ]); + }); + + it('returns null filtered fields when no groups have filters', () => { + const groups = [GroupFixture({filtered: null})]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.filteredEventCount).toBeNull(); + expect(result?.filteredUserCount).toBeNull(); + expect(result?.mergedFilteredStats).toBeNull(); + }); + + it('aggregates filtered stats separately', () => { + const groups = [ + GroupFixture({ + count: '100', + userCount: 50, + stats: { + '24h': [ + [1000, 10], + [2000, 20], + ], + }, + filtered: { + count: '30', + userCount: 15, + firstSeen: '2024-01-01T00:00:00Z', + lastSeen: '2024-01-10T00:00:00Z', + stats: { + '24h': [ + [1000, 3], + [2000, 5], + ], + }, + }, + }), + GroupFixture({ + count: '200', + userCount: 80, + stats: { + '24h': [ + [1000, 40], + [2000, 60], + ], + }, + filtered: { + count: '70', + userCount: 25, + firstSeen: '2024-01-02T00:00:00Z', + lastSeen: '2024-01-12T00:00:00Z', + stats: { + '24h': [ + [1000, 7], + [2000, 15], + ], + }, + }, + }), + ]; + + const result = aggregateSupergroupStats(groups, '24h'); + + // Total stats + expect(result?.eventCount).toBe(300); + expect(result?.userCount).toBe(130); + expect(result?.mergedStats).toEqual([ + [1000, 50], + [2000, 80], + ]); + + // Filtered stats + expect(result?.filteredEventCount).toBe(100); + expect(result?.filteredUserCount).toBe(40); + expect(result?.mergedFilteredStats).toEqual([ + [1000, 10], + [2000, 20], + ]); + }); +}); diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts new file mode 100644 index 00000000000000..fd46e74398fd45 --- /dev/null +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts @@ -0,0 +1,95 @@ +import type {TimeseriesValue} from 'sentry/types/core'; +import type {Group} from 'sentry/types/group'; + +export interface AggregatedSupergroupStats { + eventCount: number; + filteredEventCount: number | null; + filteredUserCount: number | null; + firstSeen: string | null; + lastSeen: string | null; + mergedFilteredStats: TimeseriesValue[] | null; + mergedStats: TimeseriesValue[]; + userCount: number; +} + +function addTimeseries( + acc: TimeseriesValue[] | null, + series: TimeseriesValue[] +): TimeseriesValue[] { + if (acc === null) { + return series.map(([ts, val]) => [ts, val] as TimeseriesValue); + } + for (let i = 0; i < Math.min(acc.length, series.length); i++) { + acc[i] = [acc[i]![0], acc[i]![1] + series[i]![1]]; + } + return acc; +} + +/** + * Aggregate stats from member groups for display in a supergroup row. + * Sums event/user counts, takes min firstSeen and max lastSeen, + * and point-wise sums the trend data. + * + * When groups have filtered stats (from search filters), those are + * aggregated separately so the supergroup row can show total vs matching. + */ +export function aggregateSupergroupStats( + groups: Group[], + statsPeriod: string +): AggregatedSupergroupStats | null { + if (groups.length === 0) { + return null; + } + + let eventCount = 0; + let userCount = 0; + let filteredEventCount: number | null = null; + let filteredUserCount: number | null = null; + let firstSeen: string | null = null; + let lastSeen: string | null = null; + let mergedStats: TimeseriesValue[] | null = null; + let mergedFilteredStats: TimeseriesValue[] | null = null; + + for (const group of groups) { + eventCount += parseInt(group.count, 10); + userCount += group.userCount; + + if (group.filtered) { + filteredEventCount ??= 0; + filteredUserCount ??= 0; + filteredEventCount += parseInt(group.filtered.count, 10); + filteredUserCount += group.filtered.userCount; + + const filteredStats = group.filtered.stats?.[statsPeriod]; + if (filteredStats) { + mergedFilteredStats = addTimeseries(mergedFilteredStats, filteredStats); + } + } + + const gFirstSeen = group.lifetime?.firstSeen ?? group.firstSeen; + if (!firstSeen || gFirstSeen < firstSeen) { + firstSeen = gFirstSeen; + } + + const gLastSeen = group.lifetime?.lastSeen ?? group.lastSeen; + if (!lastSeen || gLastSeen > lastSeen) { + lastSeen = gLastSeen; + } + + const stats = group.stats?.[statsPeriod]; + if (stats) { + mergedStats = addTimeseries(mergedStats, stats); + } + } + + return { + eventCount, + userCount, + filteredEventCount, + filteredUserCount, + firstSeen, + lastSeen, + mergedStats: mergedStats ?? [], + mergedFilteredStats, + }; +} diff --git a/static/app/utils/supergroup/useSuperGroups.spec.tsx b/static/app/views/issueList/supergroups/useSuperGroups.spec.tsx similarity index 96% rename from static/app/utils/supergroup/useSuperGroups.spec.tsx rename to static/app/views/issueList/supergroups/useSuperGroups.spec.tsx index 123f5cc90573ea..17d271436d823e 100644 --- a/static/app/utils/supergroup/useSuperGroups.spec.tsx +++ b/static/app/views/issueList/supergroups/useSuperGroups.spec.tsx @@ -2,8 +2,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; -import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +import {useSuperGroups} from 'sentry/views/issueList/supergroups/useSuperGroups'; const organization = OrganizationFixture({features: ['top-issues-ui']}); const API_URL = `/organizations/${organization.slug}/seer/supergroups/by-group/`; diff --git a/static/app/utils/supergroup/useSuperGroups.tsx b/static/app/views/issueList/supergroups/useSuperGroups.tsx similarity index 100% rename from static/app/utils/supergroup/useSuperGroups.tsx rename to static/app/views/issueList/supergroups/useSuperGroups.tsx From 4ca6b210be3123648cc792e0f94a2768720689eb Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 10:34:51 -0700 Subject: [PATCH 08/37] fix(aci): Handle invalid project IDs in monitor form (#112220) --- .../app/views/detectors/new-setting.spec.tsx | 29 ++++++++++++++++++- static/app/views/detectors/new-settings.tsx | 10 +++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/static/app/views/detectors/new-setting.spec.tsx b/static/app/views/detectors/new-setting.spec.tsx index 434f16d58d952c..94d64f90e29324 100644 --- a/static/app/views/detectors/new-setting.spec.tsx +++ b/static/app/views/detectors/new-setting.spec.tsx @@ -7,7 +7,13 @@ import { import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import { + render, + screen, + userEvent, + waitFor, + within, +} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; import {OrganizationStore} from 'sentry/stores/organizationStore'; @@ -103,6 +109,27 @@ describe('DetectorEdit', () => { }); }); + it('selects the first project when an invalid project is provided in the URL', async () => { + render(, { + organization, + initialRouterConfig: { + ...initialRouterConfig, + location: { + ...initialRouterConfig.location, + query: {detectorType: 'metric_issue', project: 'not-a-project-id'}, + }, + }, + }); + + await screen.findByText('New Monitor'); + + // Verify the project dropdown has the first project selected + const projectSection = screen + .getByText(/Choose the Project and Environment/) + .closest('section')!; + expect(within(projectSection).getByText(project.slug)).toBeInTheDocument(); + }); + describe('Metric Detector', () => { const metricRouterConfig = { ...initialRouterConfig, diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 395e84f6cba5c8..e05ad2dc1df0d0 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -40,9 +40,13 @@ export default function DetectorNewSettings() { ); } - const project = projectId - ? projects.find(p => p.id === projectId) - : orderBy(projects, ['isMember', 'isBookmarked'], ['desc', 'desc'])[0]; + const sortedProjects = orderBy( + projects, + ['isMember', 'isBookmarked'], + ['desc', 'desc'] + ); + const project = + (projectId ? projects.find(p => p.id === projectId) : null) ?? sortedProjects[0]; if (!project) { return ; From b613ff4ae930748b17c185b0aab8f55e46fc4272 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 10:47:02 -0700 Subject: [PATCH 09/37] feat(aci): Add issue preview to uptime monitor form (#112224) --- .../forms/uptime/formatUptimeUrl.spec.ts | 17 ++++++++ .../forms/uptime/formatUptimeUrl.ts | 15 +++++++ .../components/forms/uptime/index.tsx | 10 ++--- .../forms/uptime/uptimeIssuePreview.tsx | 43 +++++++++++++++++++ .../app/views/detectors/new-setting.spec.tsx | 5 +++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts create mode 100644 static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts create mode 100644 static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx diff --git a/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts new file mode 100644 index 00000000000000..216839011e4805 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts @@ -0,0 +1,17 @@ +import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; + +describe('formatUptimeUrl', () => { + it('returns the host when the URL has no path', () => { + expect(formatUptimeUrl('https://example.com')).toBe('example.com'); + }); + + it('includes the path and strips a trailing slash', () => { + expect(formatUptimeUrl('https://example.com/health/check/')).toBe( + 'example.com/health/check' + ); + }); + + it('returns null for invalid URLs', () => { + expect(formatUptimeUrl('not-a-url')).toBeNull(); + }); +}); diff --git a/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts new file mode 100644 index 00000000000000..a46287d0d2d339 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts @@ -0,0 +1,15 @@ +/** + * Takes a full URL used by the uptime detector and formats it nicely for display purposes + * + * https://example.com/health/check/ -> example.com/health/check + */ +export function formatUptimeUrl(url: string): string | null { + const parsedUrl = URL.parse(url); + if (!parsedUrl?.hostname) { + return null; + } + + const path = parsedUrl.pathname === '/' ? '' : parsedUrl.pathname; + + return `${parsedUrl.hostname}${path}`.replace(/\/$/, ''); +} diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index 8cc3a603631564..f572af43fddf11 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -27,9 +27,11 @@ import { uptimeFormDataToEndpointPayload, uptimeSavedDetectorToFormData, } from 'sentry/views/detectors/components/forms/uptime/fields'; +import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; import {PreviewSection} from 'sentry/views/detectors/components/forms/uptime/previewSection'; import {UptimeRegionWarning} from 'sentry/views/detectors/components/forms/uptime/regionWarning'; import {UptimeDetectorResolveSection} from 'sentry/views/detectors/components/forms/uptime/resolve'; +import {UptimeIssuePreview} from 'sentry/views/detectors/components/forms/uptime/uptimeIssuePreview'; import {UptimeDetectorVerificationSection} from 'sentry/views/detectors/components/forms/uptime/verification'; const ENVIRONMENT_CONFIG: EnvironmentConfig = { @@ -49,14 +51,11 @@ function UptimeDetectorForm() { return null; } - const parsedUrl = URL.parse(url); - if (!parsedUrl) { + const urlName = formatUptimeUrl(url); + if (!urlName) { return null; } - const path = parsedUrl.pathname === '/' ? '' : parsedUrl.pathname; - const urlName = `${parsedUrl.hostname}${path}`.replace(/\/$/, ''); - return t('Uptime check for %s', urlName); }); @@ -70,6 +69,7 @@ function UptimeDetectorForm() { + ); diff --git a/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx b/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx new file mode 100644 index 00000000000000..414aaabeccee43 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/uptimeIssuePreview.tsx @@ -0,0 +1,43 @@ +import {t} from 'sentry/locale'; +import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; +import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; +import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; +import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useUptimeDetectorFormField} from 'sentry/views/detectors/components/forms/uptime/fields'; +import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; + +const FALLBACK_ISSUE_TITLE = t('Downtime detected for …'); +const SUBTITLE = t('Your monitored domain is down'); + +function useUptimeIssueTitle() { + const url = useUptimeDetectorFormField('url'); + + if (!url) { + return FALLBACK_ISSUE_TITLE; + } + + const displayUrl = formatUptimeUrl(url); + if (!displayUrl) { + return FALLBACK_ISSUE_TITLE; + } + + return t('Downtime detected for %s', displayUrl); +} + +export function UptimeIssuePreview({step}: {step?: number}) { + const owner = useUptimeDetectorFormField('owner'); + const issueTitle = useUptimeIssueTitle(); + const assignee = ownerToActor(owner); + const {project} = useDetectorFormContext(); + + return ( + + + + ); +} diff --git a/static/app/views/detectors/new-setting.spec.tsx b/static/app/views/detectors/new-setting.spec.tsx index 94d64f90e29324..adf9ab44d7a961 100644 --- a/static/app/views/detectors/new-setting.spec.tsx +++ b/static/app/views/detectors/new-setting.spec.tsx @@ -957,6 +957,11 @@ describe('DetectorEdit', () => { await userEvent.click(bodyInput); await userEvent.paste('{"test": "data"}'); + // Issue preview reflects the URL + expect( + screen.getByText('Downtime detected for uptime.example.com') + ).toBeInTheDocument(); + await selectEvent.openMenu(screen.getByLabelText('Select Environment')); expect( screen.queryByRole('menuitemradio', {name: 'All Environments'}) From 256ba06f8d0ecb01c5eb9f2ff9f5a3d111f86fb7 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Mon, 6 Apr 2026 10:47:37 -0700 Subject: [PATCH 10/37] chore(ACI): Add a flag to incident details GET method (#112230) We're ready to roll out the metric alert and incident GET methods so this PR puts a flag around the incident details GET method to use the backwards compatible serializer. --- src/sentry/features/temporary.py | 3 +++ .../incidents/endpoints/organization_incident_details.py | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b1f1b96ab2263e..66d0e7d57411f8 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -447,6 +447,9 @@ def register_temporary_features(manager: FeatureManager) -> None: # Use workflow engine exclusively for OrganizationCombinedRuleIndexEndpoint.get results. # See src/sentry/workflow_engine/docs/legacy_backport.md for context. manager.add("organizations:workflow-engine-combinedruleindex-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use workflow engine exclusively for OrganizationIncidentDetailsEndpoint.get results. + # See src/sentry/workflow_engine/docs/legacy_backport.md for context. + manager.add("organizations:workflow-engine-orgincidentdetails-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Use workflow engine exclusively for legacy issue alert rule.get results. # See src/sentry/workflow_engine/docs/legacy_backport.md for context. manager.add("organizations:workflow-engine-issue-alert-endpoints-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/src/sentry/incidents/endpoints/organization_incident_details.py b/src/sentry/incidents/endpoints/organization_incident_details.py index ead172d0fefdf7..3ea693980caa46 100644 --- a/src/sentry/incidents/endpoints/organization_incident_details.py +++ b/src/sentry/incidents/endpoints/organization_incident_details.py @@ -72,9 +72,11 @@ def convert_args( if not features.has("organizations:incidents", organization, actor=request.user): raise ResourceDoesNotExist - if request.method == "GET" and features.has( - "organizations:workflow-engine-rule-serializers", organization - ): + has_workflow_engine_flags = features.has( + "organizations:workflow-engine-orgincidentdetails-get", organization + ) or features.has("organizations:workflow-engine-rule-serializers", organization) + + if request.method == "GET" and has_workflow_engine_flags: gop: GroupOpenPeriod | None = None # Try the association table first (dual-written data). From 3faec9a1cbe9df303b81fc317f841b8f31e51be4 Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:47:39 -0700 Subject: [PATCH 11/37] fix(autofix): On repo hide, delete corresponding SeerProjectRepository rows (#112266) When repo is hidden, add `SeerProjectRepository` to list of repo child relations to make sure it gets deleted. Corresponds to Seer's [`remove_repository_from_project_preference`](https://github.com/getsentry/seer/blob/fe644b6d5cff9e58ecfc1c846ba59545f468a5e5/src/seer/automation/preferences.py#L172). --- src/sentry/deletions/defaults/repository.py | 2 ++ tests/sentry/deletions/test_repository.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py index 41d2adedc16009..62ca63fd4a4a29 100644 --- a/src/sentry/deletions/defaults/repository.py +++ b/src/sentry/deletions/defaults/repository.py @@ -10,11 +10,13 @@ def _get_repository_child_relations(instance: Repository) -> list[BaseRelation]: ) from sentry.models.commit import Commit from sentry.models.pullrequest import PullRequest + from sentry.seer.models.project_repository import SeerProjectRepository return [ ModelRelation(Commit, {"repository_id": instance.id}), ModelRelation(PullRequest, {"repository_id": instance.id}), ModelRelation(RepositoryProjectPathConfig, {"repository_id": instance.id}), + ModelRelation(SeerProjectRepository, {"repository_id": instance.id}), ] diff --git a/tests/sentry/deletions/test_repository.py b/tests/sentry/deletions/test_repository.py index c07c84f4041446..fd4e100064f807 100644 --- a/tests/sentry/deletions/test_repository.py +++ b/tests/sentry/deletions/test_repository.py @@ -14,6 +14,7 @@ from sentry.models.projectcodeowners import ProjectCodeOwners from sentry.models.pullrequest import CommentType, PullRequest, PullRequestComment from sentry.models.repository import Repository +from sentry.seer.models.project_repository import SeerProjectRepository from sentry.testutils.cases import TransactionTestCase from sentry.testutils.hybrid_cloud import HybridCloudTestMixin @@ -21,6 +22,7 @@ class DeleteRepositoryTest(TransactionTestCase, HybridCloudTestMixin): def test_simple(self) -> None: org = self.create_organization() + project = self.create_project(organization=org) repo = Repository.objects.create( organization_id=org.id, provider="dummy", @@ -63,6 +65,10 @@ def test_simple(self) -> None: created_at=timezone.now(), updated_at=timezone.now(), ) + seer_project_repo = SeerProjectRepository.objects.create( + project=project, + repository=repo, + ) self.ScheduledDeletion.schedule(instance=repo, days=0) @@ -73,6 +79,7 @@ def test_simple(self) -> None: assert not Commit.objects.filter(id=commit.id).exists() assert not PullRequest.objects.filter(id=pull.id).exists() assert not PullRequestComment.objects.filter(id=comment.id).exists() + assert not SeerProjectRepository.objects.filter(id=seer_project_repo.id).exists() assert Commit.objects.filter(id=commit2.id).exists() def test_codeowners(self) -> None: From ce960bb4b32db8b2f5c57cb35bfb472b64755719 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 6 Apr 2026 10:50:10 -0700 Subject: [PATCH 12/37] feat(github): Handle installation_repositories webhook (#112227) Re-do of https://github.com/getsentry/sentry/pull/111864 This failed due to [SENTRY-5MSP](https://sentry.sentry.io/issues/?project=1&query=SENTRY-5MSP). This was caused by us not deploying routings to control first, and so we tried to fire a control task from a cell. That's fixed in https://github.com/getsentry/sentry/pull/112226. Other than that, this is the same as the previous pr and doesn't need re-review Currently, we only sync the available repositories from Github on installing the integration. So over time, if new repositories are added to the github organization, or access to specific repositories is added or removed, we end up out of sync with which repositories we store in Sentry. To fix this, we are going to start handling the `installation_repositories` webhook. This is fired whenever the repositories that a github app can access change. This allows us to keep all the repos in sync. Note that when access to a repo is removed, we only ever disable the repo and never delete it. This allows us to keep the history of commits and so on so far. --- src/sentry/conf/server.py | 1 + src/sentry/features/temporary.py | 1 + .../integrations/bitbucket/repository.py | 3 +- .../bitbucket_server/repository.py | 3 +- src/sentry/integrations/github/repository.py | 2 +- .../integrations/github/tasks/__init__.py | 2 + .../github/tasks/link_all_repos.py | 8 +- .../tasks/sync_repos_on_install_change.py | 136 ++++++++ src/sentry/integrations/github/webhook.py | 53 ++++ .../integrations/github/webhook_types.py | 16 + .../github_enterprise/repository.py | 3 +- src/sentry/integrations/gitlab/repository.py | 3 +- .../integrations/perforce/repository.py | 2 +- .../integrations/services/repository/impl.py | 17 + .../services/repository/service.py | 15 + .../source_code_management/metrics.py | 1 + src/sentry/integrations/utils/metrics.py | 1 + src/sentry/integrations/vsts/repository.py | 2 +- .../integrations/parsers/github_enterprise.py | 10 + .../providers/integration_repository.py | 19 +- .../test_sync_repos_on_install_change.py | 205 ++++++++++++ .../{test_webhooks.py => test_webhook.py} | 298 +++++++++++++++++- .../services/repository/test_impl.py | 162 ++++++++++ .../integrations/parsers/test_github.py | 46 +++ 24 files changed, 994 insertions(+), 15 deletions(-) create mode 100644 src/sentry/integrations/github/tasks/sync_repos_on_install_change.py create mode 100644 tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py rename tests/sentry/integrations/github/{test_webhooks.py => test_webhook.py} (82%) create mode 100644 tests/sentry/integrations/services/repository/test_impl.py diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 12f13726a2d31d..057cff8966274d 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -882,6 +882,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.integrations.github.tasks.codecov_account_unlink", "sentry.integrations.github.tasks.link_all_repos", "sentry.integrations.github.tasks.pr_comment", + "sentry.integrations.github.tasks.sync_repos_on_install_change", "sentry.integrations.gitlab.tasks", "sentry.integrations.jira.tasks", "sentry.integrations.opsgenie.tasks", diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 66d0e7d57411f8..b67a8ad03124cd 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -137,6 +137,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 78aa2a539075db..17610a849ad399 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -47,7 +48,7 @@ def get_webhook_secret(self, organization): return secret def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 6b3bab8c6c463d..528e2bd6bd9466 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index b901fc89a839fd..766c1e03a8a4d7 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -52,7 +52,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["identifier"], diff --git a/src/sentry/integrations/github/tasks/__init__.py b/src/sentry/integrations/github/tasks/__init__.py index a635eebb4b9af1..cc31059167a9fb 100644 --- a/src/sentry/integrations/github/tasks/__init__.py +++ b/src/sentry/integrations/github/tasks/__init__.py @@ -2,10 +2,12 @@ from .codecov_account_unlink import codecov_account_unlink from .link_all_repos import link_all_repos from .pr_comment import github_comment_workflow +from .sync_repos_on_install_change import sync_repos_on_install_change __all__ = ( "codecov_account_link", "codecov_account_unlink", "github_comment_workflow", "link_all_repos", + "sync_repos_on_install_change", ) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index ade3e8ef83a7e0..046c0fe466236f 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from typing import Any from taskbroker_client.retry import Retry @@ -13,6 +14,7 @@ from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( RepoExistsError, + RepositoryInputConfig, get_integration_repository_provider, ) from sentry.shared_integrations.exceptions import ApiError @@ -23,9 +25,9 @@ logger = logging.getLogger(__name__) -def get_repo_config(repo, integration_id): +def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig: return { - "external_id": repo["id"], + "external_id": str(repo["id"]), "integration_id": integration_id, "identifier": repo["full_name"], } @@ -77,7 +79,7 @@ def link_all_repos( integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[dict[str, Any]] = [] + repo_configs: list[RepositoryInputConfig] = [] missing_repos = [] for repo in repositories: try: diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py new file mode 100644 index 00000000000000..c3ab3b70155163 --- /dev/null +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -0,0 +1,136 @@ +import logging +from typing import Literal + +from taskbroker_client.retry import Retry + +from sentry import features +from sentry.constants import ObjectStatus +from sentry.integrations.github.webhook_types import GitHubInstallationRepo +from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.services.repository.service import repository_service +from sentry.integrations.source_code_management.metrics import ( + SCMIntegrationInteractionEvent, + SCMIntegrationInteractionType, +) +from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.plugins.providers.integration_repository import ( + RepoExistsError, + RepositoryInputConfig, + get_integration_repository_provider, +) +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task, retry +from sentry.taskworker.namespaces import integrations_control_tasks + +from .link_all_repos import get_repo_config + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.integrations.github.tasks.sync_repos_on_install_change", + namespace=integrations_control_tasks, + retry=Retry(times=3, delay=120), + processing_deadline_duration=120, + silo_mode=SiloMode.CONTROL, +) +@retry(exclude=(RepoExistsError, KeyError)) +def sync_repos_on_install_change( + integration_id: int, + action: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], + repository_selection: Literal["all", "selected"], +) -> None: + """ + Handle GitHub installation_repositories webhook events. + + Creates Repository records for newly accessible repos and disables + records for repos that are no longer accessible, across all orgs + linked to the integration. + """ + result = integration_service.organization_contexts(integration_id=integration_id) + integration = result.integration + org_integrations = result.organization_integrations + + if integration is None or integration.status != ObjectStatus.ACTIVE: + logger.info( + "sync_repos_on_install_change.missing_or_inactive_integration", + extra={"integration_id": integration_id}, + ) + return + + if not org_integrations: + logger.info( + "sync_repos_on_install_change.no_org_integrations", + extra={"integration_id": integration_id}, + ) + return + + provider = f"integrations:{integration.provider}" + + for oi in org_integrations: + organization_id = oi.organization_id + rpc_org = organization_service.get(id=organization_id) + + if rpc_org is None: + logger.info( + "sync_repos_on_install_change.missing_organization", + extra={"organization_id": organization_id}, + ) + continue + + if not features.has("organizations:github-repo-auto-sync", rpc_org): + continue + + with SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, + integration_id=integration_id, + organization_id=organization_id, + provider_key=integration.provider, + ).capture(): + _sync_repos_for_org( + integration=integration, + rpc_org=rpc_org, + provider=provider, + repos_added=repos_added, + repos_removed=repos_removed, + ) + + +def _sync_repos_for_org( + *, + integration: RpcIntegration, + rpc_org: RpcOrganization, + provider: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], +) -> None: + if repos_added: + integration_repo_provider = get_integration_repository_provider(integration) + repo_configs: list[RepositoryInputConfig] = [] + for repo in repos_added: + try: + repo_configs.append(get_repo_config(repo, integration.id)) + except KeyError: + logger.exception("Failed to translate repository config") + continue + + if repo_configs: + try: + integration_repo_provider.create_repositories( + configs=repo_configs, organization=rpc_org + ) + except RepoExistsError: + pass + + if repos_removed: + external_ids = [str(repo["id"]) for repo in repos_removed] + repository_service.disable_repositories_by_external_ids( + organization_id=rpc_org.id, + integration_id=integration.id, + provider=provider, + external_ids=external_ids, + ) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index b5b86fea0c0a87..28e87abd1ef559 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -30,6 +30,7 @@ from sentry.integrations.github.webhook_types import ( GITHUB_WEBHOOK_TYPE_HEADER_KEY, GithubWebhookType, + InstallationRepositoriesEvent, ) from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import ( @@ -418,6 +419,57 @@ def _handle_organization_deletion( ) +class InstallationRepositoriesEventWebhook(GitHubWebhook): + """ + Handles installation_repositories events when repos are added to or + removed from the GitHub App installation. Runs in control silo. + + https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories + """ + + EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES + + def __call__( # type: ignore[override] + self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any + ) -> None: + external_id = get_github_external_id(event=event, host=host) + if external_id is None: + return + + result = integration_service.organization_contexts( + provider=self.provider, + external_id=external_id, + ) + integration = result.integration + + if integration is None: + logger.warning( + "github.installation_repositories.missing_integration", + extra={"external_id": str(external_id)}, + ) + return + + action = event["action"] + repos_added = event["repositories_added"] + repos_removed = event["repositories_removed"] + repository_selection = event["repository_selection"] + + if not repos_added and not repos_removed: + return + + from .tasks.sync_repos_on_install_change import sync_repos_on_install_change + + sync_repos_on_install_change.apply_async( + kwargs={ + "integration_id": integration.id, + "action": action, + "repos_added": repos_added, + "repos_removed": repos_removed, + "repository_selection": repository_selection, + } + ) + + class PushEventWebhook(GitHubWebhook): """https://developer.github.com/v3/activity/events/types/#pushevent""" @@ -958,6 +1010,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): _handlers: dict[GithubWebhookType, type[GitHubWebhook]] = { GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, + GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook, GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 242b201da7b362..eaad179b8ae10e 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Any, Literal, TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -29,3 +30,18 @@ class GithubWebhookType(StrEnum): CELL_PROCESSED_GITHUB_EVENTS = frozenset( t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS ) + + +class GitHubInstallationRepo(TypedDict): + id: int + full_name: str + private: bool + + +class InstallationRepositoriesEvent(TypedDict): + action: Literal["added", "removed"] + installation: dict[str, Any] + repositories_added: list[GitHubInstallationRepo] + repositories_removed: list[GitHubInstallationRepo] + repository_selection: Literal["all", "selected"] + sender: dict[str, Any] diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 5f256206ffd418..2835befdf3918a 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.github.repository import GitHubRepositoryProvider @@ -29,7 +30,7 @@ def _validate_repo(self, client, installation, repo): return repo_data def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: integration = integration_service.get_integration( integration_id=data["integration_id"], provider=self.repo_provider diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index 1b889c641c5c71..d2285d73b195f0 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 0adea7741301be..52d84dd91c13fa 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -102,7 +102,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Build repository configuration for database storage. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index 39238a71778c89..d1cf84cdcb4456 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -134,6 +134,23 @@ def disable_repositories_for_integration( provider=provider, ).update(status=ObjectStatus.DISABLED) + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + with transaction.atomic(router.db_for_write(Repository)): + Repository.objects.filter( + organization_id=organization_id, + integration_id=integration_id, + provider=provider, + external_id__in=external_ids, + status=ObjectStatus.ACTIVE, + ).update(status=ObjectStatus.DISABLED) + def disassociate_organization_integration( self, *, diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index a10d8c42852af3..51cb81c98ba835 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -85,6 +85,21 @@ def disable_repositories_for_integration( Code owners and code mappings will not be changed. """ + @cell_rpc_method(resolve=ByOrganizationId()) + @abstractmethod + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + """ + Disables specific repositories by external_id for a given integration. + Only active repositories are affected. Code mappings and commits are preserved. + """ + @cell_rpc_method(resolve=ByOrganizationId()) @abstractmethod def disassociate_organization_integration( diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 6cc035d5bcab32..a6612f5680922b 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -41,6 +41,7 @@ class SCMIntegrationInteractionType(StrEnum): # Tasks LINK_ALL_REPOS = "link_all_repos" + SYNC_REPOS_ON_INSTALL_CHANGE = "sync_repos_on_install_change" # GitHub only DERIVE_CODEMAPPINGS = "derive_codemappings" diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index 6d0f8ea33ea22a..a341f8c31833ba 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -448,6 +448,7 @@ class IntegrationWebhookEventType(StrEnum): # This represents a webhook event for an inbound sync operation, such as syncing external resources or data into Sentry. INBOUND_SYNC = "inbound_sync" INSTALLATION = "installation" + INSTALLATION_REPOSITORIES = "installation_repositories" ISSUE_COMMENT = "issue_comment" MERGE_REQUEST = "pull_request" MERGE_REQUEST_REVIEW = "pull_request_review" diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index f9a9b74007acfa..ac015771960172 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -47,7 +47,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["name"], diff --git a/src/sentry/middleware/integrations/parsers/github_enterprise.py b/src/sentry/middleware/integrations/parsers/github_enterprise.py index 3f7cbdce60d00a..02edd104dfdb47 100644 --- a/src/sentry/middleware/integrations/parsers/github_enterprise.py +++ b/src/sentry/middleware/integrations/parsers/github_enterprise.py @@ -4,8 +4,11 @@ from collections.abc import Mapping from typing import Any +from django.http import HttpRequest + from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.github.webhook import get_github_external_id +from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType from sentry.integrations.github_enterprise.webhook import GitHubEnterpriseWebhookEndpoint, get_host from sentry.integrations.types import IntegrationProviderSlug from sentry.middleware.integrations.parsers.github import GithubRequestParser @@ -18,6 +21,13 @@ class GithubEnterpriseRequestParser(GithubRequestParser): webhook_identifier = WebhookProviderIdentifier.GITHUB_ENTERPRISE webhook_endpoint = GitHubEnterpriseWebhookEndpoint + def should_route_to_control_silo( + self, parsed_event: Mapping[str, Any], request: HttpRequest + ) -> bool: + # GHE only routes installation events to control silo. + # installation_repositories is not yet supported for GHE. + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION + def _get_external_id(self, event: Mapping[str, Any]) -> str | None: host = get_host(request=self.request) if not host: diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 9be762ce766856..e238eccd3124cc 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar, NotRequired, TypedDict from dateutil.parser import parse as parse_date from rest_framework import status @@ -27,6 +28,16 @@ from sentry.utils import metrics +class RepositoryInputConfig(TypedDict): + """Input config passed to create_repositories / build_repository_config. + Providers may include additional keys beyond these.""" + + external_id: str + integration_id: int + identifier: str + installation: NotRequired[str] + + class RepositoryConfig(TypedDict): name: str external_id: str @@ -107,7 +118,7 @@ def get_installation( def create_repository( self, - repo_config: dict[str, Any], + repo_config: Mapping[str, Any], organization: RpcOrganization, ): result = self.build_repository_config(organization=organization, data=repo_config) @@ -227,7 +238,7 @@ def _update_repositories( def create_repositories( self, - configs: list[dict[str, Any]], + configs: list[RepositoryInputConfig], organization: RpcOrganization, ): external_id_to_repo_config: dict[str, RepositoryConfig] = {} @@ -354,7 +365,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Builds final dict containing all necessary data to create the repository diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py new file mode 100644 index 00000000000000..9f63922c72d299 --- /dev/null +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -0,0 +1,205 @@ +from unittest.mock import MagicMock, patch + +from sentry.constants import ObjectStatus +from sentry.integrations.github.integration import GitHubIntegrationProvider +from sentry.integrations.github.tasks.sync_repos_on_install_change import ( + sync_repos_on_install_change, +) +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode +from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test + +FEATURE_FLAG = "organizations:github-repo-auto-sync" + + +@control_silo_test +@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") +class SyncReposOnInstallChangeTestCase(IntegrationTestCase): + provider = GitHubIntegrationProvider + base_url = "https://api.github.com" + key = "github" + + def _make_repos_added(self): + return [ + {"id": 1, "full_name": "getsentry/sentry", "private": False}, + {"id": 2, "full_name": "getsentry/snuba", "private": False}, + ] + + def _make_repos_removed(self): + return [ + {"id": 3, "full_name": "getsentry/old-repo", "private": False}, + ] + + def test_repos_added(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[0].integration_id == self.integration.id + assert repos[1].name == "getsentry/snuba" + + def test_repos_removed(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_mixed_add_and_remove(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + old_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + old_repo.refresh_from_db() + assert old_repo.status == ObjectStatus.DISABLED + + active_repos = Repository.objects.filter( + organization_id=self.organization.id, + status=ObjectStatus.ACTIVE, + ).order_by("name") + assert len(active_repos) == 2 + assert active_repos[0].name == "getsentry/sentry" + assert active_repos[1].name == "getsentry/snuba" + + def test_multi_org(self, _: MagicMock) -> None: + other_org = self.create_organization(owner=self.user) + self.create_organization_integration( + organization_id=other_org.id, + integration=self.integration, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos_org1 = Repository.objects.filter(organization_id=self.organization.id) + repos_org2 = Repository.objects.filter(organization_id=other_org.id) + + assert len(repos_org1) == 2 + assert len(repos_org2) == 2 + + def test_missing_integration(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=0, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_inactive_integration(self, _: MagicMock) -> None: + self.integration.update(status=ObjectStatus.DISABLED) + + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_feature_flag_off(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_empty_repos_is_noop(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=[], + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED diff --git a/tests/sentry/integrations/github/test_webhooks.py b/tests/sentry/integrations/github/test_webhook.py similarity index 82% rename from tests/sentry/integrations/github/test_webhooks.py rename to tests/sentry/integrations/github/test_webhook.py index bd3b637b473acd..843b76e2e4dd34 100644 --- a/tests/sentry/integrations/github/test_webhooks.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,11 @@ ) from sentry import options from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook import GitHubIntegrationsWebhookEndpoint +from sentry.integrations.github.webhook import ( + GitHubIntegrationsWebhookEndpoint, + InstallationRepositoriesEventWebhook, +) +from sentry.integrations.github.webhook_types import InstallationRepositoriesEvent from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import integration_service @@ -363,6 +368,297 @@ def test_installation_deleted_skips_codecov_unlink_when_app_ids_dont_match( mock_codecov_unlink.assert_not_called() +@control_silo_test +class InstallationRepositoriesEventWebhookTest(APITestCase): + def setUp(self) -> None: + self.url = "/extensions/github/webhook/" + self.secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.secret) + + def _make_event(self, action="added", repos_added=None, repos_removed=None): + return json.dumps( + { + "action": action, + "installation": {"id": 2}, + "repositories_added": repos_added or [], + "repositories_removed": repos_removed or [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + def _compute_signatures(self, body: str) -> tuple[str, str]: + sha1 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha1", body.encode(), self.secret + ) + sha256 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha256", body.encode(), self.secret + ) + return f"sha1={sha1}", f"sha256={sha256}" + + @patch("sentry.integrations.github.webhook.InstallationRepositoriesEventWebhook.__call__") + def test_webhook_dispatches_to_handler(self, mock_call: MagicMock) -> None: + """Verify the endpoint routes installation_repositories events to the correct handler.""" + body = self._make_event( + repos_added=[{"id": 1, "full_name": "getsentry/sentry", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + assert mock_call.called + + def test_end_to_end_repos_added(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository rows created.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + body = self._make_event( + repos_added=[ + {"id": 10, "full_name": "getsentry/sentry", "private": False}, + {"id": 20, "full_name": "getsentry/snuba", "private": False}, + ], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[1].name == "getsentry/snuba" + + def test_end_to_end_repos_removed(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository disabled.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="30", + provider="integrations:github", + integration_id=integration.id, + status=ObjectStatus.ACTIVE, + ) + + body = self._make_event( + action="removed", + repos_removed=[{"id": 30, "full_name": "getsentry/old-repo", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_added(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_added dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [ + {"id": 10, "full_name": "getsentry/sentry", "private": False} + ], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == integration.id + assert kwargs["action"] == "added" + assert len(kwargs["repos_added"]) == 1 + assert kwargs["repos_added"][0]["id"] == 10 + assert kwargs["repos_removed"] == [] + assert kwargs["repository_selection"] == "selected" + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_removed(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_removed dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "removed", + "repositories_added": [], + "repositories_removed": [ + {"id": 20, "full_name": "getsentry/old-repo", "private": False} + ], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["action"] == "removed" + assert len(kwargs["repos_removed"]) == 1 + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_no_repos(self, mock_apply_async: MagicMock) -> None: + """No repos added or removed — task should not be dispatched.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_malformed_event(self, mock_apply_async: MagicMock) -> None: + """Malformed event missing required keys — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + malformed_event = cast( + InstallationRepositoriesEvent, + {"repositories_added": [{"id": 1}], "repositories_removed": []}, + ) + handler(event=malformed_event) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_integration_not_found(self, mock_apply_async: MagicMock) -> None: + """Integration doesn't exist in Sentry — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 99999}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_propagates_host_for_ghe(self, mock_apply_async: MagicMock) -> None: + """GitHub Enterprise uses host prefix for external_id.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="github.mycompany.com:2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + }, + host="github.mycompany.com", + ) + + mock_apply_async.assert_called_once() + + class PushEventWebhookTest(APITestCase): def setUp(self) -> None: self.url = "/extensions/github/webhook/" diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py new file mode 100644 index 00000000000000..a92df36cc47066 --- /dev/null +++ b/tests/sentry/integrations/services/repository/test_impl.py @@ -0,0 +1,162 @@ +from sentry.constants import ObjectStatus +from sentry.integrations.services.repository.service import repository_service +from sentry.models.repository import Repository +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import cell_silo_test + + +@cell_silo_test +class DisableRepositoriesByExternalIdsTest(TestCase): + def setUp(self) -> None: + self.integration = self.create_integration( + organization=self.organization, + external_id="1", + provider="github", + ) + self.provider = "integrations:github" + + def test_disables_matching_active_repos(self) -> None: + repo1 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo2 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100", "200"], + ) + + repo1.refresh_from_db() + repo2.refresh_from_db() + assert repo1.status == ObjectStatus.DISABLED + assert repo2.status == ObjectStatus.DISABLED + + def test_does_not_disable_already_disabled_repos(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_does_not_affect_repos_from_other_integrations(self) -> None: + other_integration = self.create_integration( + organization=self.organization, + external_id="2", + provider="github", + ) + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=other_integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_does_not_affect_repos_from_other_orgs(self) -> None: + other_org = self.create_organization() + repo = Repository.objects.create( + organization_id=other_org.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_only_disables_specified_external_ids(self) -> None: + repo_to_disable = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo_to_keep = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo_to_disable.refresh_from_db() + repo_to_keep.refresh_from_db() + assert repo_to_disable.status == ObjectStatus.DISABLED + assert repo_to_keep.status == ObjectStatus.ACTIVE + + def test_empty_external_ids_is_noop(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=[], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index 8abbfcd5de5563..eb2f7f5c470edb 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -139,6 +139,52 @@ def test_get_integration_from_request(self) -> None: result = parser.get_integration_from_request() assert result == integration + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_repositories_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={ + "installation": {"id": "1"}, + "repositories_added": [], + "repositories_removed": [], + }, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION_REPOSITORIES.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_push_does_not_route_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.PUSH.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert not parser.should_route_to_control_silo(parsed_event={}, request=request) + @override_settings(SILO_MODE=SiloMode.CONTROL) @override_cells(cell_config) def test_webhook_outbox_creation(self) -> None: From b82a00433af0a0a22f6428ecae0aade8e8d4274b Mon Sep 17 00:00:00 2001 From: Athena Moghaddam <132939361+sentaur-athena@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:02:40 -0700 Subject: [PATCH 13/37] feat(support): Add Intercom support widget frontend integration (#108409) ## End Goal Replace Zendesk with Intercom. [Plan]() ## Summary * Add feature flag to support button to open intercom * Call intercom JWT to authenticate the user * Load the intercom widget ## Related * Backend PR: [https://github.com/getsentry/sentry/pull/108408]() ## Test plan - [X] Verify messenger opens and closes - [X] Verify IntercomLink falls back to mailto when blocked - [X] Verify analytics events fire correctly - [X] E2E: Login -> Intercom boots with JWT - [X] E2E: Click "Contact Support" -> Messenger opens ## Next PR - [ ] Verify switching organization clears session - [ ] E2E: Logout -> Intercom shuts down https://github.com/user-attachments/assets/f041001e-a9ef-479d-95ea-f3b78a5dc2c5 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 8 ++ static/app/types/system.tsx | 1 + static/app/utils/intercom.tsx | 90 +++++++++++++++++++ .../navigation/primary/helpMenu.spec.tsx | 82 +++++++++++++++++ .../app/views/navigation/primary/helpMenu.tsx | 90 +++++++++++++------ .../gsApp/utils/trackGetsentryAnalytics.tsx | 8 +- 7 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 static/app/utils/intercom.tsx create mode 100644 static/app/views/navigation/primary/helpMenu.spec.tsx diff --git a/package.json b/package.json index 368d4d7d9ef294..08453fc36a573e 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@emotion/is-prop-valid": "^1.3.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@intercom/messenger-js-sdk": "^0.0.18", "@mdx-js/loader": "^3.1.0", "@popperjs/core": "^2.11.5", "@r4ai/remark-callout": "^0.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 926d36c93de1c0..3aa47b2cb836ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@emotion/styled': specifier: ^11.14.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.3))(@types/react@19.2.1)(react@19.2.3) + '@intercom/messenger-js-sdk': + specifier: ^0.0.18 + version: 0.0.18 '@mdx-js/loader': specifier: ^3.1.0 version: 3.1.0(acorn@8.16.0)(webpack@5.99.6(esbuild@0.25.10)) @@ -1970,6 +1973,9 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@intercom/messenger-js-sdk@0.0.18': + resolution: {integrity: sha512-OQbhnNh26cdI0ddIVh67JOGnSTFAHrbKF5atXuOeWpDF2Ups3O7Do1Oz42BrvQA/o0AZF+1Wqaxtc3kq70wc6w==} + '@internationalized/date@3.11.0': resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} @@ -11309,6 +11315,8 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@intercom/messenger-js-sdk@0.0.18': {} + '@internationalized/date@3.11.0': dependencies: '@swc/helpers': 0.5.15 diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx index 54cf3721c69fe0..e9a4342c49c467 100644 --- a/static/app/types/system.tsx +++ b/static/app/types/system.tsx @@ -225,6 +225,7 @@ export interface Config { latest: string; upgradeAvailable: boolean; }; + intercomAppId?: string; partnershipAgreementPrompt?: { agreements: ParntershipAgreementType[]; partnerDisplayName: string; diff --git a/static/app/utils/intercom.tsx b/static/app/utils/intercom.tsx new file mode 100644 index 00000000000000..b2147227a2c80f --- /dev/null +++ b/static/app/utils/intercom.tsx @@ -0,0 +1,90 @@ +/** + * Intercom Messenger utilities. + * + * Uses the official @intercom/messenger-js-sdk for React integration. + * Intercom is lazily initialized on first "Contact Support" click. + */ + +import {Client} from 'sentry/api'; +import {ConfigStore} from 'sentry/stores/configStore'; + +interface IntercomUserData { + createdAt: number; + email: string; + name: string; + organizationId: string; + organizationName: string; + userId: string; +} + +interface IntercomJwtResponse { + jwt: string; + userData: IntercomUserData; +} + +let hasBooted = false; +let bootPromise: Promise | null = null; + +/** + * Initialize Intercom with identity verification. + * Only fetches JWT and boots on first call. + */ +async function initIntercom(orgSlug: string): Promise { + if (hasBooted) { + return; + } + + // Prevent concurrent initialization + if (bootPromise) { + return bootPromise; + } + + bootPromise = (async () => { + try { + const intercomAppId = ConfigStore.get('intercomAppId'); + if (!intercomAppId) { + throw new Error('Intercom app ID not configured'); + } + + // Fetch JWT for identity verification + const api = new Client(); + const jwtData: IntercomJwtResponse = await api.requestPromise( + `/organizations/${orgSlug}/intercom-jwt/` + ); + + // Boot Intercom with user data + const {default: Intercom} = await import('@intercom/messenger-js-sdk'); + Intercom({ + app_id: intercomAppId, + user_id: jwtData.userData.userId, + user_hash: jwtData.jwt, + email: jwtData.userData.email, + name: jwtData.userData.name, + created_at: jwtData.userData.createdAt, + company: { + company_id: jwtData.userData.organizationId, + name: jwtData.userData.organizationName, + }, + hide_default_launcher: true, + }); + + hasBooted = true; + } catch (error) { + // Reset so user can retry on next click + bootPromise = null; + throw error; + } + })(); + + return bootPromise; +} + +/** + * Show the Intercom Messenger. + * Lazily initializes Intercom on first call. + */ +export async function showIntercom(orgSlug: string): Promise { + await initIntercom(orgSlug); + const {show} = await import('@intercom/messenger-js-sdk'); + show(); +} diff --git a/static/app/views/navigation/primary/helpMenu.spec.tsx b/static/app/views/navigation/primary/helpMenu.spec.tsx new file mode 100644 index 00000000000000..8206341d812caf --- /dev/null +++ b/static/app/views/navigation/primary/helpMenu.spec.tsx @@ -0,0 +1,82 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {ConfigStore} from 'sentry/stores/configStore'; +import * as intercom from 'sentry/utils/intercom'; +import * as zendesk from 'sentry/utils/zendesk'; +import {PrimaryNavigationHelpMenu} from 'sentry/views/navigation/primary/helpMenu'; + +jest.mock('sentry/utils/intercom', () => ({ + showIntercom: jest.fn(), +})); + +jest.mock('sentry/utils/zendesk', () => ({ + hasZendesk: jest.fn(), + activateZendesk: jest.fn(), +})); + +jest.mock('sentry/views/navigation/navigationTour', () => ({ + useNavigationTour: jest.fn(() => ({ + startTour: jest.fn(), + })), + NavigationTourReminder: ({children}: {children: React.ReactNode}) => ( +
{children}
+ ), +})); + +describe('PrimaryNavigationHelpMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + ConfigStore.set('supportEmail', 'support@sentry.io'); + }); + + it('opens Intercom when feature flag is enabled', async () => { + const organization = OrganizationFixture({ + features: ['intercom-support'], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + // Click Contact Support in the menu + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Contact Support'})); + + expect(intercom.showIntercom).toHaveBeenCalledWith(organization.slug); + expect(zendesk.activateZendesk).not.toHaveBeenCalled(); + }); + + it('opens Zendesk when feature flag is disabled and Zendesk is available', async () => { + jest.mocked(zendesk.hasZendesk).mockReturnValue(true); + + const organization = OrganizationFixture({ + features: [], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + // Click Contact Support in the menu + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Contact Support'})); + + expect(zendesk.activateZendesk).toHaveBeenCalled(); + expect(intercom.showIntercom).not.toHaveBeenCalled(); + }); + + it('falls back to mailto when neither Intercom nor Zendesk is available', async () => { + jest.mocked(zendesk.hasZendesk).mockReturnValue(false); + + const organization = OrganizationFixture({ + features: [], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + const contactSupport = screen.getByRole('menuitemradio', {name: 'Contact Support'}); + expect(contactSupport).toHaveAttribute('href', 'mailto:support@sentry.io'); + }); +}); diff --git a/static/app/views/navigation/primary/helpMenu.tsx b/static/app/views/navigation/primary/helpMenu.tsx index 93efd4cc342b58..fc96c39e49f0cf 100644 --- a/static/app/views/navigation/primary/helpMenu.tsx +++ b/static/app/views/navigation/primary/helpMenu.tsx @@ -1,3 +1,5 @@ +import {useEffect} from 'react'; + import {Flex} from '@sentry/scraps/layout'; import {openHelpSearchModal} from 'sentry/actionCreators/modal'; @@ -22,6 +24,7 @@ import {ConfigStore} from 'sentry/stores/configStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {showIntercom} from 'sentry/utils/intercom'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useOrganization} from 'sentry/utils/useOrganization'; import {activateZendesk, hasZendesk} from 'sentry/utils/zendesk'; @@ -32,6 +35,58 @@ import { import {PrimaryNavigation} from 'sentry/views/navigation/primary/components'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; +function getContactSupportItem(organization: Organization): MenuItemProps | null { + const supportEmail = ConfigStore.get('supportEmail'); + + if (!supportEmail) { + return null; + } + + const hasIntercom = organization.features.includes('intercom-support'); + + // Use Intercom if feature flag is enabled (lazily initialized on first click) + if (hasIntercom) { + return { + key: 'support', + label: t('Contact Support'), + async onAction() { + trackAnalytics('intercom_link.clicked', { + organization, + source: 'sidebar', + }); + try { + await showIntercom(organization.slug); + } catch { + // Fall back to mailto + window.location.href = `mailto:${supportEmail}`; + } + }, + }; + } + + // Fall back to Zendesk if available + if (hasZendesk()) { + return { + key: 'support', + label: t('Contact Support'), + onAction() { + activateZendesk(); + trackAnalytics('zendesk_link.clicked', { + organization, + source: 'sidebar', + }); + }, + }; + } + + // Fall back to mailto + return { + key: 'support', + label: t('Contact Support'), + externalHref: `mailto:${supportEmail}`, + }; +} + export function PrimaryNavigationHelpMenu() { const organization = useOrganization(); const contactSupportItem = getContactSupportItem(organization); @@ -39,6 +94,13 @@ export function PrimaryNavigationHelpMenu() { const {startTour} = useNavigationTour(); const {privacyUrl, termsUrl} = useLegacyStore(ConfigStore); const hasPageFrame = useHasPageFrameFeature(); + const hasIntercom = organization.features.includes('intercom-support'); + + useEffect(() => { + if (hasIntercom) { + trackAnalytics('intercom_link.viewed', {organization, source: 'sidebar'}); + } + }, [hasIntercom, organization]); const items = hasPageFrame ? getPageFrameItems({contactSupportItem, privacyUrl, termsUrl}) @@ -309,31 +371,3 @@ function getLegacyItems({ }, ]; } - -function getContactSupportItem(organization: Organization): MenuItemProps | null { - const supportEmail = ConfigStore.get('supportEmail'); - - if (!supportEmail) { - return null; - } - - if (hasZendesk()) { - return { - key: 'support', - label: t('Contact Support'), - onAction() { - activateZendesk(); - trackAnalytics('zendesk_link.clicked', { - organization, - source: 'sidebar', - }); - }, - }; - } - - return { - key: 'support', - label: t('Contact Support'), - externalHref: `mailto:${supportEmail}`, - }; -} diff --git a/static/gsApp/utils/trackGetsentryAnalytics.tsx b/static/gsApp/utils/trackGetsentryAnalytics.tsx index b378b3a6a90347..90a6923bd7fbd2 100644 --- a/static/gsApp/utils/trackGetsentryAnalytics.tsx +++ b/static/gsApp/utils/trackGetsentryAnalytics.tsx @@ -152,6 +152,8 @@ type GetsentryEventParameters = { 'growth.upsell_feature.cancelled': UpsellProvider; 'growth.upsell_feature.clicked': UpsellProvider; 'growth.upsell_feature.confirmed': UpsellProvider; + 'intercom_link.clicked': {source?: string}; + 'intercom_link.viewed': {source?: string}; 'learn_more_link.clicked': {source?: string}; 'ondemand_budget_modal.ondemand_budget.turned_off': Record; 'ondemand_budget_modal.ondemand_budget.update': OnDemandBudgetUpdate; @@ -368,9 +370,11 @@ const GETSENTRY_EVENT_MAP: Record = { 'upgrade_now.modal.sent_email': 'Upgrade Now Modal: Sent Email', 'upgrade_now.modal.update_now': 'Upgrade Now Modal: Clicked Update Now', 'upgrade_now.modal.viewed': 'Upgrade Now Modal: Viewed Modal', - 'zendesk_link.viewed': 'Zendesk Link Viewed', - 'zendesk_link.clicked': 'Zendesk Link Clicked', + 'intercom_link.clicked': 'Intercom Link Clicked', + 'intercom_link.viewed': 'Intercom Link Viewed', 'learn_more_link.clicked': 'Learn More Link Clicked', + 'zendesk_link.clicked': 'Zendesk Link Clicked', + 'zendesk_link.viewed': 'Zendesk Link Viewed', 'spend_allocations.open_form': 'Spend Allocations: Form Opened', 'spend_allocations.submit': 'Spend Allocations: Form Submitted', 'data_consent_modal.learn_more': 'Data Consent Modal: Learn More', From 5ee90a2d251369586abb316401db24b3611b31a3 Mon Sep 17 00:00:00 2001 From: Nora Shapiro Date: Mon, 6 Apr 2026 11:03:16 -0700 Subject: [PATCH 14/37] feat(llm-detection): Accept additional_attributes param in get_trace_waterfall (#112239) Allow callers to specify which additional span attributes to fetch, defaulting to `["span.status_code"]` for backward compatibility. pairs with https://github.com/getsentry/seer/pull/5633 --- src/sentry/seer/explorer/tools.py | 14 ++++++++--- tests/sentry/seer/explorer/test_tools.py | 32 +++++++++++++++++++++--- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 7764c7cb40b1c3..c5d5dc1df390df 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -371,7 +371,9 @@ def execute_trace_table_query( raise -def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: +def get_trace_waterfall( + trace_id: str, organization_id: int, additional_attributes: list[str] | None = None +) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. @@ -382,6 +384,8 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: Returns: The spans and errors in the trace, along with the full 32-character trace ID. """ + if additional_attributes is None: + additional_attributes = ["span.status_code"] try: organization = Organization.objects.get(id=organization_id) @@ -416,7 +420,7 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: events = query_trace_data( snuba_params, full_trace_id, - additional_attributes=["span.status_code"], + additional_attributes=additional_attributes, referrer=Referrer.SEER_EXPLORER_TOOLS, organization=organization, ) @@ -428,8 +432,10 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: ) -def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, Any]: - trace = get_trace_waterfall(trace_id, organization_id) +def rpc_get_trace_waterfall( + trace_id: str, organization_id: int, additional_attributes: list[str] | None = None +) -> dict[str, Any]: + trace = get_trace_waterfall(trace_id, organization_id, additional_attributes) return trace.dict() if trace else {} diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 0b87b5ae009c43..0639121baff067 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -760,11 +760,10 @@ def test_get_trace_waterfall_sliding_window_beyond_limit(self) -> None: assert result is None def test_get_trace_waterfall_includes_status_code(self) -> None: - """Test that span.status_code is included in additional_attributes.""" + """Test that span.status_code is included if additional_attributes is not provided""" transaction_name = "api/test/status" trace_id = uuid.uuid4().hex - # Create a span with status_code span = self.create_span( { "description": "http-request", @@ -782,11 +781,38 @@ def test_get_trace_waterfall_includes_status_code(self) -> None: result = get_trace_waterfall(trace_id, self.organization.id) assert isinstance(result, EAPTrace) - # Find the span and verify additional_attributes contains status_code root_span = result.trace[0] assert "additional_attributes" in root_span assert root_span["additional_attributes"].get("span.status_code") == "500" + def test_get_trace_waterfall_includes_additional_attributes(self) -> None: + """Test that additional_attributes passed into the function are included on returned traces""" + transaction_name = "api/test/status" + trace_id = uuid.uuid4().hex + + span = self.create_span( + { + "description": "http-request", + "sentry_tags": { + "transaction": transaction_name, + "status_code": "500", + "request.url": "best-url-ev3r.biz", + }, + "trace_id": trace_id, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + ) + self.store_spans([span]) + + result = get_trace_waterfall(trace_id, self.organization.id, ["request.url"]) + assert isinstance(result, EAPTrace) + + root_span = result.trace[0] + assert "additional_attributes" in root_span + assert root_span["additional_attributes"].get("span.status_code") is None + assert root_span["additional_attributes"].get("request.url") == "best-url-ev3r.biz" + class TestTraceTableQuery(APITransactionTestCase, SnubaTestCase, SpanTestCase): def setUp(self) -> None: From 5bce766bdea8e24a63182d7a81ccf2eb19d53835 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 11:38:39 -0700 Subject: [PATCH 15/37] feat(aci): Update all monitor forms use more consistent wording and a combined assign/describe section (#112205) - Removes the individual assign/describe sections - Adds a new common IssueOwnershipSection - Uses the new component in all applicable monitor forms - Adjusts some section titles to match the new designs --- .../components/forms/common/assignSection.tsx | 44 ------------ .../forms/common/describeSection.tsx | 38 ----------- .../forms/common/issueOwnershipSection.tsx | 67 +++++++++++++++++++ .../components/forms/cron/detect.tsx | 2 +- .../components/forms/cron/index.spec.tsx | 7 +- .../detectors/components/forms/cron/index.tsx | 6 +- .../components/forms/cron/resolve.tsx | 2 +- .../components/forms/metric/metric.tsx | 62 +---------------- .../components/forms/mobileBuild/index.tsx | 10 ++- .../components/forms/uptime/detect/index.tsx | 2 +- .../components/forms/uptime/index.tsx | 6 +- .../components/forms/uptime/resolve.tsx | 2 +- 12 files changed, 84 insertions(+), 164 deletions(-) delete mode 100644 static/app/views/detectors/components/forms/common/assignSection.tsx delete mode 100644 static/app/views/detectors/components/forms/common/describeSection.tsx create mode 100644 static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx diff --git a/static/app/views/detectors/components/forms/common/assignSection.tsx b/static/app/views/detectors/components/forms/common/assignSection.tsx deleted file mode 100644 index ed5bae04a85448..00000000000000 --- a/static/app/views/detectors/components/forms/common/assignSection.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {useMemo} from 'react'; -import styled from '@emotion/styled'; - -import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; -import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; -import {Container} from 'sentry/components/workflowEngine/ui/container'; -import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; -import {t} from 'sentry/locale'; -import {useProjects} from 'sentry/utils/useProjects'; - -function AssigneeField({projectId}: {projectId?: string}) { - const {projects} = useProjects(); - const memberOfProjectSlugs = useMemo(() => { - const project = projects.find(p => p.id === projectId); - return project ? [project.slug] : undefined; - }, [projects, projectId]); - - return ( - - ); -} - -export function AssignSection({step}: {step?: number}) { - const projectId = useFormField('projectId'); - - return ( - - - - - - ); -} - -const StyledMemberTeamSelectorField = styled(SentryMemberTeamSelectorField)` - padding: 0; -`; diff --git a/static/app/views/detectors/components/forms/common/describeSection.tsx b/static/app/views/detectors/components/forms/common/describeSection.tsx deleted file mode 100644 index d972b2b8a401b3..00000000000000 --- a/static/app/views/detectors/components/forms/common/describeSection.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import styled from '@emotion/styled'; - -import {TextareaField} from 'sentry/components/forms/fields/textareaField'; -import {Container} from 'sentry/components/workflowEngine/ui/container'; -import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; -import {t} from 'sentry/locale'; - -export function DescribeSection({step}: {step?: number}) { - return ( - - - - - - ); -} - -// Min height helps prevent resize after placeholder is replaced with user input -const MinHeightTextarea = styled(TextareaField)` - padding: 0; - textarea { - min-height: 140px; - } -`; diff --git a/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx new file mode 100644 index 00000000000000..d88bda06a90c52 --- /dev/null +++ b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx @@ -0,0 +1,67 @@ +import {useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {Stack} from '@sentry/scraps/layout'; + +import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; +import {TextareaField} from 'sentry/components/forms/fields/textareaField'; +import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; +import {Container} from 'sentry/components/workflowEngine/ui/container'; +import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; +import {t} from 'sentry/locale'; +import {useProjects} from 'sentry/utils/useProjects'; + +export function IssueOwnershipSection({step}: {step?: number}) { + const projectId = useFormField('projectId'); + const {projects} = useProjects(); + const memberOfProjectSlugs = useMemo(() => { + const project = projects.find(p => p.id === projectId); + return project ? [project.slug] : undefined; + }, [projects, projectId]); + + return ( + + + + + + + + + ); +} + +const OwnershipField = styled(SentryMemberTeamSelectorField)` + padding: ${p => p.theme.space.lg} 0; +`; + +// Min height helps prevent resize after placeholder is replaced with user input +const MinHeightTextarea = styled(TextareaField)` + padding: ${p => p.theme.space.lg} 0; + textarea { + min-height: 140px; + } +`; diff --git a/static/app/views/detectors/components/forms/cron/detect.tsx b/static/app/views/detectors/components/forms/cron/detect.tsx index 7e312dd6f1ea9f..0fcbc35c6ea2e9 100644 --- a/static/app/views/detectors/components/forms/cron/detect.tsx +++ b/static/app/views/detectors/components/forms/cron/detect.tsx @@ -201,7 +201,7 @@ function Thresholds() { export function CronDetectorFormDetectSection({step}: {step?: number}) { return ( - +
{t('Set your schedule')} diff --git a/static/app/views/detectors/components/forms/cron/index.spec.tsx b/static/app/views/detectors/components/forms/cron/index.spec.tsx index 15a8d409979b02..8ccf62848d5bf2 100644 --- a/static/app/views/detectors/components/forms/cron/index.spec.tsx +++ b/static/app/views/detectors/components/forms/cron/index.spec.tsx @@ -58,8 +58,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/\d\. Assign/)).toBeInTheDocument(); - expect(screen.getByText(/Description/)).toBeInTheDocument(); + expect(screen.getByText(/Issue Ownership/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -80,6 +79,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be hidden expect(screen.queryByText(/Detect/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Issue Ownership/)).not.toBeInTheDocument(); // Create Monitor button should be present but disabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -118,8 +118,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible even with platform set, because guide is "manual" expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/\d\. Assign/)).toBeInTheDocument(); - expect(screen.getByText(/Description/)).toBeInTheDocument(); + expect(screen.getByText(/Issue Ownership/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); diff --git a/static/app/views/detectors/components/forms/cron/index.tsx b/static/app/views/detectors/components/forms/cron/index.tsx index 8cdf7c7ffd8545..e3b7eca7dc386d 100644 --- a/static/app/views/detectors/components/forms/cron/index.tsx +++ b/static/app/views/detectors/components/forms/cron/index.tsx @@ -7,8 +7,7 @@ import {Stack} from '@sentry/scraps/layout'; import {t} from 'sentry/locale'; import type {CronDetector} from 'sentry/types/workflowEngine/detectors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import {ProjectSection} from 'sentry/views/detectors/components/forms/common/projectSection'; import {CronDetectorFormDetectSection} from 'sentry/views/detectors/components/forms/cron/detect'; import { @@ -34,8 +33,7 @@ const FORM_SECTIONS = [ ProjectSection, CronDetectorFormDetectSection, CronDetectorFormResolveSection, - AssignSection, - DescribeSection, + IssueOwnershipSection, CronIssuePreview, AutomateSection, ]; diff --git a/static/app/views/detectors/components/forms/cron/resolve.tsx b/static/app/views/detectors/components/forms/cron/resolve.tsx index e62533dcdd1dbe..dc0acf593d61fa 100644 --- a/static/app/views/detectors/components/forms/cron/resolve.tsx +++ b/static/app/views/detectors/components/forms/cron/resolve.tsx @@ -10,7 +10,7 @@ import {CRON_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/components export function CronDetectorFormResolveSection({step}: {step?: number}) { return ( - + ('projectId'); - const {projects} = useProjects(); - const memberOfProjectSlugs = useMemo(() => { - const project = projects.find(p => p.id === projectId); - return project ? [project.slug] : undefined; - }, [projects, projectId]); - - return ( - - - - - - - - - ); -} - function TransactionsDatasetWarningListener() { const dataset = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.dataset); if (dataset !== DetectorDataset.TRANSACTIONS) { @@ -852,15 +806,3 @@ const RequiredAsterisk = styled('span')` color: ${p => p.theme.tokens.content.danger}; margin-left: ${p => p.theme.space['2xs']}; `; - -const OwnershipField = styled(SentryMemberTeamSelectorField)` - padding: ${p => p.theme.space.lg} 0; -`; - -// Min height helps prevent resize after placeholder is replaced with user input -const MinHeightTextarea = styled(TextareaField)` - padding: ${p => p.theme.space.lg} 0; - textarea { - min-height: 140px; - } -`; diff --git a/static/app/views/detectors/components/forms/mobileBuild/index.tsx b/static/app/views/detectors/components/forms/mobileBuild/index.tsx index cac5c11ea74527..1e0c8bd23f5ead 100644 --- a/static/app/views/detectors/components/forms/mobileBuild/index.tsx +++ b/static/app/views/detectors/components/forms/mobileBuild/index.tsx @@ -10,8 +10,7 @@ import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; import type {PreprodDetector} from 'sentry/types/workflowEngine/detectors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import {ProjectSection} from 'sentry/views/detectors/components/forms/common/projectSection'; import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDetectorLayout'; import {MobileBuildDetectSection} from 'sentry/views/detectors/components/forms/mobileBuild/detectSection'; @@ -63,10 +62,9 @@ function MobileBuildDetectorForm() { /> - - - - + + + ); } diff --git a/static/app/views/detectors/components/forms/uptime/detect/index.tsx b/static/app/views/detectors/components/forms/uptime/detect/index.tsx index 29db6e86c4ec5e..9c5ca1962bdd6f 100644 --- a/static/app/views/detectors/components/forms/uptime/detect/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/detect/index.tsx @@ -62,7 +62,7 @@ function ConnectedHttpSnippet() { export function UptimeDetectorFormDetectSection({step}: {step?: number}) { return ( - + ({ diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index f572af43fddf11..f86f84e33cbc96 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -11,8 +11,7 @@ import { } from 'sentry/views/alerts/rules/uptime/previewCheckContext'; import {useUptimeAssertionFeatures} from 'sentry/views/alerts/rules/uptime/useUptimeAssertionFeatures'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import { ProjectEnvironmentSection, type EnvironmentConfig, @@ -67,8 +66,7 @@ function UptimeDetectorForm() { {hasRuntimeAssertions && } - - + diff --git a/static/app/views/detectors/components/forms/uptime/resolve.tsx b/static/app/views/detectors/components/forms/uptime/resolve.tsx index 1cddc3cb991d57..a17f2827802901 100644 --- a/static/app/views/detectors/components/forms/uptime/resolve.tsx +++ b/static/app/views/detectors/components/forms/uptime/resolve.tsx @@ -11,7 +11,7 @@ import {UPTIME_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/componen export function UptimeDetectorResolveSection({step}: {step?: number}) { return ( - +
Date: Mon, 6 Apr 2026 14:44:02 -0400 Subject: [PATCH 16/37] chore(codeowners): move /rules/ back to alerts-notifications (#112272) `/rules/` was originally owned by alerts-notifications but seems to have mistakenly been added to issue-detection. Move it back to the appropriate team. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 914e8088f3e55e..b219879a904cee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -248,6 +248,7 @@ pnpm-lock.yaml @getsentry/owners-js-de /src/sentry/snuba/subscriptions.py @getsentry/alerts-notifications /src/sentry/snuba/tasks.py @getsentry/alerts-notifications /tests/snuba/incidents/ @getsentry/alerts-notifications +/src/sentry/rules/ @getsentry/alerts-notifications /tests/sentry/rules/ @getsentry/alerts-notifications /tests/sentry/snuba/test_query_subscription_consumer.py @getsentry/alerts-notifications /tests/sentry/snuba/test_subscriptions.py @getsentry/alerts-notifications @@ -643,7 +644,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/api/helpers/group_index/ @getsentry/issue-workflow /src/sentry/api/helpers/source_map_helper.py @getsentry/issue-workflow /src/sentry/api/endpoints/ @getsentry/issue-workflow -/src/sentry/rules/ @getsentry/issue-detection-backend /src/sentry/processing_errors/ @getsentry/issue-detection-backend /src/sentry/api/helpers/group_index/delete.py @getsentry/issue-detection-backend /src/sentry/deletions/defaults/group.py @getsentry/issue-detection-backend From 4c80d59dcaf2e5082492737275f3d4ca837eca0e Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 12:35:35 -0700 Subject: [PATCH 17/37] fix(test): Upgrade framer motion, Disable animations in tests (#112270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip all framer-motion animations in tests via `MotionGlobalConfig.skipAnimations = true` [docs](https://github.com/motiondivision/motion/commit/3dffb405794e614ccb59680eddd5f17c3832ee1b) so components render immediately without waiting for animation frames or transitions. Also bumps framer-motion from 12.23.12 to 12.38.0. ## Drawer test benchmarks (before → after) | Test suite | Before | After | Speedup | |---|---|---|---| | `globalDrawer/index.spec.tsx` | 3.01s | 1.20s | 2.5x | | `eventFeatureFlagSection.spec.tsx` | 2.66s | 2.30s | 1.2x | | `breadcrumbsDataSection.spec.tsx` | 1.95s | 1.58s | 1.2x | This also benefits every other test that renders framer-motion components (modals, tooltips, any `AnimatePresence` usage). --- package.json | 2 +- pnpm-lock.yaml | 28 +++++------ .../views/issueList/actions/index.spec.tsx | 12 ++--- .../views/navigation/index.desktop.spec.tsx | 5 +- .../views/seerExplorer/explorerPanel.spec.tsx | 50 ++++++++++--------- tests/js/setup.ts | 7 +++ 6 files changed, 57 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 08453fc36a573e..2584865f8c2d39 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "echarts-for-react": "3.0.6", "esbuild": "0.25.10", "focus-trap": "7.6.5", - "framer-motion": "12.23.12", + "framer-motion": "12.38.0", "fuse.js": "^6.6.2", "gettext-parser": "7.0.1", "gl-matrix": "3.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3aa47b2cb836ef..21ac43b1c1672b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,8 +376,8 @@ importers: specifier: 7.6.5 version: 7.6.5 framer-motion: - specifier: 12.23.12 - version: 12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 12.38.0 + version: 12.38.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -6146,8 +6146,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.23.12: - resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -7606,11 +7606,11 @@ packages: moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} - motion-dom@12.23.12: - resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -16357,10 +16357,10 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.38.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.23.12 - motion-utils: 12.23.6 + motion-dom: 12.38.0 + motion-utils: 12.36.0 tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.3.1 @@ -18429,11 +18429,11 @@ snapshots: dependencies: color-name: 1.1.4 - motion-dom@12.23.12: + motion-dom@12.38.0: dependencies: - motion-utils: 12.23.6 + motion-utils: 12.36.0 - motion-utils@12.23.6: {} + motion-utils@12.36.0: {} mri@1.2.0: {} diff --git a/static/app/views/issueList/actions/index.spec.tsx b/static/app/views/issueList/actions/index.spec.tsx index df622ad112f5e8..779eebeaf8a3d8 100644 --- a/static/app/views/issueList/actions/index.spec.tsx +++ b/static/app/views/issueList/actions/index.spec.tsx @@ -108,24 +108,24 @@ describe('IssueListActions', () => { expect(screen.queryByRole('button', {name: 'Archive'})).not.toBeInTheDocument(); }); - it('shows action buttons when any items are selected', () => { + it('shows action buttons when any items are selected', async () => { render(); - expect(screen.getByRole('button', {name: 'Resolve'})).toBeEnabled(); + expect(await screen.findByRole('button', {name: 'Resolve'})).toBeEnabled(); expect(screen.getByRole('button', {name: 'Archive'})).toBeEnabled(); }); - it('shows select all checkbox as checked when all items are selected', () => { + it('shows select all checkbox as checked when all items are selected', async () => { render(); // When all selected, label changes to "Deselect all" - expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked(); + expect(await screen.findByRole('checkbox', {name: 'Deselect all'})).toBeChecked(); }); - it('shows select all checkbox as indeterminate when some items are selected', () => { + it('shows select all checkbox as indeterminate when some items are selected', async () => { render(); - const checkbox = screen.getByRole('checkbox', {name: 'Select all'}); + const checkbox = await screen.findByRole('checkbox', {name: 'Select all'}); expect(checkbox).toBePartiallyChecked(); }); }); diff --git a/static/app/views/navigation/index.desktop.spec.tsx b/static/app/views/navigation/index.desktop.spec.tsx index 9753ff54febea8..414d53ea7b90de 100644 --- a/static/app/views/navigation/index.desktop.spec.tsx +++ b/static/app/views/navigation/index.desktop.spec.tsx @@ -851,9 +851,8 @@ describe('desktop navigation', () => { await userEvent.hover(screen.getByRole('link', {name: 'Explore'})); - expect( - await within(secondaryNav).findByRole('link', {name: 'Traces'}) - ).toBeInTheDocument(); + // Re-query secondary nav because AnimatePresence remounts it with a new key + expect(await screen.findByRole('link', {name: 'Traces'})).toBeInTheDocument(); }); it('shows hovered group content in the peek view when sidebar is collapsed', async () => { diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index 93060a94aeb53a..d1c50f45289776 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -125,15 +125,15 @@ describe('ExplorerPanel', () => { }); describe('Feature Flag and Organization Checks', () => { - it('renders when feature flag and open membership are enabled', () => { + it('renders when feature flag and open membership are enabled', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText(/Ask Seer anything about your application./) + await screen.findByText(/Ask Seer anything about your application./) ).toBeInTheDocument(); }); - it('does not render when feature flag is disabled', () => { + it('does not render when feature flag is disabled', async () => { const disabledOrg = OrganizationFixture({ features: [], hideAiFeatures: false, @@ -144,10 +144,10 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('does not render when AI features are hidden', () => { + it('does not render when AI features are hidden', async () => { const disabledOrg = OrganizationFixture({ features: ['seer-explorer'], hideAiFeatures: true, @@ -158,10 +158,10 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('does not render when open membership is disabled', () => { + it('does not render when open membership is disabled', async () => { const disabledOrg = OrganizationFixture({ features: ['seer-explorer'], hideAiFeatures: false, @@ -172,28 +172,30 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); }); describe('Empty State', () => { - it('shows empty state when no messages exist', () => { + it('shows empty state when no messages exist', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText(/Ask Seer anything about your application./) + await screen.findByText(/Ask Seer anything about your application./) ).toBeInTheDocument(); }); - it('shows input section in empty state', () => { + it('shows input section in empty state', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByPlaceholderText('Type your message or / command and press Enter ↵') + await screen.findByPlaceholderText( + 'Type your message or / command and press Enter ↵' + ) ).toBeInTheDocument(); }); - it('shows error when hook returns isError=true', () => { + it('shows error when hook returns isError=true', async () => { const useSeerExplorerSpy = jest .spyOn(useSeerExplorerModule, 'useSeerExplorer') .mockReturnValue({ @@ -220,7 +222,7 @@ describe('ExplorerPanel', () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText('Error loading this session (ID=123).') + await screen.findByText('Error loading this session (ID=123).') ).toBeInTheDocument(); expect( screen.queryByText(/Ask Seer anything about your application./) @@ -231,7 +233,7 @@ describe('ExplorerPanel', () => { }); describe('Messages Display', () => { - it('renders messages when session data exists', () => { + it('renders messages when session data exists', async () => { const mockSessionData = { blocks: [ { @@ -283,7 +285,7 @@ describe('ExplorerPanel', () => { renderWithPanelContext(, true, {organization}); - expect(screen.getByText('What is this error?')).toBeInTheDocument(); + expect(await screen.findByText('What is this error?')).toBeInTheDocument(); expect( screen.getByText('This error indicates a null pointer exception.') ).toBeInTheDocument(); @@ -533,19 +535,21 @@ describe('ExplorerPanel', () => { openMembership: true, }); - it('does not render the toggle when the feature flag is disabled', () => { + it('does not render the toggle when the feature flag is disabled', async () => { renderWithPanelContext(, true, {organization}); + // Wait for effects to settle before asserting absence + await screen.findByTestId('seer-explorer-input'); expect( screen.queryByRole('checkbox', {name: 'Toggle context engine'}) ).not.toBeInTheDocument(); }); - it('renders the toggle when the feature flag is enabled', () => { + it('renders the toggle when the feature flag is enabled', async () => { renderWithPanelContext(, true, {organization: orgWithFlag}); expect( - screen.getByRole('checkbox', {name: 'Toggle context engine'}) + await screen.findByRole('checkbox', {name: 'Toggle context engine'}) ).toBeInTheDocument(); }); @@ -623,20 +627,20 @@ describe('ExplorerPanel', () => { }); describe('Visibility Control', () => { - it('renders when isVisible=true', () => { + it('renders when isVisible=true', async () => { renderWithPanelContext(, true, {organization}); - expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); + expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument(); }); - it('can handle visibility changes', () => { + it('can handle visibility changes', async () => { const {rerenderWithOpen} = renderWithPanelContext(, false, { organization, }); rerenderWithOpen(true); - expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); + expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument(); }); }); }); diff --git a/tests/js/setup.ts b/tests/js/setup.ts index e1b2144c75ddb4..28140e2047de51 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -12,6 +12,7 @@ import { import {type ReactElement} from 'react'; import {configure as configureRtl} from '@testing-library/react'; // eslint-disable-line no-restricted-imports +import {MotionGlobalConfig} from 'framer-motion'; import {enableFetchMocks} from 'jest-fetch-mock'; import {ConfigFixture} from 'sentry-fixture/config'; @@ -41,6 +42,12 @@ enableFetchMocks(); // See https://github.com/jsdom/jsdom/issues/1330 SVGElement.prototype.getTotalLength ??= () => 1; +/** + * Skip all framer-motion animations in tests so components render immediately + * without waiting for animation frames or transitions. + */ +MotionGlobalConfig.skipAnimations = true; + /** * React Testing Library configuration to override the default test id attribute * From ff5936e2500c2f4e637e03cc842b2735b16f2b79 Mon Sep 17 00:00:00 2001 From: Krithik Ravindran <84836296+krithikravi@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:56:00 -0700 Subject: [PATCH 18/37] chore(billing): added trace metrics to byte categories(BIL-2221) (#112279) https://linear.app/getsentry/issue/BIL-2221/convert-units-for-usage-total-cards-isbytecategory This PR adds byte usage formatting for trace metrics. --- static/gsApp/utils/dataCategory.spec.tsx | 3 ++- static/gsApp/utils/dataCategory.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/static/gsApp/utils/dataCategory.spec.tsx b/static/gsApp/utils/dataCategory.spec.tsx index 7c02a019984b7d..ad5ec07467ee92 100644 --- a/static/gsApp/utils/dataCategory.spec.tsx +++ b/static/gsApp/utils/dataCategory.spec.tsx @@ -416,9 +416,10 @@ describe('listDisplayNames', () => { }); describe('isByteCategory', () => { - it('verifies isByteCategory function handles both ATTACHMENTS and LOG_BYTE', () => { + it('verifies isByteCategory function handles ATTACHMENTS, LOG_BYTE, and TRACE_METRIC_BYTE', () => { expect(isByteCategory(DataCategory.ATTACHMENTS)).toBe(true); expect(isByteCategory(DataCategory.LOG_BYTE)).toBe(true); + expect(isByteCategory(DataCategory.TRACE_METRIC_BYTE)).toBe(true); expect(isByteCategory(DataCategory.ERRORS)).toBe(false); expect(isByteCategory(DataCategory.TRANSACTIONS)).toBe(false); }); diff --git a/static/gsApp/utils/dataCategory.tsx b/static/gsApp/utils/dataCategory.tsx index 61bf1e8bbdd3ef..6884e13ae502d8 100644 --- a/static/gsApp/utils/dataCategory.tsx +++ b/static/gsApp/utils/dataCategory.tsx @@ -246,7 +246,11 @@ export function isContinuousProfiling(category: DataCategory | string) { } export function isByteCategory(category: DataCategory | string) { - return category === DataCategory.ATTACHMENTS || category === DataCategory.LOG_BYTE; + return ( + category === DataCategory.ATTACHMENTS || + category === DataCategory.LOG_BYTE || + category === DataCategory.TRACE_METRIC_BYTE + ); } /** From 233b780dceb1c48a9d785b062e6815c6144d96e2 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 6 Apr 2026 16:02:47 -0400 Subject: [PATCH 19/37] feat(integrations): Add API pipeline flag for github (#112280) --- src/sentry/features/temporary.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b67a8ad03124cd..58d6483c89ec42 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -141,6 +141,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # API-driven integration setup pipeline (per-provider rollout) + manager.add("organizations:integration-api-pipeline-github", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Project Management Integrations Feature Parity Flags manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) From a7ab73e11739c86e602330330a741c5877bbcc6a Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Mon, 6 Apr 2026 13:04:25 -0700 Subject: [PATCH 20/37] ref(grouping): Remove lookahead and lookbehind from parameterization regex class (#112274) In a number of our parameterization regex patterns, we use lookaheads and lookbehinds to control when the pattern applies. There's theoretically a mechanism built into the `ParameterizationRegex` class for that: `lookahead` and `lookbehind` values can be set when defining the regex, and they'll automatically be included in the final pattern string. Most of our regexes which use lookaheads and lookbehinds don't use it, though, opting instead to include the lookahead or lookbehind directly in the pattern. Not only is this more explicit, it also accommodates patterns which need negative rather than positive lookaheads/lookbehinds, and patterns which need multiple lookaheads/lookbehinds. This PR therefore switches the only two regexes using the attributes to also use explicit lookbehinds instead, and then deletes the now-unused attributes. No behavior changes here - just an attempt to make the code a touch simpler and more consistent. --- src/sentry/grouping/parameterization.py | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index 85043c06239069..dc049f65d18457 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -18,8 +18,6 @@ class ParameterizationRegex: name: str # name of the pattern (also used as group name in combined regex) raw_pattern: str # regex pattern w/o matching group name raw_pattern_experimental: str | None = None - lookbehind: str | None = None # positive lookbehind prefix if needed - lookahead: str | None = None # positive lookahead postfix if needed # Function which takes the matched value and returns the replacement value. replacement_callback: ParameterizationReplacementFunction | None = None @@ -38,11 +36,9 @@ def experimental_pattern(self) -> str | None: def _get_pattern(self, raw_pattern: str) -> str: """ - Returns the regex pattern with a named matching group and lookbehind/lookahead if needed. + Returns the regex pattern inside of a named matching group. """ - prefix = rf"(?<={self.lookbehind})" if self.lookbehind else "" - postfix = rf"(?={self.lookahead})" if self.lookahead else "" - return rf"{prefix}(?P<{self.name}>{raw_pattern}){postfix}" + return rf"(?P<{self.name}>{raw_pattern})" def is_valid_ip(maybe_ip_str: str) -> bool: @@ -311,23 +307,25 @@ def is_valid_ip(maybe_ip_str: str) -> bool: ParameterizationRegex( name="quoted_str", raw_pattern=r""" - '([^']+)' | "([^"]+)" + # Lookbehind to ensure we'll only match the value half of `=`-type key-value + # pairs, rather than all quoted strings + (?<=[=]) + ( + '([^']+)' | + "([^"]+)" + ) """, - # Using an `=` lookbehind guarantees we'll only match the value half of key-value pairs, - # rather than all quoted strings - lookbehind="=", ), ParameterizationRegex( name="bool", raw_pattern=r""" - True | - true | - False | - false + # Lookbehind to ensure we'll only match the value half of `=`-type key-value + # pairs, rather than all instances of the words 'true' and 'false' + (?<=[=]) + ( + True | true | False | false + ) """, - # Using an `=` lookbehind guarantees we'll only match the value half of key-value pairs, - # rather than all instances of the words 'true' and 'false'. - lookbehind="=", ), ] From c947de4fe9ed492994b8fe7cbc49866c2a20902e Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 6 Apr 2026 15:07:56 -0500 Subject: [PATCH 21/37] test(onboarding): Add acceptance tests for SCM onboarding flow (#112174) Add Selenium acceptance tests for the SCM onboarding flow. The SCM flow had zero acceptance test coverage -- existing tests only cover the legacy onboarding path. Five tests covering the three main user paths plus error states: - **Happy path**: welcome -> connect repo -> detected platform -> create project. Pre-installs a GitHub integration and mocks `get_repositories`, `_validate_repo`, and `detect_platforms` to avoid real GitHub API calls. - **Skip path**: welcome -> skip integration -> manual SDK picker -> create project. Validates the flow works end-to-end without an integration. - **Install path**: welcome -> install GitHub (simulated OAuth via postMessage injection) -> repo search -> detected platform -> create project. Overrides `window.open` to return `window` itself so the `message.source === this.dialog` check in `AddIntegration` passes. Uses the real `OrganizationIntegrationSerializer` to generate the postMessage payload, avoiding mock-drift. - **Detection error fallback**: platform detection fails -> manual picker appears -> user can still complete the flow. - **Empty repo search**: search returns no results -> "No repositories found" permissions hint is displayed. All project-creating tests assert on the setup-docs SDK heading content, project name/slug/platform, and `assert_existing_projects_status` -- matching the bar set by the legacy `test_onboarding.py`. Refs VDY-54 --- .github/CODEOWNERS | 1 + tests/acceptance/test_scm_onboarding.py | 354 ++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 tests/acceptance/test_scm_onboarding.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b219879a904cee..5baea3ba65fdcb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -610,6 +610,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/gettingStartedDocs/ @getsentry/value-discovery /static/app/types/project.tsx @getsentry/value-discovery /static/app/views/onboarding/ @getsentry/value-discovery +/tests/acceptance/test_scm_onboarding.py @getsentry/value-discovery /tests/js/fixtures/detectedPlatform.ts @getsentry/value-discovery /static/app/views/projectInstall/ @getsentry/value-discovery /src/sentry/onboarding_tasks/ @getsentry/value-discovery diff --git a/tests/acceptance/test_scm_onboarding.py b/tests/acceptance/test_scm_onboarding.py new file mode 100644 index 00000000000000..0f872712336f2d --- /dev/null +++ b/tests/acceptance/test_scm_onboarding.py @@ -0,0 +1,354 @@ +from unittest import mock + +import pytest + +from sentry.api.serializers import serialize +from sentry.integrations.models.integration import Integration +from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.models.project import Project +from sentry.shared_integrations.exceptions import ApiError +from sentry.testutils.asserts import assert_existing_projects_status +from sentry.testutils.cases import AcceptanceTestCase +from sentry.testutils.silo import no_silo_test +from sentry.utils import json + +pytestmark = pytest.mark.sentry_metrics + + +@no_silo_test +class ScmOnboardingTest(AcceptanceTestCase): + def setUp(self) -> None: + super().setUp() + self.user = self.create_user("foo@example.com") + self.org = self.create_organization(name="Rowdy Tiger", owner=None) + self.team = self.create_team(organization=self.org, name="Mariachi Band") + self.member = self.create_member( + user=self.user, organization=self.org, role="owner", teams=[self.team] + ) + self.login_as(self.user) + + def create_github_integration(self) -> Integration: + integration = self.create_provider_integration( + provider="github", + name="getsentry", + external_id="12345", + metadata={"access_token": "ghu_xxxxx"}, + ) + integration.add_organization(self.org, self.user) + return integration + + def start_onboarding(self) -> None: + self.browser.get(f"/onboarding/{self.org.slug}/") + self.browser.wait_until('[data-test-id="onboarding-step-welcome"]') + self.browser.click('[data-test-id="onboarding-welcome-start"]') + self.browser.wait_until('[data-test-id="onboarding-step-scm-connect"]') + + def test_scm_onboarding_happy_path(self) -> None: + """Full flow: welcome → connect repo → detected platform → create project.""" + self.create_github_integration() + + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + mock_platforms = [ + { + "platform": "python-django", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 1, + } + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + return_value=mock_platforms, + ), + ): + self.start_onboarding() + + # SCM Connect: wait for integration to be detected, then search + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + # react-select renders a separate placeholder element, not an HTML + # placeholder attribute, so target the input by its ARIA role. + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features: select detected platform, then continue + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until('[role="radio"]') + self.browser.click('[role="radio"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details: defaults auto-fill from platform + team + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs: verify SDK heading renders, not just the step container + self.browser.wait_until(xpath='//h2[text()="Configure Django SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "python-django" + assert project.name == "python-django" + assert project.slug == "python-django" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_skip_integration(self) -> None: + """Skip flow: welcome → skip connect → manual platform → create project.""" + with self.feature({"organizations:onboarding-scm": True}): + self.start_onboarding() + + # SCM Connect: skip + self.browser.click(xpath='//button[contains(., "Skip for now")]') + + # Platform Features: manual picker + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until(xpath='//h3[text()="Select a platform"]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("React") + self.browser.wait_until( + xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]' + ) + self.browser.click(xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details: defaults auto-fill + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure React SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "javascript-react" + assert project.name == "javascript-react" + assert project.slug == "javascript-react" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_with_integration_install(self) -> None: + """Install flow: welcome → install GitHub → repo search → detected platform → create project.""" + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + mock_platforms = [ + { + "platform": "python-django", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 1, + } + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + return_value=mock_platforms, + ), + ): + self.start_onboarding() + + # SCM Connect: no integration installed, provider pills are shown. + # Override window.open so that AddIntegration stores `window` as the + # dialog reference. When we later inject a postMessage from the same + # window, `message.source === this.dialog` passes. + self.browser.driver.execute_script( + """ + window.__testOpenCalled = false; + window.open = function() { + window.__testOpenCalled = true; + return window; + }; + """ + ) + + # Wait for the providers to load, then click Install GitHub. + self.browser.wait_until(xpath='//button[contains(., "GitHub")]') + self.browser.click(xpath='//button[contains(., "GitHub")]') + assert self.browser.driver.execute_script("return window.__testOpenCalled") + + # Simulate the OAuth pipeline: create the integration in the DB, + # then serialize it with the same code path as IntegrationPipeline._dialog_response + # to avoid mock-drift between the test data and the real serializer. + integration = self.create_github_integration() + org_integration = OrganizationIntegration.objects.get( + integration=integration, organization_id=self.org.id + ) + # Resolve Django lazy objects (translations, datetimes) so + # Selenium can JSON-serialize the data for execute_script. + serialized = json.loads(json.dumps(serialize(org_integration, self.user))) + self.browser.driver.execute_script( + "window.postMessage(arguments[0], window.location.origin);", + {"success": True, "data": serialized}, + ) + + # Wait for the component to process the message and show connected state. + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + + # Repo search (same flow as happy path from here on). + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until('[role="radio"]') + self.browser.click('[role="radio"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure Django SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "python-django" + assert project.name == "python-django" + assert project.slug == "python-django" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_detection_error_falls_back_to_manual_picker(self) -> None: + """When platform detection fails, user can still select a platform manually.""" + self.create_github_integration() + + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + side_effect=ApiError("GitHub API error"), + ), + ): + self.start_onboarding() + + # SCM Connect: select a repo + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features: detection failed, should show manual picker + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until(xpath='//h3[text()="Select a platform"]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("React") + self.browser.wait_until( + xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]' + ) + self.browser.click(xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure React SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "javascript-react" + assert project.name == "javascript-react" + assert project.slug == "javascript-react" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_repo_search_no_results(self) -> None: + """Empty search results show a helpful message about permissions.""" + self.create_github_integration() + + with ( + self.feature({"organizations:onboarding-scm": True}), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=[], + ), + ): + self.start_onboarding() + + # SCM Connect: integration detected, search returns no results + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("nonexistent-repo") + self.browser.wait_until(xpath='//*[contains(text(), "No repositories found")]') From 65bd267c8ba9df8185fb607c10bca80932acb7b9 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 6 Apr 2026 16:15:59 -0400 Subject: [PATCH 22/37] ref(py): Remove backwards-compatible org serializer aliases (#112271) Requires https://github.com/getsentry/getsentry/pull/19768 --- src/sentry/api/serializers/models/organization.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 18c1c1fa8f50af..fbe4b6339b5466 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -920,8 +920,3 @@ def serialize( # type: ignore[override] ) return context - - -# Backwards-compatible aliases for getsentry -DetailedOrganizationSerializer = OrganizationSerializer -DetailedOrganizationSerializerWithProjectsAndTeams = OrganizationWithProjectsAndTeamsSerializer From cd16069dc117b479be9ee6c78a79a26f92db2978 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 13:25:58 -0700 Subject: [PATCH 23/37] fix(replays): Use Dataset enum instead of string comparisons in replay counts (#111954) `_get_replay_id_mappings` compared `data_source` (a string) against `Dataset.Discover` (the enum), so `FILTER_HAS_A_REPLAY` was never applied to Discover queries. Fix by normalizing `data_source` to the `Dataset` enum at the boundary so all comparisons are enum-to-enum. the bug was on the line that does ```py query = query + FILTER_HAS_A_REPLAY if data_source == Dataset.Discover else query ``` Co-authored-by: Claude Opus 4.6 --- src/sentry/replays/usecases/replay_counts.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sentry/replays/usecases/replay_counts.py b/src/sentry/replays/usecases/replay_counts.py index 5c71f306a8bae5..3f8ad6a50cb0c3 100644 --- a/src/sentry/replays/usecases/replay_counts.py +++ b/src/sentry/replays/usecases/replay_counts.py @@ -48,8 +48,8 @@ def get_replay_counts( if snuba_params.start is None or snuba_params.end is None or snuba_params.organization is None: raise ValueError("Must provide start and end") - if isinstance(data_source, Dataset): - data_source = data_source.value + if not isinstance(data_source, Dataset): + data_source = Dataset(data_source) replay_ids_mapping = _get_replay_id_mappings(query, snuba_params, data_source) @@ -75,7 +75,7 @@ def get_replay_counts( def _get_replay_id_mappings( query: str, snuba_params: SnubaParams, - data_source: str = Dataset.Discover.value, + data_source: Dataset = Dataset.Discover, # XXX: the returned list depends on the query and so it could be any type :( ) -> dict[str, list[Any]]: """ @@ -83,9 +83,9 @@ def _get_replay_id_mappings( If select_column is replay_id, return an identity map of replay_id -> [replay_id]. The keys of the returned dict are UUIDs, represented as 32 char hex strings (all '-'s stripped) """ - if data_source == Dataset.Discover.value: + if data_source == Dataset.Discover: search_query_func = discover.query - elif data_source == Dataset.IssuePlatform.value: + elif data_source == Dataset.IssuePlatform: search_query_func = issue_platform.query # type: ignore[assignment] else: raise ValueError("Invalid data source") From 0e5f31fb8e477e8a8bc9f9ab3528a6220e448e59 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 6 Apr 2026 16:48:59 -0400 Subject: [PATCH 24/37] ref(ui): Remove testableTransition utility (#112277) Animations are now disabled globally in tests via framer-motion's MotionGlobalConfig, making the per-transition testableTransition wrapper unnecessary. --- static/app/bootstrap/commonInitialization.tsx | 8 +++- .../checkInTimeline/timelineCursor.tsx | 3 +- .../checkInTimeline/timelineZoom.tsx | 4 +- .../core/loader/indeterminateLoader.tsx | 4 +- static/app/components/core/toast/toast.tsx | 7 ++-- .../events/autofix/autofixChanges.tsx | 5 +-- .../events/autofix/autofixHighlightPopup.tsx | 5 +-- .../events/autofix/autofixOutputStream.tsx | 9 ++--- .../events/autofix/autofixRootCause.tsx | 5 +-- .../events/autofix/autofixSolution.tsx | 5 +-- .../events/autofix/autofixSteps.tsx | 3 +- .../events/autofix/codingAgentCard.tsx | 3 +- .../autofix/insights/autofixInsightCard.tsx | 5 +-- .../components/events/autofix/v2/utils.tsx | 5 +-- static/app/components/globalModal/index.tsx | 5 +-- static/app/components/group/groupSummary.tsx | 5 +-- .../group/groupSummaryWithAutofix.tsx | 8 ++-- .../illustrations/NoProjectEmptyState.tsx | 37 +++++++++++++------ static/app/components/overlay.tsx | 7 ++-- static/app/components/pageOverlay.tsx | 16 ++++---- static/app/components/progressRing.tsx | 3 -- static/app/utils/testableTransition.tsx | 28 -------------- .../views/navigation/secondary/components.tsx | 5 +-- .../onboarding/components/fallingError.tsx | 6 +-- .../components/firstEventFooter.tsx | 16 ++------ .../onboarding/components/genericFooter.tsx | 6 +-- .../onboarding/components/newWelcome.tsx | 9 ++--- .../onboarding/components/pageCorners.tsx | 8 ++-- .../onboarding/components/stepHeading.tsx | 6 +-- .../views/onboarding/components/stepper.tsx | 6 +-- .../components/welcomeBackground.tsx | 16 ++++---- static/app/views/onboarding/consts.ts | 4 +- static/app/views/onboarding/onboarding.tsx | 8 ++-- .../views/onboarding/platformSelection.tsx | 2 - static/app/views/onboarding/welcome.tsx | 3 +- .../relocation/components/stepHeading.tsx | 6 +-- static/app/views/relocation/encryptBackup.tsx | 2 - static/app/views/relocation/getStarted.tsx | 2 - static/app/views/relocation/inProgress.tsx | 2 - static/app/views/relocation/publicKey.tsx | 3 -- static/app/views/relocation/relocation.tsx | 8 ++-- static/app/views/relocation/uploadBackup.tsx | 2 - .../illustrations/alertsBackground.tsx | 10 ++--- .../illustrations/discoverBackground.tsx | 31 ++++++---------- .../illustrations/performanceBackground.tsx | 11 +++--- .../gsApp/components/upsellModal/details.tsx | 6 +-- .../components/upsellModal/featureList.tsx | 2 - 47 files changed, 137 insertions(+), 223 deletions(-) delete mode 100644 static/app/utils/testableTransition.tsx diff --git a/static/app/bootstrap/commonInitialization.tsx b/static/app/bootstrap/commonInitialization.tsx index 0ae520feeafd00..761bd66fcef362 100644 --- a/static/app/bootstrap/commonInitialization.tsx +++ b/static/app/bootstrap/commonInitialization.tsx @@ -1,7 +1,13 @@ -import {NODE_ENV, UI_DEV_ENABLE_PROFILING} from 'sentry/constants'; +import {MotionGlobalConfig} from 'framer-motion'; + +import {IS_ACCEPTANCE_TEST, NODE_ENV, UI_DEV_ENABLE_PROFILING} from 'sentry/constants'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Config} from 'sentry/types/system'; +if (IS_ACCEPTANCE_TEST || NODE_ENV === 'test') { + MotionGlobalConfig.skipAnimations = true; +} + export function commonInitialization(config: Config) { if (NODE_ENV === 'development') { import(/* webpackMode: "eager" */ 'sentry/utils/silence-react-unsafe-warnings'); diff --git a/static/app/components/checkInTimeline/timelineCursor.tsx b/static/app/components/checkInTimeline/timelineCursor.tsx index 41f67e303bf9f0..8035d4fad285ea 100644 --- a/static/app/components/checkInTimeline/timelineCursor.tsx +++ b/static/app/components/checkInTimeline/timelineCursor.tsx @@ -4,7 +4,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {Overlay} from 'sentry/components/overlay'; import {Sticky} from 'sentry/components/sticky'; -import {testableTransition} from 'sentry/utils/testableTransition'; const TOOLTIP_OFFSET = 10; @@ -142,7 +141,7 @@ function useTimelineCursor({ initial="initial" animate="animate" exit="exit" - transition={testableTransition({duration: 0.1})} + transition={{duration: 0.1}} variants={{ initial: {opacity: 0}, animate: {opacity: 1}, diff --git a/static/app/components/checkInTimeline/timelineZoom.tsx b/static/app/components/checkInTimeline/timelineZoom.tsx index 3d704d8d3ed1ad..be5bb0192361e1 100644 --- a/static/app/components/checkInTimeline/timelineZoom.tsx +++ b/static/app/components/checkInTimeline/timelineZoom.tsx @@ -2,8 +2,6 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - /** * The minimum number in pixels which the selection should be considered valid * and will fire the onSelect handler. @@ -157,7 +155,7 @@ function useTimelineZoom({enabled = true, onSelect}: Opti initial="initial" animate="animate" exit="exit" - transition={testableTransition({duration: 0.2})} + transition={{duration: 0.2}} variants={{ initial: {opacity: 0}, animate: {opacity: 1}, diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx index bf723e44a29098..232594ffce01d9 100644 --- a/static/app/components/core/loader/indeterminateLoader.tsx +++ b/static/app/components/core/loader/indeterminateLoader.tsx @@ -7,8 +7,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {Stack} from '@sentry/scraps/layout'; -import {testableTransition} from 'sentry/utils/testableTransition'; - // required to break import cycle // eslint-disable-next-line no-relative-import-paths/no-relative-import-paths import {Text} from '../text/text'; @@ -131,7 +129,7 @@ export function IndeterminateLoader({ initial={{opacity: 0}} animate={{opacity: 1}} exit={{opacity: 0}} - transition={testableTransition({duration: 0.3})} + transition={{duration: 0.3}} > {currentMessage} diff --git a/static/app/components/core/toast/toast.tsx b/static/app/components/core/toast/toast.tsx index 501556ce74d843..9cc83bf22f8fe4 100644 --- a/static/app/components/core/toast/toast.tsx +++ b/static/app/components/core/toast/toast.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import classNames from 'classnames'; -import {motion, type HTMLMotionProps} from 'framer-motion'; +import {motion, type HTMLMotionProps, type Transition} from 'framer-motion'; import {Button} from '@sentry/scraps/button'; import {Container, Flex} from '@sentry/scraps/layout'; @@ -11,7 +11,6 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {TextOverflow} from 'sentry/components/textOverflow'; import {IconCheckmark, IconRefresh, IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import type {Theme} from 'sentry/utils/theme'; interface ToastProps { @@ -55,11 +54,11 @@ const TOAST_TRANSITION = { initial: {opacity: 0, y: 70}, animate: {opacity: 1, y: 0}, exit: {opacity: 0, y: 70}, - transition: testableTransition({ + transition: { type: 'spring', stiffness: 450, damping: 25, - }), + } satisfies Transition, }; function ToastIcon({type}: {type: Indicator['type']}) { diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx index b2a3eb10ef2a17..98f2ae50ae1e90 100644 --- a/static/app/components/events/autofix/autofixChanges.tsx +++ b/static/app/components/events/autofix/autofixChanges.tsx @@ -33,7 +33,6 @@ import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -109,7 +108,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -123,7 +122,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; function BranchButton({change}: {change: AutofixCodebaseChange}) { diff --git a/static/app/components/events/autofix/autofixHighlightPopup.tsx b/static/app/components/events/autofix/autofixHighlightPopup.tsx index 8772059515c0d0..7f7944b19dcd9f 100644 --- a/static/app/components/events/autofix/autofixHighlightPopup.tsx +++ b/static/app/components/events/autofix/autofixHighlightPopup.tsx @@ -29,7 +29,6 @@ import {IconClose, IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; import {MarkedText} from 'sentry/utils/marked/markedText'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -646,9 +645,9 @@ export function AutofixHighlightPopup(props: Props) { initial={{opacity: 0, x: 10}} animate={{opacity: 1, x: 0}} exit={{opacity: 0, x: 10}} - transition={testableTransition({ + transition={{ duration: 0.2, - })} + }} style={{ left: `${position.left}px`, top: `${position.top}px`, diff --git a/static/app/components/events/autofix/autofixOutputStream.tsx b/static/app/components/events/autofix/autofixOutputStream.tsx index d3e78ac1c66b15..1c562d33df4a5d 100644 --- a/static/app/components/events/autofix/autofixOutputStream.tsx +++ b/static/app/components/events/autofix/autofixOutputStream.tsx @@ -20,7 +20,6 @@ import {IconRefresh, IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -269,25 +268,25 @@ export function AutofixOutputStream({ initial={{opacity: 0, height: 0}} animate={{opacity: 1, height: 'auto'}} exit={{opacity: 0, height: 0}} - transition={testableTransition({ + transition={{ duration: 0.2, height: { type: 'spring', bounce: 0.2, }, - })} + }} > diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 71cb8d3966a626..af45a1cdc747a3 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -33,7 +33,6 @@ import type {Event} from 'sentry/types/event'; import {trackAnalytics} from 'sentry/utils/analytics'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {useMutation, useQuery, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; @@ -99,7 +98,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -113,7 +112,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; export function replaceHeadersWithBold(markdown: string) { diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx index 17a8eb57dfa37d..73d7e5595216c0 100644 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ b/static/app/components/events/autofix/autofixSolution.tsx @@ -34,7 +34,6 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {valueIsEqual} from 'sentry/utils/object/valueIsEqual'; import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -134,7 +133,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -148,7 +147,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; function SolutionDescription({ diff --git a/static/app/components/events/autofix/autofixSteps.tsx b/static/app/components/events/autofix/autofixSteps.tsx index cb3a8eb9d95242..6cbf91873a8d7f 100644 --- a/static/app/components/events/autofix/autofixSteps.tsx +++ b/static/app/components/events/autofix/autofixSteps.tsx @@ -22,14 +22,13 @@ import {useAutofixRepos} from 'sentry/components/events/autofix/useAutofix'; import {getAutofixRunErrorMessage} from 'sentry/components/events/autofix/utils'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useOrganization} from 'sentry/utils/useOrganization'; const animationProps: MotionNodeAnimationOptions = { exit: {opacity: 0}, initial: {opacity: 0}, animate: {opacity: 1}, - transition: testableTransition({duration: 0.3}), + transition: {duration: 0.3}, }; interface StepProps { groupId: string; diff --git a/static/app/components/events/autofix/codingAgentCard.tsx b/static/app/components/events/autofix/codingAgentCard.tsx index 0d8c61bea560e2..78ab07d3ae3620 100644 --- a/static/app/components/events/autofix/codingAgentCard.tsx +++ b/static/app/components/events/autofix/codingAgentCard.tsx @@ -21,13 +21,12 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconCode, IconOpen} from 'sentry/icons'; import {t} from 'sentry/locale'; import {sanitizedMarkedNoHeadings} from 'sentry/utils/marked/marked'; -import {testableTransition} from 'sentry/utils/testableTransition'; const animationProps: MotionNodeAnimationOptions = { exit: {opacity: 0}, initial: {opacity: 0}, animate: {opacity: 1}, - transition: testableTransition({duration: 0.3}), + transition: {duration: 0.3}, }; interface CodingAgentCardProps { diff --git a/static/app/components/events/autofix/insights/autofixInsightCard.tsx b/static/app/components/events/autofix/insights/autofixInsightCard.tsx index 5e4f842be5cd0d..6a3a7c7a5ecd20 100644 --- a/static/app/components/events/autofix/insights/autofixInsightCard.tsx +++ b/static/app/components/events/autofix/insights/autofixInsightCard.tsx @@ -17,7 +17,6 @@ import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {ellipsize} from 'sentry/utils/string/ellipsize'; -import {testableTransition} from 'sentry/utils/testableTransition'; interface AutofixInsightCardProps { groupId: string; @@ -34,7 +33,7 @@ export const cardAnimationProps = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -48,7 +47,7 @@ export const cardAnimationProps = { type: 'tween', ease: 'easeOut', }, - }), + }, }; export function FlippedReturnIcon(props: React.HTMLAttributes) { diff --git a/static/app/components/events/autofix/v2/utils.tsx b/static/app/components/events/autofix/v2/utils.tsx index 889ead33cc02ce..8e0010fb8edd41 100644 --- a/static/app/components/events/autofix/v2/utils.tsx +++ b/static/app/components/events/autofix/v2/utils.tsx @@ -4,7 +4,6 @@ import {type MotionNodeAnimationOptions} from 'framer-motion'; import {inlineCodeStyles} from '@sentry/scraps/code'; import {MarkedText} from 'sentry/utils/marked/markedText'; -import {testableTransition} from 'sentry/utils/testableTransition'; /** * Animation props for artifact cards and status cards. @@ -13,7 +12,7 @@ export const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 0.12, height: { type: 'spring', @@ -27,7 +26,7 @@ export const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; /** diff --git a/static/app/components/globalModal/index.tsx b/static/app/components/globalModal/index.tsx index ac6317af7c669e..50645446279f98 100644 --- a/static/app/components/globalModal/index.tsx +++ b/static/app/components/globalModal/index.tsx @@ -14,7 +14,6 @@ import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {ROOT_ELEMENT} from 'sentry/constants'; import {ModalStore} from 'sentry/stores/modalStore'; import {getModalPortal} from 'sentry/utils/getModalPortal'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender'; import {useLocation} from 'sentry/utils/useLocation'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; @@ -265,11 +264,11 @@ export function GlobalModal({onClose}: Props) { transition={ hasPageFrame ? theme.motion.framer.enter.moderate - : testableTransition({ + : { type: 'spring', stiffness: 450, damping: 25, - }) + } } > diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx index 25d76fcac2c404..52eb53b6b3a9fa 100644 --- a/static/app/components/group/groupSummary.tsx +++ b/static/app/components/group/groupSummary.tsx @@ -26,7 +26,6 @@ import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useApiQuery, useQueryClient, type ApiQueryKey} from 'sentry/utils/queryClient'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; @@ -306,13 +305,13 @@ function GroupSummaryCollapsed({ ; @@ -48,15 +47,15 @@ const overlayAnimation: MotionProps = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', duration: 0.2, - }), + }, }, exit: { opacity: 0, scale: 0.95, - transition: testableTransition({type: 'spring', delay: 0.1}), + transition: {type: 'spring', delay: 0.1}, }, }; diff --git a/static/app/components/pageOverlay.tsx b/static/app/components/pageOverlay.tsx index 7ffb241e38929d..2429dcef2828ec 100644 --- a/static/app/components/pageOverlay.tsx +++ b/static/app/components/pageOverlay.tsx @@ -1,12 +1,12 @@ import {useEffect, useRef} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Transition, Variants} from 'framer-motion'; import {motion} from 'framer-motion'; import {Prose} from '@sentry/scraps/text'; import {Panel} from 'sentry/components/panels/panel'; -import {testableTransition} from 'sentry/utils/testableTransition'; /** * The default wrapper for the detail text. @@ -18,7 +18,7 @@ const DefaultWrapper = styled('div')` width: 500px; `; -const subItemAnimation = { +const subItemAnimation: Variants = { initial: { opacity: 0, x: 60, @@ -26,15 +26,15 @@ const subItemAnimation = { animate: { opacity: 1, x: 0, - transition: testableTransition({ + transition: { type: 'spring', duration: 0.4, - }), + }, }, }; const Header = styled((props: React.ComponentProps) => ( - + ))` display: flex; align-items: center; @@ -43,7 +43,7 @@ const Header = styled((props: React.ComponentProps) => ( `; const Body = styled((props: React.ComponentProps) => ( - + ))` margin-bottom: ${p => p.theme.space.xl}; `; @@ -190,13 +190,13 @@ export function PageOverlay({ const Wrapper = customWrapper ?? DefaultWrapper; - const transition = testableTransition({ + const transition: Transition = { delay: 1, duration: 1.2, ease: 'easeInOut', delayChildren: animateDelay ?? (BackgroundComponent ? 0.5 : 1.5), staggerChildren: 0.15, - }); + }; return ( diff --git a/static/app/components/progressRing.tsx b/static/app/components/progressRing.tsx index 4197c97bddf566..290d7181b0db49 100644 --- a/static/app/components/progressRing.tsx +++ b/static/app/components/progressRing.tsx @@ -3,8 +3,6 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type TextProps = { percent: number; theme: Theme; @@ -67,7 +65,6 @@ const animatedTextDefaultProps = { initial: {opacity: 0, y: -10}, animate: {opacity: 1, y: 0}, exit: {opacity: 0, y: 10}, - transition: testableTransition(), }; export function ProgressRing({ diff --git a/static/app/utils/testableTransition.tsx b/static/app/utils/testableTransition.tsx deleted file mode 100644 index dc35b1415ba3df..00000000000000 --- a/static/app/utils/testableTransition.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type {Transition} from 'framer-motion'; - -import {IS_ACCEPTANCE_TEST, NODE_ENV} from 'sentry/constants'; - -/** - * Use with a framer-motion transition to disable the animation in testing - * environments. - * - * If your animation has no transition you can simply specify - * - * ```tsx - * Component.defaultProps = { - * transition: testableTransition(), - * } - * ``` - * - * This function simply disables the animation `type`. - */ -export const testableTransition = - !IS_ACCEPTANCE_TEST && NODE_ENV !== 'test' - ? (t?: Transition) => t - : function (): Transition { - return { - delay: 0, - staggerChildren: 0, - type: false, - }; - }; diff --git a/static/app/views/navigation/secondary/components.tsx b/static/app/views/navigation/secondary/components.tsx index b3ad0cb7bc95a8..b6c8fb6d6440a6 100644 --- a/static/app/views/navigation/secondary/components.tsx +++ b/static/app/views/navigation/secondary/components.tsx @@ -49,7 +49,6 @@ import { } from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -631,13 +630,13 @@ function Collapsible(props: CollapsibleProps) { initial="collapsed" animate="expanded" exit="collapsed" - transition={testableTransition({ + transition={{ type: 'spring', damping: 50, stiffness: 600, bounce: 0, visualDuration: 0.4, - })} + }} > {/* We need to wrap the children in a div to prevent the parent's flex-direction: column-reverse diff --git a/static/app/views/onboarding/components/fallingError.tsx b/static/app/views/onboarding/components/fallingError.tsx index 74b816d92f86ba..18f3e8e7a2bfb0 100644 --- a/static/app/views/onboarding/components/fallingError.tsx +++ b/static/app/views/onboarding/components/fallingError.tsx @@ -1,8 +1,6 @@ import {Component} from 'react'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type RenderProps = { fallCount: number; fallingError: React.ReactNode; @@ -49,11 +47,11 @@ export class FallingError extends Component { originY: '0', opacity: [1, 1, 1], rotateZ: [8, -8, 8], - transition: testableTransition({ + transition: { repeat: Infinity, repeatType: 'loop', duration: 4, - }), + }, }, falling: { originY: '50%', diff --git a/static/app/views/onboarding/components/firstEventFooter.tsx b/static/app/views/onboarding/components/firstEventFooter.tsx index fdc450e17ed6ec..bc49c4566ca0f7 100644 --- a/static/app/views/onboarding/components/firstEventFooter.tsx +++ b/static/app/views/onboarding/components/firstEventFooter.tsx @@ -16,7 +16,6 @@ import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton'; import {useOnboardingSidebar} from 'sentry/views/onboarding/useOnboardingSidebar'; @@ -122,10 +121,10 @@ export function FirstEventFooter({ animate: { opacity: 1, y: 0, - transition: testableTransition({ + transition: { when: 'beforeChildren', staggerChildren: 0.35, - }), + }, }, exit: {opacity: 0, y: 10}, }} @@ -133,16 +132,9 @@ export function FirstEventFooter({ {project?.firstEvent ? ( ) : ( - + )} - + {project?.firstEvent ? t('Error Received') : t('Waiting for error')} diff --git a/static/app/views/onboarding/components/genericFooter.tsx b/static/app/views/onboarding/components/genericFooter.tsx index 90b3296637c8a5..9bc94e11675678 100644 --- a/static/app/views/onboarding/components/genericFooter.tsx +++ b/static/app/views/onboarding/components/genericFooter.tsx @@ -1,17 +1,15 @@ import styled from '@emotion/styled'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export const GenericFooter = styled((props: React.ComponentProps) => ( ))` diff --git a/static/app/views/onboarding/components/newWelcome.tsx b/static/app/views/onboarding/components/newWelcome.tsx index 76df843dc79cbe..c4fb2532233f97 100644 --- a/static/app/views/onboarding/components/newWelcome.tsx +++ b/static/app/views/onboarding/components/newWelcome.tsx @@ -18,7 +18,6 @@ import { IconWarning, } from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {GenericFooter} from 'sentry/views/onboarding/components/genericFooter'; import { NewWelcomeProductCard, @@ -44,10 +43,10 @@ const STAGGER_CONTAINER: MotionProps = { variants: { initial: {}, animate: { - transition: testableTransition({ + transition: { staggerChildren: 0.1, delayChildren: 0.1, - }), + }, }, exit: {}, }, @@ -56,9 +55,9 @@ const STAGGER_CONTAINER: MotionProps = { const STAGGER_CHILDREN = { initial: {}, animate: { - transition: testableTransition({ + transition: { staggerChildren: 0.08, - }), + }, }, }; diff --git a/static/app/views/onboarding/components/pageCorners.tsx b/static/app/views/onboarding/components/pageCorners.tsx index 20169eb117b3b0..e21c41b0985057 100644 --- a/static/app/views/onboarding/components/pageCorners.tsx +++ b/static/app/views/onboarding/components/pageCorners.tsx @@ -1,18 +1,16 @@ import type {HTMLAttributes} from 'react'; import styled from '@emotion/styled'; -import type {MotionNodeAnimationOptions} from 'framer-motion'; +import type {MotionNodeAnimationOptions, Transition} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type Props = { animateVariant: MotionNodeAnimationOptions['animate']; } & HTMLAttributes; export function PageCorners({animateVariant, ...rest}: Props) { - const baseTransition = testableTransition({type: 'spring', duration: 0.8}); + const baseTransition: Transition = {type: 'spring', duration: 0.8}; // Consistent enter delay for visible variants - const delayedTransition = testableTransition({type: 'spring', duration: 0.8, delay: 1}); + const delayedTransition: Transition = {type: 'spring', duration: 0.8, delay: 1}; return ( & {step: number}) => ( ) diff --git a/static/app/views/onboarding/components/stepper.tsx b/static/app/views/onboarding/components/stepper.tsx index 7e977cd783048c..7dca720ce05970 100644 --- a/static/app/views/onboarding/components/stepper.tsx +++ b/static/app/views/onboarding/components/stepper.tsx @@ -1,8 +1,6 @@ import styled from '@emotion/styled'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - const StepperContainer = styled('div')` display: flex; flex-direction: row; @@ -44,11 +42,11 @@ export function Stepper({currentStepIndex, numSteps, onClick, ...props}: Props) {currentStepIndex === i && ( diff --git a/static/app/views/onboarding/components/welcomeBackground.tsx b/static/app/views/onboarding/components/welcomeBackground.tsx index 96c30c268f76d5..f10260f714bd44 100644 --- a/static/app/views/onboarding/components/welcomeBackground.tsx +++ b/static/app/views/onboarding/components/welcomeBackground.tsx @@ -7,8 +7,6 @@ import BugBImage from 'sentry-images/spot/seer-config-bug-1.svg'; import {Image} from '@sentry/scraps/image'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export function WelcomeBackground() { return ( @@ -35,11 +33,11 @@ function WelcomeBackgroundImages() { animate: { opacity: 1, scale: 1, - transition: testableTransition({duration: 0.5}), + transition: {duration: 0.5}, }, exit: {y: -120, opacity: 0}, }} - transition={testableTransition({duration: 0.9})} + transition={{duration: 0.9}} > @@ -52,13 +50,13 @@ function WelcomeBackgroundImages() { animate: { opacity: 1, scale: 1, - transition: testableTransition({duration: 0.5}), + transition: {duration: 0.5}, }, exit: {y: -200, opacity: 0}, }} - transition={testableTransition({ + transition={{ duration: 1.1, - })} + }} > @@ -73,7 +71,7 @@ export function WelcomeBackgroundNewUi() { animate: {}, exit: {}, }} - transition={testableTransition({staggerChildren: 0.2})} + transition={{staggerChildren: 0.2}} > diff --git a/static/app/views/onboarding/consts.ts b/static/app/views/onboarding/consts.ts index 7d6ffe3eb5815f..aaba7e2c5e8f13 100644 --- a/static/app/views/onboarding/consts.ts +++ b/static/app/views/onboarding/consts.ts @@ -1,7 +1,5 @@ import type {MotionProps} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export const ONBOARDING_WELCOME_SCREEN_SOURCE = 'targeted_onboarding'; // Child element animation - used by each staggered item @@ -11,7 +9,7 @@ export const ONBOARDING_WELCOME_STAGGER_ITEM: MotionProps = { animate: { opacity: 1, y: 0, - transition: testableTransition({duration: 0.4}), + transition: {duration: 0.4}, }, exit: {opacity: 0, y: -10}, }, diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index 6bb306970484ae..b92c9bb1ce7f1a 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -23,7 +23,6 @@ import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {PlatformKey} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -143,9 +142,9 @@ function OnboardingStepVariable(props: PropsWithChildren @@ -351,12 +350,11 @@ export function OnboardingWithoutContext() { ) => ( animate: {clipPath: 'inset(0% 0% 0% 0%)', opacity: 1}, exit: {opacity: 0}, }} - transition={testableTransition({ + transition={{ duration: 0.3, - })} + }} {...props} /> ))<{step: number}>` diff --git a/static/app/views/relocation/encryptBackup.tsx b/static/app/views/relocation/encryptBackup.tsx index 0e7c2f493f956d..f0dd02072fb680 100644 --- a/static/app/views/relocation/encryptBackup.tsx +++ b/static/app/views/relocation/encryptBackup.tsx @@ -4,7 +4,6 @@ import {CodeBlock} from '@sentry/scraps/code'; import {IconTerminal} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {ContinueButton} from 'sentry/views/relocation/components/continueButton'; import {StepHeading} from 'sentry/views/relocation/components/stepHeading'; import {Wrapper} from 'sentry/views/relocation/components/wrapper'; @@ -20,7 +19,6 @@ export function EncryptBackup(props: StepProps) { {t('Create an encrypted backup of your current self-hosted instance')} {t('Basic information needed to get started')} {t('Your relocation is under way!')} {t("Save Sentry's public key to your machine")} {publicKey ? ( ) : ( ) = animate="animate" exit="exit" variants={{animate: {}}} - transition={testableTransition({ + transition={{ staggerChildren: 0.2, - })} + }} {...props} /> ))` diff --git a/static/app/views/relocation/uploadBackup.tsx b/static/app/views/relocation/uploadBackup.tsx index c10015f0c40083..b2d2db016a80b2 100644 --- a/static/app/views/relocation/uploadBackup.tsx +++ b/static/app/views/relocation/uploadBackup.tsx @@ -9,7 +9,6 @@ import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicato import {Client} from 'sentry/api'; import {IconDelete, IconFile, IconUpload} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useUser} from 'sentry/utils/useUser'; import {StepHeading} from 'sentry/views/relocation/components/stepHeading'; @@ -112,7 +111,6 @@ export function UploadBackup({relocationState, onComplete}: StepProps) { {t('Upload Tarball to begin the relocation process')} Math.floor(Math.random() * (max - min)) + min; @@ -13,11 +11,11 @@ const backgroundAnimateIn: Variants = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', damping: 8, stiffness: 60, - }), + }, }, }; @@ -31,7 +29,7 @@ const wormholeAnimateIn: Variants = { opacity: 1, scale: 1, rotate: 0, - transition: testableTransition({delay: 2, duration: 2.5}), + transition: {delay: 2, duration: 2.5}, }, }; @@ -64,7 +62,7 @@ const shipAnimateIn: Variants = { scale: 1, translateX: 0, translateY: 0, - transition: testableTransition({duration: 0.8}), + transition: {duration: 0.8}, }, }; diff --git a/static/gsApp/components/features/illustrations/discoverBackground.tsx b/static/gsApp/components/features/illustrations/discoverBackground.tsx index 1a1cc23b970a74..2963a9a7b41b72 100644 --- a/static/gsApp/components/features/illustrations/discoverBackground.tsx +++ b/static/gsApp/components/features/illustrations/discoverBackground.tsx @@ -1,9 +1,8 @@ import {css, keyframes} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Transition} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - // Computed using SVGGeometryElement.getTotalLength() const STROKE_LENGTH = 4445; @@ -19,12 +18,12 @@ const strokeAnimation = { init: {strokeDashoffset: STROKE_LENGTH}, animate: {strokeDashoffset: 0}, }, - transition: testableTransition({ + transition: { type: 'tween', duration: 15, delay: 2.5, ease: 'linear', - }), + } satisfies Transition, }; const StrokeBackground = styled(motion.path)``; @@ -33,7 +32,7 @@ const strokeBackgroundAnimation = { init: {opacity: 0}, animate: {opacity: 0.5}, }, - transition: testableTransition({duration: 1}), + transition: {duration: 1}, }; const Dot = styled(motion.g)``; @@ -42,7 +41,6 @@ const dotAnimation = { init: {scale: 0.5, opacity: 0}, animate: {scale: 1, opacity: 1}, }, - transition: testableTransition(), }; const Guy1 = styled(motion.g)``; @@ -51,7 +49,7 @@ const guy1Animation = { init: {opacity: 0, x: 20}, animate: {opacity: 1, x: 0}, }, - transition: testableTransition({bounce: 0.15}), + transition: {bounce: 0.15}, }; const Guy2 = styled(motion.g)``; @@ -60,7 +58,7 @@ const guy2Animation = { init: {opacity: 0, x: -20}, animate: {opacity: 1, x: 0}, }, - transition: testableTransition({bounce: 0.15}), + transition: {bounce: 0.15}, }; const shake = keyframes` @@ -86,11 +84,11 @@ const errorAsteroidAnimation = { init: {opacity: 0, x: 150, y: -150}, animate: {opacity: 1, x: 0, y: 0}, }, - transition: testableTransition({ + transition: { type: 'spring', delay: 1.8, bounce: 0.15, - }), + } satisfies Transition, }; const ploom = keyframes` @@ -129,10 +127,10 @@ const landBeforeTimeAnimation = { init: {opacity: 0, filter: 'saturation(0)'}, animate: {opacity: 1, filter: 'saturation(1)'}, }, - transition: testableTransition({ + transition: { type: 'tween', duration: 1.4, - }), + } satisfies Transition, }; export function DiscoverBackground({anchorRef}: Props) { @@ -140,7 +138,7 @@ export function DiscoverBackground({anchorRef}: Props) { - - - - - - - diff --git a/static/gsApp/components/features/illustrations/performanceBackground.tsx b/static/gsApp/components/features/illustrations/performanceBackground.tsx index 1834275d05d0f2..9421c46087aec0 100644 --- a/static/gsApp/components/features/illustrations/performanceBackground.tsx +++ b/static/gsApp/components/features/illustrations/performanceBackground.tsx @@ -3,8 +3,6 @@ import styled from '@emotion/styled'; import type {Variants} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - const random = (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min; @@ -13,11 +11,11 @@ const backgroundAnimateIn: Variants = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', damping: 8, stiffness: 60, - }), + }, }, }; @@ -40,7 +38,10 @@ const Background = styled(motion.g)` const animation: Variants = { initial: {opacity: 0, translateY: -100}, - animate: {opacity: 1, translateY: 0, transition: testableTransition()}, + animate: { + opacity: 1, + translateY: 0, + }, }; const fallingKeyframes = keyframes` diff --git a/static/gsApp/components/upsellModal/details.tsx b/static/gsApp/components/upsellModal/details.tsx index 0c0c51ca55521c..45edb02fabc05a 100644 --- a/static/gsApp/components/upsellModal/details.tsx +++ b/static/gsApp/components/upsellModal/details.tsx @@ -15,7 +15,6 @@ import userMiseryImg from 'getsentry-images/features/user-misery.svg'; import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; -import {testableTransition} from 'sentry/utils/testableTransition'; import type {Subscription} from 'getsentry/types'; import {getTrialLength, hasPerformance, isTrialPlan} from 'getsentry/utils/billing'; @@ -476,13 +475,12 @@ const featureContentAnimation = { exit: { opacity: 0, x: 20, - transition: testableTransition(), }, animate: { opacity: 1, x: 0, - transition: testableTransition({ + transition: { delay: 0.02, - }), + }, }, }; diff --git a/static/gsApp/components/upsellModal/featureList.tsx b/static/gsApp/components/upsellModal/featureList.tsx index 3ffebbd12427a0..c4baa331a92004 100644 --- a/static/gsApp/components/upsellModal/featureList.tsx +++ b/static/gsApp/components/upsellModal/featureList.tsx @@ -7,7 +7,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {ProgressRing} from 'sentry/components/progressRing'; import {IconBusiness} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {MoreFeaturesLink} from 'getsentry/views/amCheckout/components/moreFeaturesLink'; @@ -51,7 +50,6 @@ export function FeatureList({ aria-selected={feat === selected ? true : undefined} data-test-id={feat.id} whileTap={{x: -7}} - transition={testableTransition()} > {feat.name} From 7d8fe401076146c61d6e18ed826a09b2992a1813 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 6 Apr 2026 16:51:04 -0400 Subject: [PATCH 25/37] ref(pipleine): Remove `data` from PipelineStepResult.advance (#112281) We're never advancing by adding data, we don't need this. --- src/sentry/pipeline/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/pipeline/types.py b/src/sentry/pipeline/types.py index 947c970f1fea06..01a1c991a2f548 100644 --- a/src/sentry/pipeline/types.py +++ b/src/sentry/pipeline/types.py @@ -41,8 +41,8 @@ class PipelineStepResult: data: dict[str, Any] = field(default_factory=dict) @classmethod - def advance(cls, data: dict[str, Any] | None = None) -> PipelineStepResult: - return cls(action=PipelineStepAction.ADVANCE, data=data or {}) + def advance(cls) -> PipelineStepResult: + return cls(action=PipelineStepAction.ADVANCE) @classmethod def stay(cls, data: dict[str, Any] | None = None) -> PipelineStepResult: From 266c806f1efaf3bbfdc5858f8f4ce5d06bf38c6e Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 6 Apr 2026 13:56:49 -0700 Subject: [PATCH 26/37] ref: Replace useUrlParams in favor of nuqs (#112268) Related to https://github.com/getsentry/frontend-tsc/issues/78, we shouldn't be using browserHistory anymore: `useNavigate()` covers cases where we want to change it, and location/nuqs covers reading and query-params too. So this aims to remove `useUrlParams` from the codebase, it's a query-param helper that is basically a bad version of nuqs. We can use nuqs instead. Related to https://github.com/getsentry/frontend-tsc/issues/78 --- .../feedback/feedbackOnboarding/sidebar.tsx | 104 +++++++++--------- .../utils/useCurrentProjectState.spec.tsx | 6 +- .../utils/useCurrentProjectState.ts | 5 +- .../virtualizedGrid/useDetailsSplit.tsx | 23 ++-- .../components/replaysOnboarding/sidebar.tsx | 101 +++++++++-------- .../replays/hooks/useActiveReplayTab.spec.tsx | 41 +++++-- .../replays/hooks/useActiveReplayTab.tsx | 55 ++++----- static/app/utils/url/useUrlParams.spec.tsx | 90 --------------- static/app/utils/url/useUrlParams.tsx | 65 ----------- .../hooks/useDashboardWidgetSource.tsx | 6 +- .../groupDistributions/useDrawerTab.tsx | 19 ++-- .../replays/detail/network/details/index.tsx | 12 +- .../replays/detail/network/details/tabs.tsx | 19 ++-- .../views/replays/detail/network/index.tsx | 3 +- .../detail/network/networkTableCell.tsx | 6 +- .../views/settings/project/projectReplays.tsx | 12 +- 16 files changed, 212 insertions(+), 355 deletions(-) delete mode 100644 static/app/utils/url/useUrlParams.spec.tsx delete mode 100644 static/app/utils/url/useUrlParams.tsx diff --git a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx index 4103268c11fc09..4c07f415670005 100644 --- a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx +++ b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx @@ -1,13 +1,14 @@ import type {ReactNode} from 'react'; import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {PlatformIcon} from 'platformicons'; import HighlightTopRightPattern from 'sentry-images/pattern/highlight-top-right.svg'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {FeedbackOnboardingLayout} from 'sentry/components/feedback/feedbackOnboarding/feedbackOnboardingLayout'; @@ -42,7 +43,6 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {SelectValue} from 'sentry/types/core'; import type {PlatformKey, Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -197,13 +197,12 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { textValue?: string; }>(jsFrameworkSelectOptions[0]!); - const defaultTab = 'npm'; const location = useLocation(); const crashReportOnboarding = location.hash === CRASH_REPORT_HASH; - const {getParamValue: setupMode, setParamValue: setSetupMode} = useUrlParams( + const [setupMode, setSetupMode] = useQueryState( 'mode', - defaultTab + parseAsStringLiteral(['npm', 'jsLoader'] as const).withDefault('npm') ); const currentPlatform = currentProject.platform @@ -211,7 +210,7 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { : otherPlatform; const webBackendPlatform = replayBackendPlatforms.includes(currentPlatform.id); - const showJsFrameworkInstructions = webBackendPlatform && setupMode() === 'npm'; + const showJsFrameworkInstructions = webBackendPlatform && setupMode === 'npm'; const crashApiPlatform = feedbackCrashApiPlatforms.includes(currentPlatform.id); const widgetPlatform = feedbackWidgetPlatforms.includes(currentPlatform.id); @@ -261,48 +260,50 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { const radioButtons = (
{showRadioButtons ? ( - - {tct('I use [platformSelect]', { - platformSelect: ( - ( - - {jsFramework.label ?? triggerProps.children} - - )} - value={jsFramework.value} - onChange={setJsFramework} - options={jsFrameworkSelectOptions} - position="bottom-end" - key={jsFramework.textValue} - disabled={setupMode() === 'jsLoader'} + + + label="mode" + choices={[ + [ + 'npm', + webBackendPlatform ? ( + + {tct('I use [platformSelect]', { + platformSelect: ( + ( + + {jsFramework.label ?? triggerProps.children} + + )} + value={jsFramework.value} + onChange={setJsFramework} + options={jsFrameworkSelectOptions} + position="bottom-end" + key={jsFramework.textValue} + disabled={setupMode === 'jsLoader'} + /> + ), + })} + {jsFrameworkDocs?.platformOptions && ( + - ), - })} - {jsFrameworkDocs?.platformOptions && ( - - )} - - ) : ( - t('I use NPM or Yarn') - ), - ], - ['jsLoader', t('I use HTML templates (Loader Script)')], - ]} - value={setupMode()} - onChange={setSetupMode} - tooltipPosition="top-start" - /> + )} + + ) : ( + t('I use NPM or Yarn') + ), + ], + ['jsLoader', t('I use HTML templates (Loader Script)')], + ]} + value={setupMode} + onChange={value => setSetupMode(value)} + tooltipPosition="top-start" + /> + ) : ( (newDocs?.platformOptions?.siblingOption || newDocs?.platformOptions?.packageManager) && @@ -370,9 +371,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { return 'feedbackOnboardingCrashApi'; } if ( - setupMode() === 'npm' || // switched to NPM option - (!setupMode() && defaultTab === 'npm' && widgetPlatform) || // default value for FE frameworks when ?mode={...} in URL is not set yet - npmOnlyFramework // even if '?mode=jsLoader', only show npm instructions for FE frameworks) + setupMode === 'npm' || // switched to NPM option + npmOnlyFramework // even if '?mode=jsLoader', only show npm instructions for FE frameworks ) { return 'feedbackOnboardingNpm'; } @@ -431,7 +431,3 @@ const StyledIdBadge = styled(IdBadge)` white-space: nowrap; flex-shrink: 1; `; - -const StyledRadioGroup = styled(RadioGroup)` - padding: ${p => p.theme.space.md} 0; -`; diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx index 1fede2a07f7ec9..6efaad65e9d54f 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx +++ b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx @@ -1,6 +1,7 @@ import {createMemoryRouter, RouterProvider} from 'react-router-dom'; import {ProjectFixture} from 'sentry-fixture/project'; +import {SentryNuqsTestingAdapter} from 'sentry-test/nuqsTestingAdapter'; import {act, renderHook} from 'sentry-test/reactTestingLibrary'; import {useCurrentProjectState} from 'sentry/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState'; @@ -16,14 +17,15 @@ import type {Project} from 'sentry/types/project'; function createWrapper(projectSlug?: string) { return function Wrapper({children}: any) { + const wrapped = {children}; const memoryRouter = createMemoryRouter([ { path: '/', - element: children, + element: wrapped, }, { path: '/:projectId/', - element: children, + element: wrapped, }, ]); diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts index c1649f331ee07c..8d9b9ef80dc052 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts +++ b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts @@ -1,12 +1,12 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; import partition from 'lodash/partition'; +import {parseAsString, useQueryState} from 'nuqs'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import type {OnboardingDrawerKey} from 'sentry/stores/onboardingDrawerStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {PlatformKey, Project} from 'sentry/types/project'; import {getSelectedProjectList} from 'sentry/utils/project/useSelectedProjectsHaveField'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useProjects} from 'sentry/utils/useProjects'; type Props = { @@ -24,8 +24,7 @@ export function useCurrentProjectState({ }: Props) { const {projects, initiallyLoaded: projectsLoaded} = useProjects(); const {selection, isReady} = useLegacyStore(PageFiltersStore); - const {getParamValue: projectIds} = useUrlParams('project'); - const projectId = projectIds()?.split('&').at(0); + const [projectId] = useQueryState('project', parseAsString); const isActive = currentPanel === targetPanel; // Projects with onboarding instructions diff --git a/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx b/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx index 030ff2a8aa7760..df947f99345f0b 100644 --- a/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx +++ b/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx @@ -1,7 +1,7 @@ import type {RefObject} from 'react'; import {useCallback} from 'react'; +import {parseAsInteger, useQueryState} from 'nuqs'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; interface OnClickProps { @@ -26,26 +26,23 @@ export function useDetailsSplit({ onShowDetails, urlParamName, }: Props) { - const {getParamValue: getDetailIndex, setParamValue: setDetailIndex} = useUrlParams( - urlParamName, - '' - ); + const [detailIndex, setDetailIndex] = useQueryState(urlParamName, parseAsInteger); const onClickCell = useCallback( ({dataIndex, rowIndex}: OnClickProps) => { - if (getDetailIndex() === String(dataIndex)) { - setDetailIndex(''); + if (detailIndex === dataIndex) { + setDetailIndex(null); onHideDetails?.(); } else { - setDetailIndex(String(dataIndex)); + setDetailIndex(dataIndex); onShowDetails?.({dataIndex, rowIndex}); } }, - [getDetailIndex, setDetailIndex, onHideDetails, onShowDetails] + [detailIndex, setDetailIndex, onHideDetails, onShowDetails] ); const onCloseDetailsSplit = useCallback(() => { - setDetailIndex(''); + setDetailIndex(null); onHideDetails?.(); }, [setDetailIndex, onHideDetails]); @@ -63,13 +60,15 @@ export function useDetailsSplit({ const maxContainerHeight = (containerRef.current?.clientHeight || window.innerHeight) - handleHeight; const splitSize = - frames && getDetailIndex() ? Math.min(maxContainerHeight, containerSize) : undefined; + frames && detailIndex !== null + ? Math.min(maxContainerHeight, containerSize) + : undefined; return { onClickCell, onCloseDetailsSplit, resizableDrawerProps, - selectedIndex: getDetailIndex(), + selectedIndex: detailIndex, splitSize, }; } diff --git a/static/app/components/replaysOnboarding/sidebar.tsx b/static/app/components/replaysOnboarding/sidebar.tsx index 6a6e0e7b3ec0b3..4d1c2699c8cf7e 100644 --- a/static/app/components/replaysOnboarding/sidebar.tsx +++ b/static/app/components/replaysOnboarding/sidebar.tsx @@ -1,13 +1,14 @@ import type {ReactNode} from 'react'; import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {PlatformIcon} from 'platformicons'; import HighlightTopRightPattern from 'sentry-images/pattern/highlight-top-right.svg'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; @@ -38,7 +39,6 @@ import { import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {SelectValue} from 'sentry/types/core'; import type {PlatformKey, Project} from 'sentry/types/project'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; export function useReplaysOnboardingDrawer() { @@ -223,13 +223,12 @@ function OnboardingContent({ .filter((p): p is PlatformKey => p !== 'javascript') .includes(currentProject.platform); - const defaultTab = 'jsLoader'; - const {getParamValue: setupMode, setParamValue: setSetupMode} = useUrlParams( + const [setupMode, setSetupMode] = useQueryState( 'mode', - defaultTab + parseAsStringLiteral(['npm', 'jsLoader'] as const).withDefault('jsLoader') ); - const showJsFrameworkInstructions = backendPlatform && setupMode() === 'npm'; + const showJsFrameworkInstructions = backendPlatform && setupMode === 'npm'; const currentPlatform = currentProject.platform ? (platforms.find(p => p.id === currentProject.platform) ?? otherPlatform) @@ -243,7 +242,7 @@ function OnboardingContent({ projectKeyId, } = useLoadGettingStarted({ platform: - showJsFrameworkInstructions && setupMode() === 'npm' + showJsFrameworkInstructions && setupMode === 'npm' ? (replayJsFrameworkOptions().find(p => p.id === jsFramework.value) ?? replayJsFrameworkOptions()[0]!) : currentPlatform, @@ -269,47 +268,49 @@ function OnboardingContent({ const radioButtons = (
{showRadioButtons ? ( - - {tct('I use [platformSelect]', { - platformSelect: ( - ( - - {jsFramework.label ?? triggerProps.children} - - )} - value={jsFramework.value} - onChange={setJsFramework} - options={jsFrameworkSelectOptions} - position="bottom-end" - key={jsFramework.textValue} - disabled={setupMode() === 'jsLoader'} + + + label="mode" + choices={[ + [ + 'npm', + backendPlatform ? ( + + {tct('I use [platformSelect]', { + platformSelect: ( + ( + + {jsFramework.label ?? triggerProps.children} + + )} + value={jsFramework.value} + onChange={setJsFramework} + options={jsFrameworkSelectOptions} + position="bottom-end" + key={jsFramework.textValue} + disabled={setupMode === 'jsLoader'} + /> + ), + })} + {jsFrameworkDocs?.platformOptions && ( + - ), - })} - {jsFrameworkDocs?.platformOptions && ( - - )} - - ) : ( - t('I use NPM or Yarn') - ), - ], - ['jsLoader', t('I use HTML templates (Loader Script)')], - ]} - value={setupMode()} - onChange={setSetupMode} - /> + )} + + ) : ( + t('I use NPM or Yarn') + ), + ], + ['jsLoader', t('I use HTML templates (Loader Script)')], + ]} + value={setupMode} + onChange={value => setSetupMode(value)} + /> + ) : ( !mobilePlatform && (docs?.platformOptions?.siblingOption || docs?.platformOptions?.packageManager) && @@ -405,7 +406,7 @@ function OnboardingContent({ platformKey={currentPlatform.id} project={currentProject} configType={ - setupMode() === 'npm' || // switched to NPM option + setupMode === 'npm' || // switched to NPM option npmOnlyFramework || mobilePlatform // even if '?mode=jsLoader', only show npm/default instructions for FE frameworks & mobile platforms ? 'replayOnboarding' @@ -452,7 +453,3 @@ const StyledIdBadge = styled(IdBadge)` white-space: nowrap; flex-shrink: 1; `; - -const StyledRadioGroup = styled(RadioGroup)` - padding: ${p => p.theme.space.md} 0; -`; diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx index 34dfba24dcc860..954b5d0161c86a 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx @@ -1,7 +1,7 @@ import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {setWindowLocation} from 'sentry-test/utils'; import {TabKey, useActiveReplayTab} from 'sentry/utils/replays/hooks/useActiveReplayTab'; @@ -40,9 +40,12 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.AI); }); - it('should set the default tab if the name is invalid', () => { + it('should set the default tab if the name is invalid', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({ features: ['gen-ai-features', 'replay-ai-summaries'], }), @@ -50,7 +53,12 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.AI); act(() => result.current.setActiveTab('foo bar')); - expect(router.location.query).toEqual({query: 'click.tag:button', t_main: 'ai'}); + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'ai', + }); + }); }); it('should use AI as default for video replays when replay-ai-summaries-mobile is enabled', () => { @@ -103,34 +111,43 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); }); - it('should set the default tab if the name is invalid', () => { + it('should set the default tab if the name is invalid', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({features: []}), }); expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); act(() => result.current.setActiveTab('foo bar')); - expect(router.location.query).toEqual({ - query: 'click.tag:button', - t_main: 'breadcrumbs', + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'breadcrumbs', + }); }); }); }); }); - it('should allow case-insensitive tab names', () => { + it('should allow case-insensitive tab names', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({features: []}), }); expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); act(() => result.current.setActiveTab('nEtWoRk')); - - expect(router.location.query).toEqual({ - query: 'click.tag:button', - t_main: 'network', + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'network', + }); }); }); }); diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.tsx index fe288e8fa36f8d..0379b28ebc12d6 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.tsx @@ -1,8 +1,8 @@ import {useCallback} from 'react'; +import {createParser, parseAsStringLiteral, useQueryState} from 'nuqs'; import {useOrganizationSeerSetup} from 'sentry/components/events/autofix/useOrganizationSeerSetup'; import {defined} from 'sentry/utils'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; export enum TabKey { @@ -10,34 +10,38 @@ export enum TabKey { BREADCRUMBS = 'breadcrumbs', CONSOLE = 'console', ERRORS = 'errors', + LOGS = 'logs', MEMORY = 'memory', NETWORK = 'network', + PLAYLIST = 'playlist', TAGS = 'tags', TRACE = 'trace', - LOGS = 'logs', - PLAYLIST = 'playlist', } function isReplayTab({tab, isVideoReplay}: {isVideoReplay: boolean; tab: string}) { - const supportedVideoTabs = [ - TabKey.TAGS, - TabKey.ERRORS, - TabKey.BREADCRUMBS, - TabKey.NETWORK, - TabKey.CONSOLE, - TabKey.TRACE, - TabKey.LOGS, - TabKey.AI, - TabKey.PLAYLIST, - ]; - if (isVideoReplay) { + const supportedVideoTabs = [ + TabKey.AI, + TabKey.BREADCRUMBS, + TabKey.CONSOLE, + TabKey.ERRORS, + TabKey.LOGS, + TabKey.NETWORK, + TabKey.PLAYLIST, + TabKey.TAGS, + TabKey.TRACE, + ]; return supportedVideoTabs.includes(tab as TabKey); } return Object.values(TabKey).includes(tab); } +const tabKeyParser = createParser({ + parse: value => parseAsStringLiteral(Object.values(TabKey)).parse(value.toLowerCase()), + serialize: value => value, +}); + export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boolean}) { const organization = useOrganization(); const {areAiFeaturesAllowed} = useOrganizationSeerSetup(); @@ -52,24 +56,23 @@ export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boo ? TabKey.AI : TabKey.BREADCRUMBS; - const {getParamValue, setParamValue} = useUrlParams('t_main', defaultTab); - - const paramValue = getParamValue()?.toLowerCase() ?? ''; + const [tabParam, setTabParam] = useQueryState( + 't_main', + tabKeyParser.withDefault(defaultTab).withOptions({clearOnDefault: false}) + ); return { getActiveTab: useCallback( - () => (isReplayTab({tab: paramValue, isVideoReplay}) ? paramValue : defaultTab), - [paramValue, defaultTab, isVideoReplay] + () => + tabParam && isReplayTab({tab: tabParam, isVideoReplay}) ? tabParam : defaultTab, + [tabParam, defaultTab, isVideoReplay] ), setActiveTab: useCallback( (value: string) => { - setParamValue( - isReplayTab({tab: value.toLowerCase(), isVideoReplay}) - ? value.toLowerCase() - : defaultTab - ); + const lower = value.toLowerCase() as TabKey; + setTabParam(isReplayTab({tab: lower, isVideoReplay}) ? lower : defaultTab); }, - [setParamValue, defaultTab, isVideoReplay] + [setTabParam, defaultTab, isVideoReplay] ), }; } diff --git a/static/app/utils/url/useUrlParams.spec.tsx b/static/app/utils/url/useUrlParams.spec.tsx deleted file mode 100644 index 8ed30b402fa5c7..00000000000000 --- a/static/app/utils/url/useUrlParams.spec.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as qs from 'query-string'; - -import {renderHook} from 'sentry-test/reactTestingLibrary'; -import {setWindowLocation} from 'sentry-test/utils'; - -import {browserHistory} from 'sentry/utils/browserHistory'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; - -describe('useUrlParams', () => { - beforeEach(() => { - setWindowLocation( - `http://localhost/?${qs.stringify({ - page: '3', - limit: '50', - array: ['first', 'second'], - })}` - ); - }); - - it('should read query values from the url', () => { - const {result} = renderHook(useUrlParams); - - expect(result.current.getParamValue('page')).toBe('3'); - expect(result.current.getParamValue('limit')).toBe('50'); - expect(result.current.getParamValue('array')).toBe('first'); - expect(result.current.getParamValue('foo')).toBeUndefined(); - }); - - it('should read a specific query value if the defaultKey is passed along', () => { - const {result} = renderHook((args: [string]) => useUrlParams(args[0]), { - initialProps: ['page'], - }); - - expect(result.current.getParamValue()).toBe('3'); - }); - - it('should read the default value for the defaultKey', () => { - const {result} = renderHook( - (args: [string, string]) => useUrlParams(args[0], args[1]), - { - initialProps: ['foo', 'bar'], - } - ); // Prefer TS function overloading, not initialProps - - expect(result.current.getParamValue()).toBe('bar'); - }); - - it('should update browser history with new values', () => { - const {result} = renderHook(useUrlParams); - - result.current.setParamValue('page', '4'); - - expect(browserHistory.push).toHaveBeenCalledWith({ - pathname: '/', - query: { - array: ['first', 'second'], - page: '4', - limit: '50', - }, - }); - }); - - it('should update browser history with new values for the defaultKey', () => { - const {result} = renderHook((args: [string]) => useUrlParams(args[0]), { - initialProps: ['page'], - }); - - result.current.setParamValue('4'); - - expect(browserHistory.push).toHaveBeenCalledWith({ - pathname: '/', - query: { - array: ['first', 'second'], - page: '4', - limit: '50', - }, - }); - }); - - it('uses the same function reference after each render', () => { - const {result, rerender} = renderHook(useUrlParams); - - const firstResult = result.current; - rerender(); - const secondResult = result.current; - - expect(firstResult.getParamValue).toBe(secondResult.getParamValue); - expect(firstResult.setParamValue).toBe(secondResult.setParamValue); - }); -}); diff --git a/static/app/utils/url/useUrlParams.tsx b/static/app/utils/url/useUrlParams.tsx deleted file mode 100644 index e5e5711d82a03c..00000000000000 --- a/static/app/utils/url/useUrlParams.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {useCallback} from 'react'; -import * as qs from 'query-string'; - -import {browserHistory} from 'sentry/utils/browserHistory'; - -// TODO(epurkhiser): Once we're on react-router 6 we should replace this with -// their useSearchParams hook - -export function useUrlParams( - defaultKey: string, - defaultValue: string -): { - getParamValue: () => string; - setParamValue: (value: string) => void; -}; -export function useUrlParams(defaultKey: string): { - getParamValue: () => string | undefined; - setParamValue: (value: string) => void; -}; -export function useUrlParams(): { - getParamValue: (key: string) => string | undefined; - setParamValue: (key: string, value: string) => void; -}; -export function useUrlParams(defaultKey?: string, defaultValue?: string) { - const getParamValue = useCallback( - (key: string) => { - const currentQuery = qs.parse(window.location.search); - - // location.query.key can return string[] but we expect a singular value - // from this function, so we return the first string (this is picked - // arbitrarily) if it's string[] - return Array.isArray(currentQuery[key]) - ? (currentQuery[key]?.at(0) ?? defaultValue) - : (currentQuery[key] ?? defaultValue); - }, - [defaultValue] - ); - - const setParamValue = useCallback((key: string, value: string) => { - const currentQuery = qs.parse(window.location.search); - const query = {...currentQuery, [key]: value}; - browserHistory.push({pathname: location.pathname, query}); - }, []); - - const getWithDefault = useCallback( - () => getParamValue(defaultKey || ''), - [getParamValue, defaultKey] - ); - const setWithDefault = useCallback( - (value: string) => setParamValue(defaultKey || '', value), - [setParamValue, defaultKey] - ); - - if (defaultKey !== undefined) { - return { - getParamValue: getWithDefault, - setParamValue: setWithDefault, - }; - } - - return { - getParamValue, - setParamValue, - }; -} diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx index dcbe784fbbc700..b547eb386211e5 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx @@ -1,10 +1,10 @@ +import {parseAsString, useQueryState} from 'nuqs'; + import {defined} from 'sentry/utils'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {DashboardWidgetSource} from 'sentry/views/dashboards/types'; export function useDashboardWidgetSource(): DashboardWidgetSource | '' { - const {getParamValue} = useUrlParams('source'); - const source = getParamValue(); + const [source] = useQueryState('source', parseAsString); const validSources = Object.values( DashboardWidgetSource diff --git a/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx b/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx index bcfa670a4f22e7..38880fada23917 100644 --- a/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx +++ b/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx @@ -1,16 +1,13 @@ -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; + import {DrawerTab} from 'sentry/views/issueDetails/groupDistributions/types'; +const tabParser = parseAsStringLiteral(Object.values(DrawerTab)).withDefault( + DrawerTab.TAGS +); + export function useDrawerTab({enabled}: {enabled: boolean}) { - const {getParamValue: getTabParam, setParamValue: setTabParam} = useUrlParams( - 'tab', - DrawerTab.TAGS - ); + const [tab, setTab] = useQueryState('tab', tabParser); - return enabled - ? { - tab: getTabParam() as DrawerTab, - setTab: setTabParam, - } - : {tab: DrawerTab.TAGS, setTab: (_tab: string) => {}}; + return enabled ? {tab, setTab} : {tab: DrawerTab.TAGS, setTab: (_tab: DrawerTab) => {}}; } diff --git a/static/app/views/replays/detail/network/details/index.tsx b/static/app/views/replays/detail/network/details/index.tsx index a420135a68ec33..7fd6fe9db04a45 100644 --- a/static/app/views/replays/detail/network/details/index.tsx +++ b/static/app/views/replays/detail/network/details/index.tsx @@ -1,12 +1,14 @@ import {Fragment} from 'react'; +import {useQueryState} from 'nuqs'; import {DetailsSplitDivider} from 'sentry/components/replays/virtualizedGrid/detailsSplitDivider'; import type {SpanFrame} from 'sentry/utils/replays/types'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import type {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; import {NetworkDetailsContent} from 'sentry/views/replays/detail/network/details/content'; -import type {TabKey} from 'sentry/views/replays/detail/network/details/tabs'; -import {StyledNetworkDetailsTabs as NetworkDetailsTabs} from 'sentry/views/replays/detail/network/details/tabs'; +import { + networkDetailsTabParser, + StyledNetworkDetailsTabs as NetworkDetailsTabs, +} from 'sentry/views/replays/detail/network/details/tabs'; type Props = { isCaptureBodySetup: boolean; @@ -28,14 +30,12 @@ export function NetworkDetails({ projectId, startTimestampMs, }: Props) { - const {getParamValue: getDetailTab} = useUrlParams('n_detail_tab', 'details'); + const [visibleTab] = useQueryState('n_detail_tab', networkDetailsTabParser); if (!item || !projectId) { return null; } - const visibleTab = getDetailTab() as TabKey; - return ( - { - setParamValue(tab); - }} - > + setActiveTab(tab)}> {Object.entries(TABS).map(([tab, label]) => ( {label} diff --git a/static/app/views/replays/detail/network/index.tsx b/static/app/views/replays/detail/network/index.tsx index e3971f938cb8fb..bb1cfde508dea9 100644 --- a/static/app/views/replays/detail/network/index.tsx +++ b/static/app/views/replays/detail/network/index.tsx @@ -150,8 +150,7 @@ export function NetworkList() { }, [isNetworkDetailsSetup, organization]), }); - const selectedItem = - selectedIndex === '' ? null : (items[Number(selectedIndex)] ?? null); + const selectedItem = selectedIndex === null ? null : (items[selectedIndex] ?? null); return ( diff --git a/static/app/views/replays/detail/network/networkTableCell.tsx b/static/app/views/replays/detail/network/networkTableCell.tsx index 5155e0822a6370..00dc835a34e564 100644 --- a/static/app/views/replays/detail/network/networkTableCell.tsx +++ b/static/app/views/replays/detail/network/networkTableCell.tsx @@ -1,4 +1,5 @@ import type {ComponentProps, CSSProperties} from 'react'; +import {parseAsInteger, useQueryState} from 'nuqs'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -16,7 +17,6 @@ import { getResponseBodySize, } from 'sentry/utils/replays/resourceFrame'; import type {SpanFrame} from 'sentry/utils/replays/types'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {TimestampButton} from 'sentry/views/replays/detail/timestampButton'; import {operationName} from 'sentry/views/replays/detail/utils'; @@ -47,8 +47,8 @@ export function NetworkTableCell({ // Rows include the sortable header, the dataIndex does not const dataIndex = rowIndex - 1; - const {getParamValue} = useUrlParams('n_detail_row', ''); - const isSelected = getParamValue() === String(dataIndex); + const [detailRow] = useQueryState('n_detail_row', parseAsInteger); + const isSelected = detailRow === dataIndex; const method = getFrameMethod(frame); const statusCode = getFrameStatus(frame); diff --git a/static/app/views/settings/project/projectReplays.tsx b/static/app/views/settings/project/projectReplays.tsx index eb5bc4bb7baa0d..5c34ca6cd1fcd4 100644 --- a/static/app/views/settings/project/projectReplays.tsx +++ b/static/app/views/settings/project/projectReplays.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {z} from 'zod'; import {LinkButton} from '@sentry/scraps/button'; @@ -14,7 +15,6 @@ import {t, tct} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Project} from 'sentry/types/project'; import {fetchMutation} from 'sentry/utils/queryClient'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; import {ProjectPermissionAlert} from 'sentry/views/settings/project/projectPermissionAlert'; @@ -52,9 +52,11 @@ export default function ProjectReplaySettings() { onSuccess: (response: Project) => ProjectsStore.onUpdateSuccess(response), }; - const {getParamValue, setParamValue} = useUrlParams( + const [tab, setTab] = useQueryState( 'replaySettingsTab', - 'replay-issues' + parseAsStringLiteral(['replay-issues', 'bulk-delete'] as const).withDefault( + 'replay-issues' + ) ); return ( @@ -72,8 +74,8 @@ export default function ProjectReplaySettings() { } /> setParamValue(String(value))} + value={tab} + onChange={value => setTab(value as 'replay-issues' | 'bulk-delete')} > {t('Replay Issues')} From 66f69d2d066d7ac57a23ca383efc41b63b388f84 Mon Sep 17 00:00:00 2001 From: joshuarli Date: Mon, 6 Apr 2026 13:58:29 -0700 Subject: [PATCH 27/37] ci(st): cutover to new selective testing infra (#112263) --- .github/workflows/backend-selective.yml | 277 ------------------ .github/workflows/backend.yml | 123 +++----- .../getsentry-dispatch-selective.yml | 100 ------- .github/workflows/getsentry-dispatch.yml | 13 + .../fixtures/pr-files-with-rename.json | 47 +++ .github/workflows/scripts/parse-pr-files.py | 50 ++++ .../compute-selected-tests.py | 213 -------------- .../confirm-test-selection.py | 51 ---- .../selective-testing/fetch-coverage.py | 125 -------- .../workflows/scripts/test_parse_pr_files.py | 80 +++++ Makefile | 30 -- tests/tools/test_compute_selected_tests.py | 144 --------- 12 files changed, 233 insertions(+), 1020 deletions(-) delete mode 100644 .github/workflows/backend-selective.yml delete mode 100644 .github/workflows/getsentry-dispatch-selective.yml create mode 100644 .github/workflows/scripts/fixtures/pr-files-with-rename.json create mode 100644 .github/workflows/scripts/parse-pr-files.py delete mode 100644 .github/workflows/scripts/selective-testing/compute-selected-tests.py delete mode 100644 .github/workflows/scripts/selective-testing/confirm-test-selection.py delete mode 100644 .github/workflows/scripts/selective-testing/fetch-coverage.py create mode 100644 .github/workflows/scripts/test_parse_pr_files.py delete mode 100644 tests/tools/test_compute_selected_tests.py diff --git a/.github/workflows/backend-selective.yml b/.github/workflows/backend-selective.yml deleted file mode 100644 index 88a1ae68ed8020..00000000000000 --- a/.github/workflows/backend-selective.yml +++ /dev/null @@ -1,277 +0,0 @@ -# Parallel validation workflow: runs sentry backend tests using getsentry's -# cross-repo coverage DB for selective test selection. Runs alongside the -# existing backend.yml — not required for merge. -# -# Once validated, this will replace prepare-selective-tests in backend.yml. -name: backend (NOT REQUIRED - selective via getsentry) - -on: - pull_request: - types: [opened, synchronize, reopened, labeled] - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -defaults: - run: - shell: bash -euo pipefail {0} - -# hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359 -env: - SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3 - SNUBA_NO_WORKERS: 1 - SENTRY_SKIP_SELENIUM_PLUGIN: '1' - -permissions: - contents: read - id-token: write - actions: read - -jobs: - files-changed: - name: detect what files changed - runs-on: ubuntu-24.04 - timeout-minutes: 3 - outputs: - backend: ${{ steps.changes.outputs.backend_all_without_acceptance }} - skip_selective_testing: "${{ contains(github.event.pull_request.labels.*.name, 'Trigger: Override Selective Testing') }}" - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Check for backend file changes - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 - id: changes - with: - token: ${{ github.token }} - filters: .github/file-filters.yml - - select-tests: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' && - github.event.pull_request.head.repo.full_name == github.repository - needs: files-changed - name: select tests via getsentry coverage - runs-on: ubuntu-24.04 - timeout-minutes: 10 - outputs: - has-selected-tests: ${{ steps.compute-tests.outputs.has-selected-tests }} - test-count: ${{ steps.compute-tests.outputs.test-count }} - steps: - - name: Get changed files - id: changed - env: - GH_TOKEN: ${{ github.token }} - run: | - - CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --paginate --jq '.[].filename' | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version-file: '.python-version' - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.3 - with: - project_id: sentry-dev-tooling - workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }} - service_account: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }} - - - name: Download coverage database - id: download-coverage - run: | - - mkdir -p .artifacts/coverage - - GCS_PATH="gs://getsentry-coverage-data/latest/.coverage.combined" - echo "Fetching coverage DB from: $GCS_PATH" - gcloud storage ls -l "$GCS_PATH" 2>/dev/null || true - - if ! gcloud storage cp "$GCS_PATH" \ - .artifacts/coverage/.coverage.combined 2>/dev/null; then - echo "Warning: Failed to download coverage from GCS, will run full test suite" - echo "coverage-file=" >> "$GITHUB_OUTPUT" - else - ls -lh .artifacts/coverage/.coverage.combined - echo "coverage-file=.artifacts/coverage/.coverage.combined" >> "$GITHUB_OUTPUT" - fi - - - name: Compute selected tests - id: compute-tests - if: steps.download-coverage.outputs.coverage-file != '' - env: - COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }} - CHANGED_FILES: ${{ steps.changed.outputs.files }} - run: | - - - python3 .github/workflows/scripts/compute-sentry-selected-tests.py \ - --coverage-db "$COVERAGE_DB" \ - --changed-files "$CHANGED_FILES" \ - --output .artifacts/selected-tests.txt \ - --github-output - - - name: Upload selected tests artifact - if: steps.compute-tests.outputs.has-selected-tests == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/selected-tests.txt - retention-days: 1 - - calculate-shards: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' - needs: [files-changed, select-tests] - name: calculate test shards (selective) - runs-on: ubuntu-24.04 - timeout-minutes: 5 - outputs: - shard-count: ${{ steps.calculate-shards.outputs.shard-count }} - shard-indices: ${{ steps.calculate-shards.outputs.shard-indices }} - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup sentry env - uses: ./.github/actions/setup-sentry - id: setup - with: - mode: backend-ci - skip-devservices: true - - - name: Download selected tests artifact - if: needs.select-tests.outputs.has-selected-tests == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/ - - - name: Calculate test shards - id: calculate-shards - env: - SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} - SELECTED_TEST_COUNT: ${{ needs.select-tests.outputs.test-count || '' }} - run: | - python3 .github/workflows/scripts/calculate-backend-test-shards.py - - backend-test: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' && - needs.calculate-shards.outputs.shard-count != '0' - needs: [files-changed, select-tests, calculate-shards] - name: backend test (selective) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - instance: ${{ fromJSON(needs.calculate-shards.outputs.shard-indices) }} - env: - MATRIX_INSTANCE_TOTAL: ${{ needs.calculate-shards.outputs.shard-count }} - TEST_GROUP_STRATEGY: roundrobin - PYTHONHASHSEED: '0' - XDIST_PER_WORKER_SNUBA: '1' - XDIST_WORKERS: '2' - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup sentry env - uses: ./.github/actions/setup-sentry - id: setup - with: - mode: backend-ci - - - name: Download odiff binary - run: | - curl -sL https://registry.npmjs.org/odiff-bin/-/odiff-bin-4.3.2.tgz \ - | tar -xz --strip-components=2 package/raw_binaries/odiff-linux-x64 - sudo install -m 755 odiff-linux-x64 /usr/local/bin/odiff - rm odiff-linux-x64 - - - name: Bootstrap per-worker Snuba instances - run: | - set -eo pipefail - SNUBA_IMAGE=$(docker inspect snuba-snuba-1 --format '{{.Config.Image}}') - SNUBA_NETWORK=$(docker inspect snuba-snuba-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}') - if [ -z "$SNUBA_IMAGE" ] || [ -z "$SNUBA_NETWORK" ]; then - echo "ERROR: Could not inspect snuba-snuba-1 container. Is devservices running?" - exit 1 - fi - - docker stop snuba-snuba-1 || true - - PIDS=() - for i in $(seq 0 $(( ${XDIST_WORKERS} - 1 ))); do - ( - WORKER_DB="default_gw${i}" - WORKER_PORT=$((1230 + i)) - curl -sf 'http://localhost:8123/' --data-binary "CREATE DATABASE IF NOT EXISTS ${WORKER_DB}" - docker run --rm --network "$SNUBA_NETWORK" \ - -e "CLICKHOUSE_DATABASE=${WORKER_DB}" -e "CLICKHOUSE_HOST=clickhouse" \ - -e "CLICKHOUSE_PORT=9000" -e "CLICKHOUSE_HTTP_PORT=8123" \ - -e "DEFAULT_BROKERS=kafka:9093" -e "REDIS_HOST=redis" \ - -e "REDIS_PORT=6379" -e "REDIS_DB=1" -e "SNUBA_SETTINGS=docker" \ - "$SNUBA_IMAGE" bootstrap --force 2>&1 | tail -3 - docker run -d --name "snuba-gw${i}" --network "$SNUBA_NETWORK" \ - -p "${WORKER_PORT}:1218" \ - -e "CLICKHOUSE_DATABASE=${WORKER_DB}" -e "CLICKHOUSE_HOST=clickhouse" \ - -e "CLICKHOUSE_PORT=9000" -e "CLICKHOUSE_HTTP_PORT=8123" \ - -e "DEFAULT_BROKERS=kafka:9093" -e "REDIS_HOST=redis" \ - -e "REDIS_PORT=6379" -e "REDIS_DB=1" -e "SNUBA_SETTINGS=docker" \ - -e "DEBUG=1" "$SNUBA_IMAGE" api - - for attempt in $(seq 1 30); do - if curl -sf "http://127.0.0.1:${WORKER_PORT}/health" > /dev/null 2>&1; then - echo "snuba-gw${i} healthy on port ${WORKER_PORT}" - break - fi - if [ "$attempt" -eq 30 ]; then - echo "ERROR: snuba-gw${i} failed health check after 30 attempts" - docker logs "snuba-gw${i}" 2>&1 | tail -20 || true - exit 1 - fi - sleep 2 - done - ) & - PIDS+=($!) - done - - for pid in "${PIDS[@]}"; do - wait "$pid" || { echo "ERROR: Snuba bootstrap subshell (PID $pid) failed"; exit 1; } - done - - - name: Download selected tests artifact - if: needs.select-tests.outputs.has-selected-tests == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/ - - - name: Run backend test (${{ steps.setup.outputs.matrix-instance-number }} of ${{ steps.setup.outputs.matrix-instance-total }}) - env: - SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} - run: | - export PYTEST_ADDOPTS="$PYTEST_ADDOPTS -n ${XDIST_WORKERS} --dist=loadfile" - make test-python-ci - - - name: Inspect failure - if: failure() - run: | - if command -v devservices; then - devservices logs - fi - - for i in $(seq 0 $(( ${XDIST_WORKERS} - 1 ))); do - echo "--- snuba-gw${i} logs ---" - docker logs "snuba-gw${i}" 2>&1 | tail -30 || true - done diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index a37fd5da08c65f..79dec6b951e9b5 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -78,13 +78,14 @@ jobs: # Selective testing - only on PRs, determine which tests to run based on coverage data. # This job is skipped on push-to-master where the full suite runs instead. - prepare-selective-tests: + select-tests: if: >- needs.files-changed.outputs.backend == 'true' && needs.files-changed.outputs.skip_selective_testing != 'true' && - github.event_name == 'pull_request' + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository needs: files-changed - name: prepare selective tests + name: select tests runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -95,97 +96,59 @@ jobs: test-count: ${{ steps.compute-tests.outputs.test-count }} steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - fetch-depth: 0 # Need full history for git diff + + - name: Get changed files + id: changed + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \ + --paginate | python3 .github/workflows/scripts/parse-pr-files.py >> "$GITHUB_OUTPUT" - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: '3.13.1' + python-version-file: '.python-version' - name: Authenticate to Google Cloud - id: gcloud-auth uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.3 with: project_id: sentry-dev-tooling workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }} service_account: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }} - - name: Find coverage data for selective testing - id: find-coverage - env: - GCS_BUCKET: sentry-coverage-data - run: | - set -euo pipefail - - # Get the base commit (what the PR branches from) - BASE_SHA="${{ github.event.pull_request.base.sha }}" - - echo "Looking for coverage data starting from base commit: $BASE_SHA" - - COVERAGE_SHA="" - for sha in $(git rev-list "$BASE_SHA" --max-count=30); do - # Check if coverage exists in GCS for this commit - if gcloud storage ls "gs://${GCS_BUCKET}/${sha}/" &>/dev/null; then - COVERAGE_SHA="$sha" - echo "Found coverage data at commit: $sha" - break - fi - echo "No coverage at $sha, checking parent..." - done - - if [[ -z "$COVERAGE_SHA" ]]; then - echo "No coverage found in last 30 commits, will run full test suite" - echo "found=false" >> "$GITHUB_OUTPUT" - else - echo "found=true" >> "$GITHUB_OUTPUT" - echo "coverage-sha=$COVERAGE_SHA" >> "$GITHUB_OUTPUT" - fi - - name: Download coverage database id: download-coverage - if: steps.find-coverage.outputs.found == 'true' - env: - COVERAGE_SHA: ${{ steps.find-coverage.outputs.coverage-sha }} run: | - set -euxo pipefail - mkdir -p .coverage + mkdir -p .artifacts/coverage - if ! gcloud storage cp "gs://sentry-coverage-data/${COVERAGE_SHA}/.coverage.combined" .coverage/; then - echo "Warning: Failed to download coverage file" - echo "coverage-file=" >> "$GITHUB_OUTPUT" - exit 0 - fi + GCS_PATH="gs://getsentry-coverage-data/latest/.coverage.combined" + echo "Fetching coverage DB from: $GCS_PATH" + gcloud storage ls -l "$GCS_PATH" 2>/dev/null || true - if [[ ! -f .coverage/.coverage.combined ]]; then - echo "Warning: Coverage file not found after download" - ls -la .coverage/ || true + if ! gcloud storage cp "$GCS_PATH" \ + .artifacts/coverage/.coverage.combined 2>/dev/null; then + echo "Warning: Failed to download coverage from GCS, will run full test suite" echo "coverage-file=" >> "$GITHUB_OUTPUT" else - echo "Downloaded coverage file: .coverage/.coverage.combined" - echo "coverage-file=.coverage/.coverage.combined" >> "$GITHUB_OUTPUT" + ls -lh .artifacts/coverage/.coverage.combined + echo "coverage-file=.artifacts/coverage/.coverage.combined" >> "$GITHUB_OUTPUT" fi - - name: Get changed files - id: changed-files - run: | - # Get files changed between base and head of PR - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - # Use triple-dot syntax to find the merge-base first, so we only get - # changes introduced in this PR, not changes merged to master since branching - CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - name: Compute selected tests id: compute-tests if: steps.download-coverage.outputs.coverage-file != '' env: COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }} - CHANGED_FILES: ${{ steps.changed-files.outputs.files }} - run: make compute-selected-tests + CHANGED_FILES: ${{ steps.changed.outputs.files }} + PREVIOUS_FILENAMES: ${{ steps.changed.outputs.previous-filenames }} + run: | + python3 .github/workflows/scripts/compute-sentry-selected-tests.py \ + --coverage-db "$COVERAGE_DB" \ + --changed-files "$CHANGED_FILES" \ + --previous-filenames "$PREVIOUS_FILENAMES" \ + --output .artifacts/selected-tests.txt \ + --github-output - name: Upload selected tests artifact if: steps.compute-tests.outputs.has-selected-tests == 'true' @@ -196,12 +159,12 @@ jobs: retention-days: 1 calculate-shards: - # Use always() so this job runs even when prepare-selective-tests is skipped (master) + # Use always() so this job runs even when select-tests is skipped (master) if: >- always() && !cancelled() && needs.files-changed.outputs.backend == 'true' - needs: [files-changed, prepare-selective-tests] + needs: [files-changed, select-tests] name: calculate test shards runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -212,17 +175,17 @@ jobs: steps: - name: Use default shards (no selective testing) id: static-shards - if: needs.prepare-selective-tests.outputs.has-selected-tests != 'true' + if: needs.select-tests.outputs.has-selected-tests != 'true' # Keep in sync with MAX_SHARDS in .github/workflows/scripts/calculate-backend-test-shards.py run: | echo "shard-count=22" >> "$GITHUB_OUTPUT" echo "shard-indices=[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]" >> "$GITHUB_OUTPUT" - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' - name: Setup sentry env - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: ./.github/actions/setup-sentry id: setup with: @@ -230,7 +193,7 @@ jobs: skip-devservices: true - name: Download selected tests artifact - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: selected-tests-${{ github.run_id }} @@ -238,20 +201,20 @@ jobs: - name: Calculate test shards id: calculate-shards - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' env: SELECTED_TESTS_FILE: '.artifacts/selected-tests.txt' - SELECTED_TEST_COUNT: ${{ needs.prepare-selective-tests.outputs.test-count || '' }} + SELECTED_TEST_COUNT: ${{ needs.select-tests.outputs.test-count || '' }} run: | python3 .github/workflows/scripts/calculate-backend-test-shards.py backend-test: - # Use always() so this job runs even when prepare-selective-tests is skipped (master) + # Use always() so this job runs even when select-tests is skipped (master) if: >- always() && !cancelled() && needs.files-changed.outputs.backend == 'true' && needs.calculate-shards.outputs.shard-count != '0' - needs: [files-changed, prepare-selective-tests, calculate-shards] + needs: [files-changed, select-tests, calculate-shards] name: backend test runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -346,7 +309,7 @@ jobs: done - name: Download selected tests artifact - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: selected-tests-${{ github.run_id }} @@ -354,7 +317,7 @@ jobs: - name: Run backend test (${{ steps.setup.outputs.matrix-instance-number }} of ${{ steps.setup.outputs.matrix-instance-total }}) env: - SELECTED_TESTS_FILE: ${{ needs.prepare-selective-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} + SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} run: | if [ -n "${XDIST_WORKERS}" ]; then export PYTEST_ADDOPTS="$PYTEST_ADDOPTS -n ${XDIST_WORKERS} --dist=loadfile" diff --git a/.github/workflows/getsentry-dispatch-selective.yml b/.github/workflows/getsentry-dispatch-selective.yml deleted file mode 100644 index 577c7879c57665..00000000000000 --- a/.github/workflows/getsentry-dispatch-selective.yml +++ /dev/null @@ -1,100 +0,0 @@ -# Parallel dispatch that passes changed files for selective testing. -# Runs alongside getsentry-dispatch.yml during rollout to validate that -# selective testing produces correct results without affecting the existing -# dispatch. Remove this once selective testing is validated and the changes -# are folded into getsentry-dispatch.yml. -name: getsentry dispatcher (selective testing) - -on: - # XXX: We are using `pull_request_target` instead of `pull_request` because we want - # this to run on forks. It allows forks to access secrets safely by - # only running workflows from the main branch. Prefer to use `pull_request` when possible. - # - # See https://github.com/getsentry/sentry/pull/21600 for more details - pull_request_target: - types: [labeled, opened, reopened, synchronize] - -# disable all other special privileges -permissions: - # needed for `actions/checkout` to clone the code - contents: read - # needed to remove the pull-request label - pull-requests: write - -jobs: - dispatch: - if: "github.event.action != 'labeled' || github.event.label.name == 'Trigger: getsentry tests'" - name: getsentry dispatch (selective) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - persist-credentials: false - - - name: permissions - run: | - python3 -uS .github/workflows/scripts/getsentry-dispatch-setup \ - --repo-id ${{ github.event.repository.id }} \ - --pr ${{ github.event.number }} \ - --event ${{ github.event.action }} \ - --username "$ARG_USERNAME" \ - --label-names "$ARG_LABEL_NAMES" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # these can contain special characters - ARG_USERNAME: ${{ github.event.pull_request.user.login }} - ARG_LABEL_NAMES: ${{ toJSON(github.event.pull_request.labels.*.name) }} - - - name: Check for file changes - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 - id: changes - with: - token: ${{ github.token }} - filters: .github/file-filters.yml - - - name: Get changed files for selective testing - id: changed-files - env: - GH_TOKEN: ${{ github.token }} - run: | - CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.number }}/files --paginate --jq '.[].filename' | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "sentry-changed-files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - - name: getsentry token - uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc # v3.0.0 - id: getsentry - with: - app_id: ${{ vars.SENTRY_INTERNAL_APP_ID }} - private_key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} - - - name: Wait for PR merge commit - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - id: mergecommit - with: - github-token: ${{ steps.getsentry.outputs.token }} - script: | - const { waitForMergeCommit } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/wait-for-merge-commit.js`); - await waitForMergeCommit({ - github, - context, - core, - }); - - - name: Dispatch getsentry tests - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - SENTRY_CHANGED_FILES: ${{ steps.changed-files.outputs.sentry-changed-files }} - with: - github-token: ${{ steps.getsentry.outputs.token }} - script: | - const { dispatch } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/getsentry-dispatch.js`); - await dispatch({ - github, - context, - core, - mergeCommitSha: '${{ steps.mergecommit.outputs.mergeCommitSha }}', - fileChanges: ${{ toJson(steps.changes.outputs) }}, - sentryChangedFiles: process.env.SENTRY_CHANGED_FILES, - targetWorkflow: 'backend-selective.yml', - }); diff --git a/.github/workflows/getsentry-dispatch.yml b/.github/workflows/getsentry-dispatch.yml index d2a75fe6d10442..8c5d6166286510 100644 --- a/.github/workflows/getsentry-dispatch.yml +++ b/.github/workflows/getsentry-dispatch.yml @@ -48,6 +48,14 @@ jobs: token: ${{ github.token }} filters: .github/file-filters.yml + - name: Get changed files for selective testing + id: changed-files + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api repos/${{ github.repository }}/pulls/${{ github.event.number }}/files \ + --paginate | python3 .github/workflows/scripts/parse-pr-files.py >> "$GITHUB_OUTPUT" + - name: getsentry token uses: getsentry/action-github-app-token@5c1e90706fe007857338ac1bfbd7a4177db2f789 # v4.0.0 id: getsentry @@ -70,6 +78,9 @@ jobs: - name: Dispatch getsentry tests uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SENTRY_CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + SENTRY_PREVIOUS_FILENAMES: ${{ steps.changed-files.outputs.previous-filenames }} with: github-token: ${{ steps.getsentry.outputs.token }} script: | @@ -80,4 +91,6 @@ jobs: core, mergeCommitSha: '${{ steps.mergecommit.outputs.mergeCommitSha }}', fileChanges: ${{ toJson(steps.changes.outputs) }}, + sentryChangedFiles: process.env.SENTRY_CHANGED_FILES, + sentryPreviousFilenames: process.env.SENTRY_PREVIOUS_FILENAMES, }); diff --git a/.github/workflows/scripts/fixtures/pr-files-with-rename.json b/.github/workflows/scripts/fixtures/pr-files-with-rename.json new file mode 100644 index 00000000000000..0c2284e4d95594 --- /dev/null +++ b/.github/workflows/scripts/fixtures/pr-files-with-rename.json @@ -0,0 +1,47 @@ +[ + { + "sha": "798034c0653b888a6d07869c47c7662e55a48ec1", + "filename": ".agents/skills/hybrid-cloud-outboxes/SKILL.md", + "status": "modified", + "additions": 1, + "deletions": 1, + "changes": 2, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md?ref=96b8ad19" + }, + { + "sha": "91c952f9e0174cc8f574b7f7e9b07353102c5682", + "filename": ".agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md", + "status": "modified", + "additions": 1, + "deletions": 1, + "changes": 2, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md?ref=96b8ad19" + }, + { + "sha": "9b306a8ce766ae0cae8975c9da9954b305e62498", + "filename": ".agents/skills/hybrid-cloud-rpc/SKILL.md", + "status": "modified", + "additions": 5, + "deletions": 5, + "changes": 10, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md?ref=96b8ad19" + }, + { + "sha": "442d665073e560105377fc137d7fc4a4868cb57e", + "filename": "tests/sentry/hybridcloud/test_cell.py", + "status": "renamed", + "additions": 28, + "deletions": 30, + "changes": 58, + "previous_filename": "tests/sentry/hybridcloud/test_region.py", + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py?ref=96b8ad19" + } +] diff --git a/.github/workflows/scripts/parse-pr-files.py b/.github/workflows/scripts/parse-pr-files.py new file mode 100644 index 00000000000000..a027c0c0312eb9 --- /dev/null +++ b/.github/workflows/scripts/parse-pr-files.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Parse the GitHub PR files API response into changed files and previous filenames. + +Reads JSON from stdin (the output of `gh api .../pulls/N/files --paginate`). +Note: --paginate can emit multiple JSON arrays (one per page), so we handle +concatenated arrays by decoding incrementally. + +Outputs two lines (suitable for appending to $GITHUB_OUTPUT): + files= + previous-filenames= + +Usage: + gh api repos/OWNER/REPO/pulls/N/files --paginate \ + | python3 parse-pr-files.py >> "$GITHUB_OUTPUT" +""" + +from __future__ import annotations + +import json +import sys + + +def main() -> None: + raw = sys.stdin.read() + decoder = json.JSONDecoder() + files = [] + idx = 0 + while idx < len(raw): + while idx < len(raw) and raw[idx].isspace(): + idx += 1 + if idx >= len(raw): + break + obj, end = decoder.raw_decode(raw, idx) + if isinstance(obj, list): + files.extend(obj) + idx = end + + changed = [f["filename"] for f in files] + previous = [ + f["previous_filename"] + for f in files + if f.get("status") == "renamed" and f.get("previous_filename") + ] + + print(f"files={' '.join(changed)}") + print(f"previous-filenames={' '.join(previous)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/selective-testing/compute-selected-tests.py b/.github/workflows/scripts/selective-testing/compute-selected-tests.py deleted file mode 100644 index fb452fd10c5bde..00000000000000 --- a/.github/workflows/scripts/selective-testing/compute-selected-tests.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import os -import re -import sqlite3 -import sys -from pathlib import Path - -# Files/patterns that, if matched by any changed file, should trigger the full test suite. -# Strings are matched as suffixes, re.Pattern entries are matched with .search(). -FULL_SUITE_TRIGGERS: list[str | re.Pattern[str]] = [ - "sentry/testutils/pytest/sentry.py", - "pyproject.toml", - "Makefile", - "sentry/conf/server.py", - "sentry/web/urls.py", - # Django migrations can affect schema and invalidate selective test coverage - re.compile(r"/migrations/\d{4}_[^/]+\.py$"), -] - -# These test files are excluded if they aren't explicitly modified. -EXCLUDED_TEST_FILES: set[str] = { - # this is selected very frequently since it covers the majority of - # app warmup, and is almost never actually relevant to changed files - "tests/sentry/test_wsgi.py", -} - -# Non-source files that don't appear in coverage data but have known test -# dependencies. When one of these files is changed, the mapped test files -# are added to the selected set so selective testing covers them without -# falling back to a full suite run. -EXTRA_FILE_TO_TEST_MAPPING: dict[str, list[str]] = { - ".github/CODEOWNERS": ["tests/sentry/api/test_api_owners.py"], -} - -# Tests that should always be run even if not explicitly selected. -ALWAYS_RUN_TESTS: set[str] = { - "tests/sentry/taskworker/test_config.py", -} - - -def _matches_trigger(file_path: str, trigger: str | re.Pattern[str]) -> bool: - if isinstance(trigger, re.Pattern): - return trigger.search(file_path) is not None - return file_path.endswith(trigger) - - -def should_run_full_suite(changed_files: list[str]) -> bool: - for file_path in changed_files: - if any(_matches_trigger(file_path, t) for t in FULL_SUITE_TRIGGERS): - return True - return False - - -# Test directories excluded from backend test runs (must match calculate-backend-test-shards.py) -EXCLUDED_TEST_PATTERNS: list[str | re.Pattern[str]] = [ - re.compile(r"^tests/(acceptance|apidocs|js|tools)/"), -] - - -def get_changed_test_files(changed_files: list[str]) -> set[str]: - test_files: set[str] = set() - for file_path in changed_files: - if file_path.startswith("tests/") and file_path.endswith(".py"): - if not any(_matches_trigger(file_path, p) for p in EXCLUDED_TEST_PATTERNS): - test_files.add(file_path) - return test_files - - -def get_affected_test_files(coverage_db_path: str, changed_files: list[str]) -> set[str]: - affected_test_files: set[str] = set() - - conn = sqlite3.connect(coverage_db_path) - cur = conn.cursor() - - # Verify required tables exist (need context tracking enabled) - tables = { - r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - } - if "line_bits" not in tables or "context" not in tables: - raise ValueError( - "Coverage database missing line_bits/context tables. " - "Coverage must be collected with --cov-context=test" - ) - - test_contexts: set[str] = set() - - for file_path in changed_files: - cur.execute( - """ - SELECT c.context, lb.numbits - FROM line_bits lb - JOIN file f ON lb.file_id = f.id - JOIN context c ON lb.context_id = c.id - WHERE f.path LIKE '%' || ? - AND c.context != '' - """, - (f"%{file_path}",), - ) - - for context, bitblob in cur.fetchall(): - if any(b != 0 for b in bytes(bitblob)): - test_contexts.add(context) - - conn.close() - - # Extract test file paths from contexts - # Context format: 'tests/foo/bar.py::TestClass::test_function|run' - for context in test_contexts: - test_file = context.split("::", 1)[0] - affected_test_files.add(test_file) - - return affected_test_files - - -def main() -> int: - parser = argparse.ArgumentParser(description="Compute selected tests from coverage data") - parser.add_argument("--coverage-db", required=True, help="Path to coverage SQLite database") - parser.add_argument( - "--changed-files", required=True, help="Space-separated list of changed files" - ) - parser.add_argument("--output", help="Output file path for selected test files (one per line)") - parser.add_argument("--github-output", action="store_true", help="Write to GITHUB_OUTPUT") - args = parser.parse_args() - - coverage_db = Path(args.coverage_db) - if not coverage_db.exists(): - print(f"Error: Coverage database not found: {coverage_db}", file=sys.stderr) - return 1 - - changed_files = [f.strip() for f in args.changed_files.split() if f.strip()] - if not changed_files: - print("No changed files provided, running full test suite") - affected_test_files: set[str] = set() - elif should_run_full_suite(changed_files): - triggered_by = [ - f for f in changed_files if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) - ] - print(f"Full test suite triggered by: {', '.join(triggered_by)}") - affected_test_files = set() - else: - print(f"Computing selected tests for {len(changed_files)} changed files...") - try: - affected_test_files = get_affected_test_files(str(coverage_db), changed_files) - except sqlite3.Error as e: - print(f"Error querying coverage database: {e}", file=sys.stderr) - return 1 - - affected_test_files -= EXCLUDED_TEST_FILES - - # Include tests for non-source files with known test dependencies - for file_path in changed_files: - mapped_tests = EXTRA_FILE_TO_TEST_MAPPING.get(file_path, []) - if mapped_tests: - print(f"Including {len(mapped_tests)} mapped test files for {file_path}") - affected_test_files.update(mapped_tests) - - # Also include test files that were directly modified or added in the PR. - # Note: we intentionally exclude deleted test files here — they can't be - # run, and their coverage is already captured by the lookup above (any - # OTHER test that covered the now-deleted source will be included via - # get_affected_test_files). Deleted test files that appear in the - # coverage results are removed by the filter below. - changed_test_files = get_changed_test_files(changed_files) - existing_changed_test_files = {f for f in changed_test_files if Path(f).exists()} - if existing_changed_test_files: - print(f"Including {len(existing_changed_test_files)} directly changed test files") - affected_test_files.update(existing_changed_test_files) - - # Include tests that should always be run - affected_test_files.update(ALWAYS_RUN_TESTS) - - # Filter out any test files found via coverage lookup that no longer exist - # (e.g. a deleted test file that covered the same source as another changed file). - existing_files = {f for f in affected_test_files if Path(f).exists()} - deleted_files = affected_test_files - existing_files - if deleted_files: - print( - f"Excluding {len(deleted_files)} deleted test file(s) found via coverage: " - + ", ".join(sorted(deleted_files)) - ) - affected_test_files = existing_files - - print(f"Found {len(affected_test_files)} affected test files") - - if args.output: - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("w") as f: - for test_file in sorted(affected_test_files): - f.write(f"{test_file}\n") - print(f"Wrote selected tests to {output_path}") - - if args.github_output: - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - with open(github_output, "a") as f: - f.write(f"test-count={len(affected_test_files)}\n") - f.write(f"has-selected-tests={'true' if affected_test_files else 'false'}\n") - print(f"Wrote to GITHUB_OUTPUT: test-count={len(affected_test_files)}") - - if affected_test_files: - print("\nAffected test files:") - for test_file in sorted(affected_test_files): - print(f" {test_file}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/selective-testing/confirm-test-selection.py b/.github/workflows/scripts/selective-testing/confirm-test-selection.py deleted file mode 100644 index 23bb9ce5019f82..00000000000000 --- a/.github/workflows/scripts/selective-testing/confirm-test-selection.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import sys -from pathlib import Path - -LARGE_SELECTION_THRESHOLD = 300 - - -def main() -> int: - if len(sys.argv) < 2: - print("Usage: confirm-test-selection.py ", file=sys.stderr) - return 1 - - selected_tests_path = Path(sys.argv[1]) - - if not selected_tests_path.exists(): - print(f"Selected tests file not found: {selected_tests_path}", file=sys.stderr) - return 1 - - selected_files = [ - line.strip() for line in selected_tests_path.read_text().splitlines() if line.strip() - ] - count = len(selected_files) - - if count == 0: - prompt = ( - "The full test suite will be run, usually due to a change in a file that triggers the full suite (see logs above).\n" - "Continue? [y/N] " - ) - elif count >= LARGE_SELECTION_THRESHOLD: - prompt = f"{count} test files selected, a large amount to run locally. Continue? [y/N] " - else: - print(f"{count} test files selected") - return 0 - - try: - response = input(prompt).strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nAborted.") - return 1 - - if response in ("y", "yes"): - return 0 - - print("Aborted.") - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/selective-testing/fetch-coverage.py b/.github/workflows/scripts/selective-testing/fetch-coverage.py deleted file mode 100644 index f3ecb1e60bedaa..00000000000000 --- a/.github/workflows/scripts/selective-testing/fetch-coverage.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -import urllib.request -from pathlib import Path - -GCS_BUCKET = "sentry-coverage-data" -GCS_BASE_URL = f"https://storage.googleapis.com/{GCS_BUCKET}" -COVERAGE_FILENAME = ".coverage.combined" -DEFAULT_MAX_COMMITS = 30 - - -def detect_base_ref() -> str: - result = subprocess.run( - ["git", "merge-base", "origin/master", "HEAD"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return result.stdout.strip() - - print("Error: Could not find merge-base with origin/master", file=sys.stderr) - print("Make sure you have fetched from the remote (git fetch origin)", file=sys.stderr) - sys.exit(1) - - -def get_commit_list(base_ref: str) -> list[str]: - result = subprocess.run( - ["git", "rev-list", base_ref, f"--max-count={DEFAULT_MAX_COMMITS}"], - capture_output=True, - text=True, - check=True, - ) - return [sha.strip() for sha in result.stdout.strip().splitlines() if sha.strip()] - - -def check_coverage_exists(sha: str) -> bool: - url = f"{GCS_BASE_URL}/{sha}/{COVERAGE_FILENAME}" - req = urllib.request.Request(url, method="HEAD") - try: - urllib.request.urlopen(req, timeout=5) - return True - except Exception as e: - print(f" Warning: Error checking {sha[:12]}: {e}", file=sys.stderr) - return False - - -def download_coverage(sha: str, output_path: Path) -> bool: - cache_dir = Path.home() / ".cache" / "sentry" / "coverage" - cache_dir.mkdir(parents=True, exist_ok=True) - cached_file = cache_dir / sha / COVERAGE_FILENAME - - if cached_file.exists(): - print(f"Using cached coverage data for {sha[:12]}") - output_path.parent.mkdir(parents=True, exist_ok=True) - # Copy from cache (symlink would break if cache is cleaned) - shutil.copy2(cached_file, output_path) - return True - - url = f"{GCS_BASE_URL}/{sha}/{COVERAGE_FILENAME}" - print(f"Downloading coverage data from {sha[:12]}...") - - try: - urllib.request.urlretrieve(url, str(output_path)) - except (urllib.error.HTTPError, urllib.error.URLError) as e: - print(f"Error downloading coverage data: {e}", file=sys.stderr) - return False - - # Cache the download - cached_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(output_path, cached_file) - print(f"Cached coverage data at {cached_file}") - - return True - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Fetch coverage data from GCS for selective testing" - ) - parser.add_argument( - "--base-ref", - help="Base git ref to walk history from (default: origin/master)", - ) - parser.add_argument( - "--output", - default=".cache/coverage.db", - help="Output path for the coverage database (default: .cache/coverage.db)", - ) - args = parser.parse_args() - - base_ref = args.base_ref or detect_base_ref() - output_path = Path(args.output) - - print(f"Looking for coverage data from {base_ref} (up to {DEFAULT_MAX_COMMITS} commits)") - - commits = get_commit_list(base_ref) - if not commits: - print("No commits found to check", file=sys.stderr) - return 1 - - for sha in commits: - print(f" Checking {sha[:12]}...", end=" ") - if check_coverage_exists(sha): - print("found!") - output_path.parent.mkdir(parents=True, exist_ok=True) - if download_coverage(sha, output_path): - print(f"Coverage database written to {output_path}") - return 0 - else: - return 1 - else: - print("no coverage") - - print(f"No coverage data found in last {DEFAULT_MAX_COMMITS} commits", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/test_parse_pr_files.py b/.github/workflows/scripts/test_parse_pr_files.py new file mode 100644 index 00000000000000..fdee382d68ec4a --- /dev/null +++ b/.github/workflows/scripts/test_parse_pr_files.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Tests for parse-pr-files.py.""" + +from __future__ import annotations + +import importlib.util +import io +import sys +from pathlib import Path +from unittest import mock + +_script_path = Path(__file__).parent / "parse-pr-files.py" +_spec = importlib.util.spec_from_file_location("parse_pr_files", _script_path) +_mod = importlib.util.module_from_spec(_spec) +sys.modules["parse_pr_files"] = _mod +_spec.loader.exec_module(_mod) + +from parse_pr_files import main + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def _run(json_input: str) -> dict[str, str]: + """Run main() with json_input on stdin, return output as key-value dict.""" + buf = io.StringIO() + with mock.patch("sys.stdin", io.StringIO(json_input)): + with mock.patch("sys.stdout", buf): + main() + return dict(line.split("=", 1) for line in buf.getvalue().strip().split("\n")) + + +class TestParsePrFiles: + def test_real_response_with_rename(self): + """Vendored fixture from getsentry/sentry#111009 (3 modified + 1 renamed).""" + fixture = (FIXTURES_DIR / "pr-files-with-rename.json").read_text() + out = _run(fixture) + + files = out["files"].split() + assert len(files) == 4 + assert "tests/sentry/hybridcloud/test_cell.py" in files + assert ".agents/skills/hybrid-cloud-rpc/SKILL.md" in files + + assert out["previous-filenames"] == "tests/sentry/hybridcloud/test_region.py" + + def test_no_renames(self): + out = _run('[{"filename": "src/foo.py", "status": "modified"}]') + assert out["files"] == "src/foo.py" + assert out["previous-filenames"] == "" + + def test_multiple_renames(self): + out = _run( + """[ + {"filename": "b.py", "status": "renamed", "previous_filename": "a.py"}, + {"filename": "d.py", "status": "renamed", "previous_filename": "c.py"}, + {"filename": "e.py", "status": "added"} + ]""" + ) + assert out["files"] == "b.py d.py e.py" + assert out["previous-filenames"] == "a.py c.py" + + def test_empty_list(self): + out = _run("[]") + assert out["files"] == "" + assert out["previous-filenames"] == "" + + def test_paginated_response(self): + """gh api --paginate emits concatenated JSON arrays, one per page.""" + page1 = '[{"filename": "a.py", "status": "modified"}]' + page2 = '[{"filename": "b.py", "status": "renamed", "previous_filename": "old_b.py"}]' + out = _run(page1 + page2) + assert out["files"] == "a.py b.py" + assert out["previous-filenames"] == "old_b.py" + + def test_paginated_response_with_whitespace(self): + """Pages may be separated by newlines.""" + page1 = '[{"filename": "a.py", "status": "modified"}]' + page2 = '[{"filename": "b.py", "status": "added"}]' + out = _run(page1 + "\n" + page2) + assert out["files"] == "a.py b.py" + assert out["previous-filenames"] == "" diff --git a/Makefile b/Makefile index 2a53e999fab8e9..3a3554bfc81023 100644 --- a/Makefile +++ b/Makefile @@ -153,36 +153,6 @@ test-backend-ci-with-coverage: -o junit_suite_name=pytest @echo "" -compute-selected-tests: - @echo "--> Computing selected tests from coverage data" - python3 .github/workflows/scripts/selective-testing/compute-selected-tests.py \ - --coverage-db "$(COVERAGE_DB)" \ - --changed-files "$(CHANGED_FILES)" \ - --output .artifacts/selected-tests.txt \ - --github-output - @echo "" - -test-selective: - @echo "--> Running selective tests based on branch changes" - python3 .github/workflows/scripts/selective-testing/fetch-coverage.py \ - --output .cache/coverage.db - python3 .github/workflows/scripts/selective-testing/compute-selected-tests.py \ - --coverage-db .cache/coverage.db \ - --changed-files "$$(git diff --name-only $$(git merge-base origin/master HEAD))" \ - --output .cache/selected-tests.txt - python3 .github/workflows/scripts/selective-testing/confirm-test-selection.py \ - .cache/selected-tests.txt - SELECTED_TESTS_FILE=.cache/selected-tests.txt \ - python3 -b -m pytest \ - tests \ - --reuse-db \ - --ignore tests/acceptance \ - --ignore tests/apidocs \ - --ignore tests/js \ - --ignore tests/tools \ - -svv - @echo "" - # it's not possible to change settings.DATABASE after django startup, so # unfortunately these tests must be run in a separate pytest process. References: # * https://docs.djangoproject.com/en/4.2/topics/testing/tools/#overriding-settings diff --git a/tests/tools/test_compute_selected_tests.py b/tests/tools/test_compute_selected_tests.py deleted file mode 100644 index 9910662c6118e4..00000000000000 --- a/tests/tools/test_compute_selected_tests.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import runpy -import sqlite3 -from pathlib import Path - -import pytest - -_mod = runpy.run_path( - str( - Path(__file__).resolve().parents[2] - / ".github/workflows/scripts/selective-testing/compute-selected-tests.py" - ) -) -should_run_full_suite = _mod["should_run_full_suite"] -get_changed_test_files = _mod["get_changed_test_files"] -get_affected_test_files = _mod["get_affected_test_files"] - - -def test_full_suite_triggered(): - trigger_files = [ - "sentry/testutils/pytest/sentry.py", - "src/sentry/testutils/pytest/sentry.py", - "pyproject.toml", - "Makefile", - "sentry/conf/server.py", - "src/sentry/conf/server.py", - "sentry/web/urls.py", - "src/sentry/migrations/0001_initial.py", - "src/sentry/replays/migrations/0042_add_index.py", - "src/sentry/issues/migrations/9999_something.py", - ] - for path in trigger_files: - assert should_run_full_suite([path]) is True, path - - -def test_full_suite_not_triggered(): - safe_files = [ - "src/sentry/migrations/initial.py", - "src/sentry/utils/migrations_helper.py", - "src/sentry/migrations/0001_initial.txt", - "src/sentry/models/group.py", - "src/sentry/api/endpoints/project.py", - ] - for path in safe_files: - assert should_run_full_suite([path]) is False, path - assert should_run_full_suite([]) is False - assert ( - should_run_full_suite(["src/sentry/api/foo.py", "src/sentry/migrations/0500_bar.py"]) - is True - ) - - -def test_get_changed_test_files(): - changed = [ - "tests/sentry/api/test_base.py", - "src/sentry/api/base.py", - "tests/sentry/models/test_group.py", - "tests/tools/test_compute_selected_tests.py", - "tests/acceptance/test_foo.py", - "README.md", - ] - assert get_changed_test_files(changed) == { - "tests/sentry/api/test_base.py", - "tests/sentry/models/test_group.py", - } - - -def test_get_changed_test_files_empty(): - assert get_changed_test_files([]) == set() - - -def test_get_affected_test_files(tmp_path): - db_path = tmp_path / ".coverage.combined" - _create_coverage_db( - db_path, - { - "src/sentry/models/group.py": [ - "tests/sentry/models/test_group.py::TestGroup::test_get|run", - "tests/sentry/api/test_issues.py::TestIssues::test_list|run", - ], - "src/sentry/models/project.py": [ - "tests/sentry/models/test_project.py::TestProject::test_create|run", - ], - }, - ) - - assert get_affected_test_files(str(db_path), ["src/sentry/models/group.py"]) == { - "tests/sentry/models/test_group.py", - "tests/sentry/api/test_issues.py", - } - - -def test_get_affected_test_files_no_match(tmp_path): - db_path = tmp_path / ".coverage.combined" - _create_coverage_db( - db_path, - { - "src/sentry/models/group.py": [ - "tests/sentry/models/test_group.py::TestGroup::test_get|run" - ] - }, - ) - - assert get_affected_test_files(str(db_path), ["src/sentry/unrelated.py"]) == set() - - -def test_get_affected_test_files_missing_tables(tmp_path): - db_path = tmp_path / ".coverage.combined" - conn = sqlite3.connect(str(db_path)) - conn.execute("CREATE TABLE file (id INTEGER PRIMARY KEY, path TEXT)") - conn.commit() - conn.close() - - with pytest.raises(ValueError, match="missing line_bits/context tables"): - get_affected_test_files(str(db_path), ["src/sentry/models/group.py"]) - - -def _create_coverage_db(db_path, file_contexts): - conn = sqlite3.connect(str(db_path)) - cur = conn.cursor() - cur.execute("CREATE TABLE file (id INTEGER PRIMARY KEY, path TEXT)") - cur.execute("CREATE TABLE context (id INTEGER PRIMARY KEY, context TEXT)") - cur.execute("CREATE TABLE line_bits (file_id INTEGER, context_id INTEGER, numbits BLOB)") - - file_id = 0 - ctx_id = 0 - seen_contexts: dict[str, int] = {} - - for file_path, contexts in file_contexts.items(): - file_id += 1 - cur.execute("INSERT INTO file VALUES (?, ?)", (file_id, file_path)) - for ctx in contexts: - if ctx not in seen_contexts: - ctx_id += 1 - seen_contexts[ctx] = ctx_id - cur.execute("INSERT INTO context VALUES (?, ?)", (ctx_id, ctx)) - cur.execute( - "INSERT INTO line_bits VALUES (?, ?, ?)", (file_id, seen_contexts[ctx], b"\x01") - ) - - cur.execute("INSERT INTO context VALUES (?, ?)", (ctx_id + 1, "")) - conn.commit() - conn.close() From 409b88c425aabae8243331f86013249781bac546 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Mon, 6 Apr 2026 13:59:34 -0700 Subject: [PATCH 28/37] ref(admin): Replace `useRouter` usage in `InstanceLevelOAuthDetails` (#112289) https://github.com/getsentry/frontend-tsc/issues/78 Replaces `useRouter` usage in `InstanceLevelOAuthDetails` with `useParams`. --- static/app/utils/useParams.tsx | 1 + .../instanceLevelOAuth/instanceLevelOAuthDetails.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index 39b13f28612177..af1d8b9fb5fb48 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -20,6 +20,7 @@ type ParamKeys = | 'baseArtifactId' | 'beaconId' | 'broadcastId' + | 'clientID' | 'codeId' | 'dashboardId' | 'dataExportId' diff --git a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx index 0aaa5dd308d4b5..814d0b2e787b4a 100644 --- a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx +++ b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx @@ -14,7 +14,7 @@ import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; import type {RequestError} from 'sentry/utils/requestError/requestError'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useApi} from 'sentry/utils/useApi'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useParams} from 'sentry/utils/useParams'; import {PageHeader} from 'admin/components/pageHeader'; @@ -40,7 +40,7 @@ const fieldProps = { export function InstanceLevelOAuthDetails() { const api = useApi(); - const router = useRouter(); + const params = useParams<{clientID: string}>(); const [clientDetails, setClientDetails] = useState(); const [errorMessage, setErrorMessage] = useState(); @@ -49,7 +49,7 @@ export function InstanceLevelOAuthDetails() { const fetchClientData = useCallback(async () => { try { const response = await api.requestPromise( - `/_admin/instance-level-oauth/${router.params.clientID}/`, + `/_admin/instance-level-oauth/${params.clientID}/`, {} ); @@ -72,7 +72,7 @@ export function InstanceLevelOAuthDetails() { } finally { setLoading(false); } - }, [router.params.clientID, api]); + }, [params.clientID, api]); useEffect(() => { fetchClientData(); From 8ec9413223846312cd59c6815812cd41503d03b2 Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Mon, 6 Apr 2026 14:01:02 -0700 Subject: [PATCH 29/37] ref(grouping): Add false positive parameterization metric (#112275) In the metrics we use to track message parameterization, we tag the overall timing metric with a `false_positive` tag when we hit the fallback case (to see both how often it happens and how much time it adds to the process), but we don't track the individual times we hit a false positive value (which could be more than once per message). This adds a metric to do that, so that we can compare false positive hits to true positive hits (tracked by the existing `grouping.value_parameterized` metric). If the rate is higher than we'd like, we can consider tightening the "IP-like" regex we use for our initial match. --- src/sentry/grouping/parameterization.py | 16 ++++++++++++++++ tests/sentry/grouping/test_parameterization.py | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index dc049f65d18457..a1be4c4a3376a7 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -399,6 +399,9 @@ def parameterize(self, input_str: str) -> str: replacement_counts: defaultdict[str, int] = defaultdict(int) # Track whether any regex matches don't lead to a replacement found_false_positive = False + # Flag allowing us to only count false positives during the main parameterization, not the + # fallback run + emit_false_positive_metric = True def _handle_regex_match(match: re.Match[str]) -> str: # Ensure we're dealing with the flag from the outer scope, rather than shadowing it @@ -433,6 +436,16 @@ def _handle_regex_match(match: re.Match[str]) -> str: else: found_false_positive = True + # This is only true during the main combo-regex parameterization, not during + # fallback, so that we don't double-count these occurrences + if emit_false_positive_metric: + # Track the number of false positive matches, and what pattern produced them. We + # can compare this to the same key's `grouping.value_parameterized` metric below + # to see how often our maybe-matches pan out to be actual matches. + metrics.incr( + "grouping.parameterization_false_positive", tags={"key": matched_key} + ) + return replacement_string with metrics.timer( @@ -452,6 +465,9 @@ def _handle_regex_match(match: re.Match[str]) -> str: replacement_counts = defaultdict(int) parameterized = input_str + # Prevent double-counting of false positives + emit_false_positive_metric = False + # Apply patterns one by one, with no short-circuiting for regex_key, regex in self.compiled_regexes_by_name.items(): parameterized = regex.sub(_handle_regex_match, parameterized) diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 14a5f576ddf740..48cfb97bc31fc5 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -708,3 +708,12 @@ def test_replacement_callback_false_positive_triggers_individual_regex_fallback( ) == 0 ) + + # We also only counted the false positive once, even though we hit it both during the main + # combo-regex parameterization and during fallback + assert ( + count_matching_calls( + mock_metrics_incr, "grouping.parameterization_false_positive", tags={"key": "ip"} + ) + == 1 + ) From d06d2fc76cff82b586e80969e855dfa6bd431603 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Mon, 6 Apr 2026 14:16:42 -0700 Subject: [PATCH 30/37] ref(admin): Replace `useRouter` usage in `CustomerStats` & `CustomerStatsFilters` (#112291) https://github.com/getsentry/frontend-tsc/issues/78 Replaces `useRouter` usages in `CustomerStats` & `CustomerStatsFilters`. --- .../components/customers/customerStats.tsx | 8 +++---- .../customers/customerStatsFilters.tsx | 22 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/static/gsAdmin/components/customers/customerStats.tsx b/static/gsAdmin/components/customers/customerStats.tsx index aa2928ccdd5d9a..ec408a18eb0dcb 100644 --- a/static/gsAdmin/components/customers/customerStats.tsx +++ b/static/gsAdmin/components/customers/customerStats.tsx @@ -22,7 +22,7 @@ import {defined} from 'sentry/utils'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {getDynamicText} from 'sentry/utils/getDynamicText'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useLocation} from 'sentry/utils/useLocation'; enum SeriesName { ACCEPTED = 'Accepted', @@ -434,7 +434,7 @@ type Props = { export const CustomerStats = memo( ({orgSlug, projectId, dataType, onDemandPeriodStart, onDemandPeriodEnd}: Props) => { - const router = useRouter(); + const location = useLocation(); const dataDatetime = useMemo((): DateTimeObject => { const { @@ -442,7 +442,7 @@ export const CustomerStats = memo( end, utc: utcString, statsPeriod, - } = normalizeDateTimeParams(router.location.query, { + } = normalizeDateTimeParams(location.query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, @@ -474,7 +474,7 @@ export const CustomerStats = memo( return { period: statsPeriod ?? '90d', }; - }, [router.location.query, onDemandPeriodStart, onDemandPeriodEnd]); + }, [location.query, onDemandPeriodStart, onDemandPeriodEnd]); const statsEndpointUrl = getApiUrl(`/organizations/$organizationIdOrSlug/stats_v2/`, { path: {organizationIdOrSlug: orgSlug}, diff --git a/static/gsAdmin/components/customers/customerStatsFilters.tsx b/static/gsAdmin/components/customers/customerStatsFilters.tsx index c270f64acbe504..5dc24b0790d65c 100644 --- a/static/gsAdmin/components/customers/customerStatsFilters.tsx +++ b/static/gsAdmin/components/customers/customerStatsFilters.tsx @@ -17,7 +17,8 @@ import { import {DATA_CATEGORY_INFO, DEFAULT_RELATIVE_PERIODS} from 'sentry/constants'; import {DataCategoryExact} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; const ON_DEMAND_PERIOD_KEY = 'onDemand'; @@ -35,13 +36,12 @@ export function CustomerStatsFilters({ onDemandPeriodStart, onDemandPeriodEnd, }: Props) { - const router = useRouter(); + const location = useLocation(); + const navigate = useNavigate(); const onDemand = !!onDemandPeriodStart && !!onDemandPeriodEnd; const pageDateTime = useMemo((): DateTimeObject => { - const query = router.location.query; - - const {start, end, statsPeriod} = normalizeDateTimeParams(query, { + const {start, end, statsPeriod} = normalizeDateTimeParams(location.query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, @@ -59,7 +59,7 @@ export function CustomerStatsFilters({ } return {}; - }, [router.location.query]); + }, [location.query]); const handleDateChange = useCallback( (datetime: ChangeData) => { @@ -68,10 +68,10 @@ export function CustomerStatsFilters({ if (start && end) { const parser = utc ? moment.utc : moment; - router.push({ + navigate({ ...location, query: { - ...router.location.query, + ...location.query, statsPeriod: undefined, start: parser(start).format(), end: parser(end).format(), @@ -81,10 +81,10 @@ export function CustomerStatsFilters({ return; } - router.push({ + navigate({ ...location, query: { - ...router.location.query, + ...location.query, statsPeriod: relative === ON_DEMAND_PERIOD_KEY ? undefined : relative, start: undefined, end: undefined, @@ -92,7 +92,7 @@ export function CustomerStatsFilters({ }, }); }, - [router] + [location, navigate] ); const {start, end, period, utc} = pageDateTime; From 7860532247b76e02f6c31bf1a2157fa0100b86c3 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 6 Apr 2026 14:20:25 -0700 Subject: [PATCH 31/37] fix(supergroup): Guard against NaN in supergroup row stats during loading (#112294) Stats are fetched separately from groups, so when groups are in the store but stats haven't populated yet, `count`/`userCount` are undefined. `parseInt(undefined)` returns `NaN` which propagates through the sum and renders as "NaN" in the supergroup row. Guards with `|| 0` and `?? 0` so partially-loaded groups contribute zero instead of poisoning the total. Co-authored-by: Claude Opus 4.6 --- .../supergroups/aggregateSupergroupStats.spec.ts | 11 +++++++++++ .../issueList/supergroups/aggregateSupergroupStats.ts | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts index e67a275e9a60de..abe0f467ac4e80 100644 --- a/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts @@ -1,5 +1,7 @@ import {GroupFixture} from 'sentry-fixture/group'; +import type {Group} from 'sentry/types/group'; + import {aggregateSupergroupStats} from './aggregateSupergroupStats'; describe('aggregateSupergroupStats', () => { @@ -61,6 +63,15 @@ describe('aggregateSupergroupStats', () => { expect(result?.mergedFilteredStats).toBeNull(); }); + it('treats missing counts as zero when stats have not loaded', () => { + const result = aggregateSupergroupStats( + [GroupFixture({count: '10', userCount: 3}), {} as Group], + '24h' + ); + expect(result?.eventCount).toBe(10); + expect(result?.userCount).toBe(3); + }); + it('aggregates filtered stats separately', () => { const groups = [ GroupFixture({ diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts index fd46e74398fd45..42aa4e46ad7e2d 100644 --- a/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts @@ -51,14 +51,14 @@ export function aggregateSupergroupStats( let mergedFilteredStats: TimeseriesValue[] | null = null; for (const group of groups) { - eventCount += parseInt(group.count, 10); - userCount += group.userCount; + eventCount += parseInt(group.count, 10) || 0; + userCount += group.userCount ?? 0; if (group.filtered) { filteredEventCount ??= 0; filteredUserCount ??= 0; - filteredEventCount += parseInt(group.filtered.count, 10); - filteredUserCount += group.filtered.userCount; + filteredEventCount += parseInt(group.filtered.count, 10) || 0; + filteredUserCount += group.filtered.userCount ?? 0; const filteredStats = group.filtered.stats?.[statsPeriod]; if (filteredStats) { From 4a23a84a1c2e9e571c9bb65b6139bf9ce6459fb6 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 6 Apr 2026 14:25:26 -0700 Subject: [PATCH 32/37] feat(seer): Wrap the seat-based wizard in a feature flag (#112212) This will let us control whether the wizard is available or not. If it's not available and you get there somehow then the user will get sent back to the main settings landing page The only link/redirect to get into the wizard is via the banner, so we also control whether that renders or not. The legacy settings page has a hard-coded button to it's own onboarding flow, which really should've been removed already... but since that's technically separate i'll leave it for now. we'll be able to cleanup all the onboarding specific flows at the same time, general config and onboarding are not distinct, its the same screens whether it's the first time to get going, or if you're making tweaks. --- .../components/primaryNavSeerConfigReminder.tsx | 16 ++-------------- .../components/seerSettingsPageContent.tsx | 2 +- .../seerAutomation/onboarding/onboarding.tsx | 15 ++++++++++----- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/static/gsApp/components/primaryNavSeerConfigReminder.tsx b/static/gsApp/components/primaryNavSeerConfigReminder.tsx index 36089416c6e7dd..ba625eec543460 100644 --- a/static/gsApp/components/primaryNavSeerConfigReminder.tsx +++ b/static/gsApp/components/primaryNavSeerConfigReminder.tsx @@ -128,36 +128,24 @@ function useReminderCopywriting() { const hasSeatBasedSeer = organization.features.includes('seat-based-seer-enabled'); const hasLegacySeer = organization.features.includes('seer-added'); - const descriptionByStep: Record< - Steps, - {description: string; pathname: string; title: string} | null - > = { + const descriptionByStep: Record = { [Steps.CONNECT_GITHUB]: { title: t('Connect GitHub'), description: t( 'Seer is enabled, but Github is not connected. Connect your GitHub account to enable Root Cause Analysis and Code Review.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_ROOT_CAUSE_ANALYSIS]: { title: t('Start using Seer\u2019s Issue Autofix'), description: t( 'Seer is enabled but Root Cause Analysis is not configured. Configure Seer to automatically look at issues and generate code fixes.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_CODE_REVIEW]: { title: t('Start using Seer\u2019s AI Code Review'), description: t( 'Seer is enabled but Code Review is not configured. Configure Seer to automatically review PRs and flag potential issues.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_DEFAULTS]: null, [Steps.WRAP_UP]: null, @@ -217,7 +205,7 @@ export function PrimaryNavSeerConfigReminder() { {copy.description} state.close()} analyticsEventName="Seer Config Reminder: Configure Now Clicked" diff --git a/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx b/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx index 23449f9a5e5068..4b42c8ca13e011 100644 --- a/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx +++ b/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx @@ -24,7 +24,7 @@ export function SeerSettingsPageContent({children}: Props) { return ( - + {organization.features.includes('seer-wizard') ? : null} {showNoActiveSeerSubscriptionBanner ? : null} diff --git a/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx b/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx index 87aa0dc5099bc1..024793e2a6077e 100644 --- a/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx +++ b/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx @@ -1,5 +1,7 @@ import {AnalyticsArea} from 'sentry/components/analyticsArea'; +import {Redirect} from 'sentry/components/redirect'; import {showNewSeer} from 'sentry/utils/seer/showNewSeer'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SeerAutomationOnboarding as SeerOnboardingLegacy} from './onboardingLegacy'; @@ -12,11 +14,14 @@ export default function SeerOnboarding() { const organization = useOrganization(); if (showNewSeer(organization)) { - return ( - - - - ); + if (organization.features.includes('seer-wizard')) { + return ( + + + + ); + } + return ; } return ( From 4e9626fc4e08939b8d71b1c3e09bad006b127c00 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Mon, 6 Apr 2026 14:31:04 -0700 Subject: [PATCH 33/37] ref(spans): Replace `useRouter` usage in spans `SampleList` (#112297) https://github.com/getsentry/frontend-tsc/issues/78 Replaces `useRouter` usage in spans `SampleList` with `useNavigate`. --- .../spanSummaryPage/sampleList/index.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx b/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx index 777fc68af09f5b..4ca9f57c7bd81f 100644 --- a/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx +++ b/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx @@ -13,9 +13,9 @@ import {decodeScalar} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocationQuery} from 'sentry/utils/url/useLocationQuery'; import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; -import {useRouter} from 'sentry/utils/useRouter'; import {TraceItemSearchQueryBuilder} from 'sentry/views/explore/components/traceItemSearchQueryBuilder'; import {DATA_TYPE} from 'sentry/views/insights/browser/resources/settings'; import {decodeSubregions} from 'sentry/views/insights/browser/resources/utils/queryParameterDecoders/subregions'; @@ -82,7 +82,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr }, }); - const router = useRouter(); + const navigate = useNavigate(); const [highlightedSpanId, setHighlightedSpanId] = useState( undefined ); @@ -101,13 +101,16 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr ); const handleSearch = (newSpanSearchQuery: string) => { - router.replace({ - pathname: location.pathname, - query: { - ...location.query, - spanSearchQuery: newSpanSearchQuery, + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + spanSearchQuery: newSpanSearchQuery, + }, }, - }); + {replace: true} + ); }; // set additional query filters from the span search bar and the `query` param @@ -146,7 +149,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr const handleClickSample = useCallback( (span: SpanSample) => { - router.push( + navigate( generateLinkToEventInTraceView({ targetId: span['transaction.span_id'], spanId: span.span_id, @@ -157,7 +160,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr }) ); }, - [organization, location, router] + [organization, location, navigate] ); const handleMouseOverSample = useCallback( From 4fd1d1016e4715bc74dcc91e9df7551465ee6fb6 Mon Sep 17 00:00:00 2001 From: Krithik Ravindran <84836296+krithikravi@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:31:12 -0700 Subject: [PATCH 34/37] chore(billing): Added data category constant for trace metric byte(BIL-2213) (#112286) https://linear.app/getsentry/issue/BIL-2213/set-billed-data-category-canproduct-for-trace-metrics https://linear.app/getsentry/issue/BIL-2226/set-isbilledcategory-to-true-for-installable-builds This PR adds a billed category entry for trace metric bytes. It also sets the isBilledCategory value to true for trace metric bytes. --- static/app/constants/index.tsx | 2 +- .../gsApp/components/productSelectionAvailability.spec.tsx | 1 + static/gsApp/components/productUnavailableCTA.spec.tsx | 1 + .../components/upgradeNowModal/usePreviewData.spec.tsx | 1 + .../upgradeNowModal/useUpgradeNowParams.spec.tsx | 1 + .../components/upgradeNowModal/useUpgradeNowParams.tsx | 1 + static/gsApp/constants.tsx | 7 +++++++ 7 files changed, 13 insertions(+), 1 deletion(-) diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index 583236a1bd4b99..8630a1b79ae4b5 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -653,7 +653,7 @@ export const DATA_CATEGORY_INFO = { titleName: t('Metrics (Bytes)'), productName: t('Metrics'), uid: 37, - isBilledCategory: false, + isBilledCategory: true, statsInfo: { ...DEFAULT_STATS_INFO, showExternalStats: true, diff --git a/static/gsApp/components/productSelectionAvailability.spec.tsx b/static/gsApp/components/productSelectionAvailability.spec.tsx index b619008b52b58d..d8fc54448b91e3 100644 --- a/static/gsApp/components/productSelectionAvailability.spec.tsx +++ b/static/gsApp/components/productSelectionAvailability.spec.tsx @@ -351,6 +351,7 @@ describe('ProductSelectionAvailability', () => { reservedSeerScanner: undefined, reservedSeerUsers: undefined, reservedSizeAnalyses: 0, + reservedTraceMetricBytes: 0, }; const mockPlan = PlanFixture({}); const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/productUnavailableCTA.spec.tsx b/static/gsApp/components/productUnavailableCTA.spec.tsx index 35785e0bc587d5..63792a6c5c69cc 100644 --- a/static/gsApp/components/productUnavailableCTA.spec.tsx +++ b/static/gsApp/components/productUnavailableCTA.spec.tsx @@ -219,6 +219,7 @@ describe('ProductUnavailableCTA', () => { reservedSeerScanner: undefined, reservedSeerUsers: undefined, reservedSizeAnalyses: undefined, + reservedTraceMetricBytes: undefined, }; const mockPlan = PlanFixture({}); const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx b/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx index 2bb679032fc052..c771d6db5222a2 100644 --- a/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx +++ b/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx @@ -26,6 +26,7 @@ const mockReservations: Reservations = { reservedSeerScanner: 0, reservedSeerUsers: 0, reservedSizeAnalyses: 100, + reservedTraceMetricBytes: undefined, }; const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx index 828c9378007283..e11edfd1694495 100644 --- a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx +++ b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx @@ -59,6 +59,7 @@ describe('useUpgradeNowParams', () => { reservedSeerScanner: 0, reservedSeerUsers: 0, reservedSizeAnalyses: 100, + reservedTraceMetricBytes: undefined, }, }) ); diff --git a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx index 1d934da6b77421..d02c2a6de0b12a 100644 --- a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx +++ b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx @@ -108,6 +108,7 @@ export function useUpgradeNowParams({organization, subscription, enabled = true} reservedSeerScanner: reserved.seerScanner, reservedSeerUsers: reserved.seerUsers, reservedSizeAnalyses: reserved.sizeAnalyses, + reservedTraceMetricBytes: reserved.traceMetricBytes, }, }; }, [billingConfig, isPending, subscription, enabled]); diff --git a/static/gsApp/constants.tsx b/static/gsApp/constants.tsx index 57652941d53347..00998ca509ab9a 100644 --- a/static/gsApp/constants.tsx +++ b/static/gsApp/constants.tsx @@ -196,6 +196,13 @@ export const BILLED_DATA_CATEGORY_INFO = { ), shortenedUnitName: 'GB', }, + [DataCategoryExact.TRACE_METRIC_BYTE]: { + ...DEFAULT_BILLED_DATA_CATEGORY_INFO[DataCategoryExact.TRACE_METRIC_BYTE], + canProductTrial: true, + freeEventsMultiple: 1, + feature: 'expose-category-trace-metric-byte', + shortenedUnitName: 'GB', + }, [DataCategoryExact.SEER_USER]: { ...DEFAULT_BILLED_DATA_CATEGORY_INFO[DataCategoryExact.SEER_USER], feature: 'seer-user-billing-launch', From 1ca62160bffb96cfa87012b86f2ec06be5fcfb30 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 6 Apr 2026 14:31:41 -0700 Subject: [PATCH 35/37] feat(aci): Add markdown icon to monitor description input (#112295) We want to make it more obvious that the description will use markdown syntax, so this adds a `MarkdownTextArea` component which adds the "markdown supported" icon in the top right. Hopefully we can improve this component in the future to use previews and such, but this is an improvement for now. --- static/app/components/markdownTextArea.tsx | 29 +++++++++++++++++++ .../forms/common/issueOwnershipSection.tsx | 29 ++++++++++--------- 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 static/app/components/markdownTextArea.tsx diff --git a/static/app/components/markdownTextArea.tsx b/static/app/components/markdownTextArea.tsx new file mode 100644 index 00000000000000..6741807531d360 --- /dev/null +++ b/static/app/components/markdownTextArea.tsx @@ -0,0 +1,29 @@ +import styled from '@emotion/styled'; + +import {Container} from '@sentry/scraps/layout'; +import type {TextAreaProps} from '@sentry/scraps/textarea'; +import {TextArea} from '@sentry/scraps/textarea'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {IconMarkdown} from 'sentry/icons'; +import {t} from 'sentry/locale'; +interface MarkdownTextAreaProps extends TextAreaProps { + className?: string; +} + +export function MarkdownTextArea({className, ...props}: MarkdownTextAreaProps) { + return ( + + + + + + + + + ); +} + +const RightPaddedTextArea = styled(TextArea)` + padding-right: ${p => p.theme.space['2xl']}; +`; diff --git a/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx index d88bda06a90c52..c074b07f61973e 100644 --- a/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx +++ b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx @@ -4,7 +4,8 @@ import styled from '@emotion/styled'; import {Stack} from '@sentry/scraps/layout'; import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; -import {TextareaField} from 'sentry/components/forms/fields/textareaField'; +import {FormField} from 'sentry/components/forms/formField'; +import {MarkdownTextArea} from 'sentry/components/markdownTextArea'; import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import {Container} from 'sentry/components/workflowEngine/ui/container'; import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; @@ -33,21 +34,27 @@ export function IssueOwnershipSection({step}: {step?: number}) { flexibleControlStateSize memberOfProjectSlugs={memberOfProjectSlugs} /> - + {fieldProps => ( + )} - rows={6} - autosize - /> + @@ -58,10 +65,6 @@ const OwnershipField = styled(SentryMemberTeamSelectorField)` padding: ${p => p.theme.space.lg} 0; `; -// Min height helps prevent resize after placeholder is replaced with user input -const MinHeightTextarea = styled(TextareaField)` +const DescriptionField = styled(FormField)` padding: ${p => p.theme.space.lg} 0; - textarea { - min-height: 140px; - } `; From 95827eb6b6140c212728c67f3bddbdf3f98f640c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 6 Apr 2026 14:33:28 -0700 Subject: [PATCH 36/37] feat(github-enterprise): Route installation_repositories to control silo (#112245) Remove the should_route_to_control_silo override in GithubEnterpriseRequestParser so it inherits the parent's routing, which sends installation_repositories events to control silo. We do this to make sure the webhooks for GHE will also route to the right place --- .../integrations/parsers/github_enterprise.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/sentry/middleware/integrations/parsers/github_enterprise.py b/src/sentry/middleware/integrations/parsers/github_enterprise.py index 02edd104dfdb47..3f7cbdce60d00a 100644 --- a/src/sentry/middleware/integrations/parsers/github_enterprise.py +++ b/src/sentry/middleware/integrations/parsers/github_enterprise.py @@ -4,11 +4,8 @@ from collections.abc import Mapping from typing import Any -from django.http import HttpRequest - from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.github.webhook import get_github_external_id -from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType from sentry.integrations.github_enterprise.webhook import GitHubEnterpriseWebhookEndpoint, get_host from sentry.integrations.types import IntegrationProviderSlug from sentry.middleware.integrations.parsers.github import GithubRequestParser @@ -21,13 +18,6 @@ class GithubEnterpriseRequestParser(GithubRequestParser): webhook_identifier = WebhookProviderIdentifier.GITHUB_ENTERPRISE webhook_endpoint = GitHubEnterpriseWebhookEndpoint - def should_route_to_control_silo( - self, parsed_event: Mapping[str, Any], request: HttpRequest - ) -> bool: - # GHE only routes installation events to control silo. - # installation_repositories is not yet supported for GHE. - return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION - def _get_external_id(self, event: Mapping[str, Any]) -> str | None: host = get_host(request=self.request) if not host: From d6d1381d7a44d6a913cf1ecae038b5420391941e Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Mon, 6 Apr 2026 14:53:03 -0700 Subject: [PATCH 37/37] feat(billing): Gate trace metric bytes notification behind feature flag (#112285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend notification setting is added in #112282 — this PR handles the frontend ff gating. closes https://linear.app/getsentry/issue/BIL-2216/update-notifications-backend-for-trace-metrics-settings --- .../notifications/notificationSettingsByType.spec.tsx | 3 +++ .../account/notifications/notificationSettingsByType.tsx | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx index 6239aed56f6931..99f37b62482238 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx @@ -326,6 +326,7 @@ describe('NotificationSettingsByType', () => { 'continuous-profiling-billing', 'seer-billing', 'logs-billing', + 'expose-category-trace-metric-byte', 'seer-user-billing-launch', ], }); @@ -501,6 +502,7 @@ describe('NotificationSettingsByType', () => { // No continuous-profiling-billing feature // No seer-billing feature // No logs-billing feature + // No expose-category-trace-metric-byte feature ], }); renderComponent({ @@ -525,6 +527,7 @@ describe('NotificationSettingsByType', () => { expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); expect(screen.queryByText('Seer Budget')).not.toBeInTheDocument(); expect(screen.queryByText('Logs')).not.toBeInTheDocument(); + expect(screen.queryByText('Metrics (Bytes)')).not.toBeInTheDocument(); expect(screen.queryByText('Active Contributors')).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 4e9f207b0fa789..9d55ec256598b6 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -251,6 +251,10 @@ export function NotificationSettingsByType({notificationType}: Props) { organization.features?.includes('logs-billing') ); + const hasTraceMetricsBilling = organizations.some(organization => + organization.features?.includes('expose-category-trace-metric-byte') + ); + const hasSeerUserBilling = organizations.some(organization => organization.features?.includes('seer-user-billing-launch') ); @@ -282,6 +286,9 @@ export function NotificationSettingsByType({notificationType}: Props) { if (field.name.startsWith('quotaLogBytes') && !includeLogs) { return false; } + if (field.name.startsWith('quotaTraceMetricBytes') && !hasTraceMetricsBilling) { + return false; + } if (field.name.startsWith('quotaSeerUsers') && !hasSeerUserBilling) { return false; }