From ac317d3126e097f458c90dc1e05abae14c781976 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Wed, 27 May 2026 18:01:10 +0200 Subject: [PATCH 01/37] ref(tasks): Remove base64 encoding for bytes parameters in tasks (#116293) ## Summary - Pass bytes directly to tasks instead of base64-encoding, now that taskbroker uses msgpack - `assemble_download`: `page_token` now accepts `bytes | str` - `fulfill_cross_region_export_request`: `encrypt_with_public_key` now accepts `bytes | str` - Both handle legacy base64 strings for backwards compatibility Follow-up to #115069 which did the same for `process_profile_task`. ref STREAM-1011 --- src/sentry/data_export/tasks.py | 36 ++++++++++--------- .../services/relocation_export/impl.py | 3 +- src/sentry/relocation/tasks/process.py | 8 +++-- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/sentry/data_export/tasks.py b/src/sentry/data_export/tasks.py index a785b0adee31..04e4217c0583 100644 --- a/src/sentry/data_export/tasks.py +++ b/src/sentry/data_export/tasks.py @@ -100,11 +100,11 @@ def _sentry_metric_attrs( return attrs -def _page_token_b64_from_processor( +def _page_token_from_processor( processor: IssuesByTagProcessor | DiscoverProcessor | ExploreProcessor, -) -> str | None: +) -> bytes | None: if isinstance(processor, TraceItemFullExportProcessor) and processor.page_token is not None: - return base64.b64encode(processor.page_token).decode("ascii") + return processor.page_token return None @@ -162,7 +162,7 @@ def export_chunk_to_stored_blobs( export_limit: int, environment_id: int | None, first_page: bool = True, - page_token: str | None = None, + page_token: bytes | str | None = None, offset: int = 0, bytes_written: int = 0, batch_size: int = SNUBA_MAX_RESULTS, @@ -174,7 +174,7 @@ def export_chunk_to_stored_blobs( data_export, environment_id, output_mode, - page_token_b64=page_token, + page_token=page_token, ) with tempfile.TemporaryFile(mode="w+b") as tf: @@ -240,7 +240,7 @@ def _schedule_retry( base_bytes_written: int, environment_id: int | None, export_retries: int, - page_token: str | None, + page_token: bytes | str | None, delay_retry: bool = False, ) -> None: assemble_download.apply_async( @@ -280,7 +280,7 @@ def _schedule_next_task( "bytes_written": bytes_written, "environment_id": environment_id, "export_retries": export_retries, - "page_token": _page_token_b64_from_processor(processor), + "page_token": _page_token_from_processor(processor), } should_continue = next_offset < export_limit and ( (isinstance(processor, TraceItemFullExportProcessor) and processor.page_token is not None) @@ -325,7 +325,7 @@ def assemble_download( environment_id: int | None = None, export_retries: int = DEFAULT_EXPORT_RETRIES, *, - page_token: str | None = None, + page_token: bytes | str | None = None, **kwargs: Any, ) -> None: # The API response to export the data contains the ID which you can use @@ -573,7 +573,7 @@ def get_processor( environment_id: int | None, output_mode: OutputMode, *, - page_token_b64: str | None = None, + page_token: bytes | str | None = None, ) -> IssuesByTagProcessor | DiscoverProcessor | ExploreProcessor | TraceItemFullExportProcessor: try: if data_export.query_type == ExportQueryType.ISSUES_BY_TAG: @@ -597,17 +597,21 @@ def get_processor( output_mode=output_mode, ) elif data_export.query_type == ExportQueryType.TRACE_ITEM_FULL_EXPORT: - page_token: bytes | None = None - if page_token_b64: - try: - page_token = base64.b64decode(page_token_b64) - except (ValueError, TypeError) as e: - raise ExportError("Invalid export trace item pagination state.") from e + page_token_bytes: bytes | None = None + if page_token is not None: + # Handle both bytes (new) and base64 string (legacy) page tokens + if isinstance(page_token, str): + try: + page_token_bytes = base64.b64decode(page_token) + except (ValueError, TypeError) as e: + raise ExportError("Invalid export trace item pagination state.") from e + else: + page_token_bytes = page_token return TraceItemFullExportProcessor( explore_query=data_export.query_info, organization=data_export.organization, output_mode=output_mode, - page_token=page_token, + page_token=page_token_bytes, ) else: diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 2a6447e62f49..0c9e2ed7366c 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -3,7 +3,6 @@ # in modules such as this one where hybrid cloud data models or service classes are # defined, because we want to reflect on type annotations and avoid forward references. -import base64 import logging from datetime import UTC, datetime from io import BytesIO @@ -64,7 +63,7 @@ def request_new_export( requesting_region_name, replying_region_name, org_slug, - base64.b64encode(encrypt_with_public_key).decode("utf8"), + encrypt_with_public_key, int(round(datetime.now(tz=UTC).timestamp())), ] ) diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index f229c1a482e1..9b7341f075ce 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -319,7 +319,7 @@ def fulfill_cross_region_export_request( requesting_cell_name: str, replying_cell_name: str, org_slug: str, - encrypt_with_public_key: str, + encrypt_with_public_key: bytes | str, # Unix timestamp, in seconds. scheduled_at: int, ) -> None: @@ -334,7 +334,11 @@ def fulfill_cross_region_export_request( """ from sentry.relocation.tasks.transfer import process_relocation_transfer_region - encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) + # Handle both bytes (new) and base64 string (legacy) + if isinstance(encrypt_with_public_key, str): + encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) + else: + encrypt_with_public_key_bytes = encrypt_with_public_key logger_data = { "uuid": uuid_str, From d2820f273af93db714c7f128e23b05b7169fb392 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 09:25:10 -0700 Subject: [PATCH 02/37] ref(issueDiff): Refactor event data fetching to use useQueries (#116042) This PR refactors the `IssueDiff` component's event data fetching logic. Previously, the component used multiple `useQuery` hooks to sequentially fetch 'latest' event IDs and then the full event data. This created a complex asynchronous chain that could lead to flaky tests, specifically `JEST-21V8: TestingLibraryElementError: Unable to find an element by: [data-test-id="split-diff"]`, where the `SplitDiff` component was not rendered within the test timeout. This change consolidates the data fetching into a single `useQueries` call. It leverages `skipToken` for conditional fetching and the `combine` function to process the results and compute the combined stacktraces. This approach simplifies the component's data fetching logic, making it more robust and easier to reason about, which should also contribute to more stable test execution. Related to JEST-21V8 --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Ryan Albrecht Co-authored-by: Ryan Albrecht Co-authored-by: Claude Opus 4.6 --- static/app/components/issueDiff/index.tsx | 177 +++++++++++----------- 1 file changed, 91 insertions(+), 86 deletions(-) diff --git a/static/app/components/issueDiff/index.tsx b/static/app/components/issueDiff/index.tsx index 9cbe582a49d2..7f93e19f6acc 100644 --- a/static/app/components/issueDiff/index.tsx +++ b/static/app/components/issueDiff/index.tsx @@ -1,5 +1,5 @@ -import {lazy, useEffect, useMemo, useRef} from 'react'; -import {useQuery} from '@tanstack/react-query'; +import {lazy, useEffect, useRef} from 'react'; +import {skipToken, useQueries} from '@tanstack/react-query'; import {Flex} from '@sentry/scraps/layout'; @@ -71,99 +71,112 @@ export function IssueDiff({ const newestFirst = isStacktraceNewestFirst(); - const baseLatestQuery = useQuery({ - ...apiOptions.as<{eventID: string}>()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: baseIssueId, - eventId: 'latest', - }, - staleTime: 60_000, - } - ), - enabled: baseEventId === 'latest', - }); - - const targetLatestQuery = useQuery({ - ...apiOptions.as<{eventID: string}>()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: targetIssueId, - eventId: 'latest', - }, - staleTime: 60_000, - } - ), - enabled: targetEventId === 'latest', + // Resolve "latest" to concrete event IDs (skipped if concrete IDs were passed) + const [baseLatestQuery, targetLatestQuery] = useQueries({ + queries: [ + apiOptions.as<{eventID: string}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: + baseEventId === 'latest' + ? { + organizationIdOrSlug: organization.slug, + issueId: baseIssueId, + eventId: 'latest', + } + : skipToken, + staleTime: 60_000, + } + ), + apiOptions.as<{eventID: string}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: + targetEventId === 'latest' + ? { + organizationIdOrSlug: organization.slug, + issueId: targetIssueId, + eventId: 'latest', + } + : skipToken, + staleTime: 60_000, + } + ), + ], }); + // Derive resolved IDs reactively from the query results const resolvedBaseEventId = baseEventId === 'latest' ? baseLatestQuery.data?.eventID : baseEventId; const resolvedTargetEventId = targetEventId === 'latest' ? targetLatestQuery.data?.eventID : targetEventId; - const baseEventQuery = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: baseIssueId, - eventId: resolvedBaseEventId ?? '', - }, - staleTime: 60_000, - } - ), - enabled: Boolean(resolvedBaseEventId), - }); - - const targetEventQuery = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', - { - path: { - organizationIdOrSlug: organization.slug, - issueId: targetIssueId, - eventId: resolvedTargetEventId ?? '', - }, - staleTime: 60_000, - } - ), - enabled: Boolean(resolvedTargetEventId), - }); - - const {combinedBase, combinedTarget} = useMemo( - () => ({ + // Fetch actual event data once IDs are resolved + const { + combinedBase, + combinedTarget, + isLoading, + hasError, + baseEventData, + targetEventData, + } = useQueries({ + queries: [ + apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: resolvedBaseEventId + ? { + organizationIdOrSlug: organization.slug, + issueId: baseIssueId, + eventId: resolvedBaseEventId, + } + : skipToken, + staleTime: 60_000, + } + ), + apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/$issueId/events/$eventId/', + { + path: resolvedTargetEventId + ? { + organizationIdOrSlug: organization.slug, + issueId: targetIssueId, + eventId: resolvedTargetEventId, + } + : skipToken, + staleTime: 60_000, + } + ), + ], + combine: ([baseEvent, targetEvent]) => ({ + isLoading: baseEvent.isPending || targetEvent.isPending, + hasError: + baseLatestQuery.isError || + targetLatestQuery.isError || + baseEvent.isError || + targetEvent.isError, + baseEventData: baseEvent.data, + targetEventData: targetEvent.data, combinedBase: getCombinedStacktrace({ - event: baseEventQuery.data, + event: baseEvent.data, hasSimilarityEmbeddingsFeature, newestFirst, }), combinedTarget: getCombinedStacktrace({ - event: targetEventQuery.data, + event: targetEvent.data, hasSimilarityEmbeddingsFeature, newestFirst, }), }), - [ - baseEventQuery.data, - targetEventQuery.data, - hasSimilarityEmbeddingsFeature, - newestFirst, - ] - ); + }); useEffect(() => { if ( hasTrackedAnalytics.current || !organization || !hasSimilarityEmbeddingsFeature || - !baseEventQuery.data || - !targetEventQuery.data + !baseEventData || + !targetEventData ) { return; } @@ -171,27 +184,19 @@ export function IssueDiff({ hasTrackedAnalytics.current = true; trackAnalytics('issue_details.similar_issues.diff_clicked', { organization, - project_id: baseEventQuery.data?.projectID, - group_id: baseEventQuery.data?.groupID, - parent_group_id: targetEventQuery.data?.groupID, + project_id: baseEventData?.projectID, + group_id: baseEventData?.groupID, + parent_group_id: targetEventData?.groupID, shouldBeGrouped, }); }, [ - baseEventQuery.data, + baseEventData, hasSimilarityEmbeddingsFeature, organization, shouldBeGrouped, - targetEventQuery.data, + targetEventData, ]); - const hasError = - baseLatestQuery.isError || - targetLatestQuery.isError || - baseEventQuery.isError || - targetEventQuery.isError; - - const isLoading = baseEventQuery.isPending || targetEventQuery.isPending; - if (hasError) { return ( From e7a13d81827e8c540e84b2c14f1b6321b34ad383 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini <99758426+giovanni-guidini@users.noreply.github.com> Date: Wed, 27 May 2026 18:29:48 +0200 Subject: [PATCH 03/37] fix(code-mapping): update codeowners GET endpoint and tests (#116309) The GET handler for the codeowners endpoint looked up RepositoryProjectPathConfig by config id and organization only. The sibling PUT and DELETE handlers on the code-mapping details endpoint gate on project access; this brings the GET path in line with that behaviour. Also moves the test file to the canonical mirror path under tests/sentry/integrations/api/endpoints/ to match the source module location, and consolidates the two test classes into one following the pattern established by the sibling endpoint tests. ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. Co-authored-by: Claude Sonnet 4 --- pyproject.toml | 1 - .../organization_code_mapping_codeowners.py | 4 ++ ...st_organization_code_mapping_codeowners.py | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) rename tests/sentry/{ => integrations}/api/endpoints/test_organization_code_mapping_codeowners.py (59%) diff --git a/pyproject.toml b/pyproject.toml index 07e1c0cc6fce..4703bccfde49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1788,7 +1788,6 @@ module = [ "tests.sentry.api.endpoints.test_organization_auth_providers", "tests.sentry.api.endpoints.test_organization_auth_token_details", "tests.sentry.api.endpoints.test_organization_auth_tokens", - "tests.sentry.api.endpoints.test_organization_code_mapping_codeowners", "tests.sentry.api.endpoints.test_organization_config_integrations", "tests.sentry.api.endpoints.test_organization_events_trends_v2", "tests.sentry.api.endpoints.test_organization_fork", diff --git a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py index b239590e885c..75faac1498c5 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py @@ -59,6 +59,10 @@ def convert_args(self, request: Request, organization_id_or_slug, config_id, *ar return (args, kwargs) def get(self, request: Request, config_id, organization, config) -> Response: + project = config.project_repository.project + if not request.access.has_project_access(project): + return self.respond(status=status.HTTP_403_FORBIDDEN) + try: codeowner_contents = get_codeowner_contents(config) except ApiError as e: diff --git a/tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py similarity index 59% rename from tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py rename to tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py index 072f43a459b2..4a70980b02e4 100644 --- a/tests/sentry/api/endpoints/test_organization_code_mapping_codeowners.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_codeowners.py @@ -18,6 +18,10 @@ def setUp(self) -> None: self.login_as(user=self.user) + # Restrict project membership so that team assignment controls access. + self.organization.flags.allow_joinleave = False + self.organization.save() + self.team = self.create_team(organization=self.organization, name="Mariachi Band") self.project = self.create_project( organization=self.organization, teams=[self.team], name="Bengal" @@ -61,3 +65,40 @@ def test_codeowner_contents(self, mock_get_codeowner_file: MagicMock) -> None: resp = self.client.get(self.url) assert resp.status_code == 200 assert resp.data == GITHUB_CODEOWNER + + @patch( + "sentry.integrations.github.integration.GitHubIntegration.get_codeowner_file", + return_value=GITHUB_CODEOWNER, + ) + def test_user_without_project_access_cannot_read_codeowners( + self, mock_get_codeowner_file: MagicMock + ) -> None: + outsider = self.create_user() + self.create_member( + organization=self.organization, + user=outsider, + has_global_access=False, + teams=[], + ) + self.login_as(user=outsider) + resp = self.client.get(self.url) + assert resp.status_code == 403 + + @patch( + "sentry.integrations.github.integration.GitHubIntegration.get_codeowner_file", + return_value=GITHUB_CODEOWNER, + ) + def test_user_with_project_access_can_read_codeowners( + self, mock_get_codeowner_file: MagicMock + ) -> None: + insider = self.create_user() + self.create_member( + organization=self.organization, + user=insider, + has_global_access=False, + teams=[self.team], + ) + self.login_as(user=insider) + resp = self.client.get(self.url) + assert resp.status_code == 200 + assert resp.data == GITHUB_CODEOWNER From 62f4dc8d3d0bcc66a87a79d9ad1e5aa0f6acb4e4 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Wed, 27 May 2026 12:33:04 -0400 Subject: [PATCH 04/37] fix(api-logs): preserve snuba policy info on throttles (#116263) --- src/sentry/utils/snuba.py | 3 ++- tests/sentry/utils/test_snuba.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index 04ebdae2d347..528451597d45 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -1351,7 +1351,8 @@ def _bulk_snuba_query(snuba_requests: Sequence[SnubaRequest]) -> ResultSet: quota_unit=policy_info["quota_unit"], storage_key=policy_info["storage_key"], quota_used=policy_info["quota_used"], - rejection_threshold=policy_info["rejection_threshold"], + # We won't have rejection_threshold for throttled_by errors + rejection_threshold=policy_info.get("rejection_threshold"), ) except KeyError: logger.warning( diff --git a/tests/sentry/utils/test_snuba.py b/tests/sentry/utils/test_snuba.py index a275d59d35e5..d656d66ca789 100644 --- a/tests/sentry/utils/test_snuba.py +++ b/tests/sentry/utils/test_snuba.py @@ -628,3 +628,41 @@ def test_rate_limit_error_handling_with_stats_but_no_quota_details( assert ( str(exc_info.value) == "Query on could not be run due to allocation policies, info: ..." ) + + @mock.patch("sentry.utils.snuba._snuba_query") + def test_rate_limit_error_handling_throttle_only(self, mock_snuba_query) -> None: + """ + Test that policy metadata propagates when the 429 came from a throttle: + rejected_by is empty and throttled_by carries throttle_threshold, with no rejection_threshold + """ + mock_response = mock.Mock(spec=HTTPResponse) + mock_response.status = 429 + mock_response.data = json.dumps( + { + "error": { + "message": "Query scanned more than the allocated amount of bytes", + }, + "quota_allowance": { + "summary": { + "rejected_by": {}, + "throttled_by": { + "policy": "BytesScannedRejectingPolicy", + "quota_used": 1500000000000, + "throttle_threshold": 1000000000000, + "quota_unit": "bytes", + "storage_key": "errors_ro", + }, + } + }, + } + ).encode() + + mock_snuba_query.return_value = ("test_referrer", mock_response, lambda x: x, lambda x: x) + + with pytest.raises(RateLimitExceeded) as exc_info: + _bulk_snuba_query([self.snuba_request]) + + assert exc_info.value.policy == "BytesScannedRejectingPolicy" + assert exc_info.value.storage_key == "errors_ro" + assert exc_info.value.quota_used == 1500000000000 + assert exc_info.value.quota_unit == "bytes" From f4d567dfd59df507606f4df8ca7c6c39f4c42ed6 Mon Sep 17 00:00:00 2001 From: Richard Roggenkemper <46740234+roggenkemper@users.noreply.github.com> Date: Wed, 27 May 2026 12:34:40 -0400 Subject: [PATCH 05/37] chore(issue-detection): Update badge for AI Issue Detection (#116311) now that ai issue detection is GA, updating the badge from beta to new --- static/app/views/issueDetails/streamline/header/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/issueDetails/streamline/header/header.tsx b/static/app/views/issueDetails/streamline/header/header.tsx index 8da71c4984e9..88cc27674ac8 100644 --- a/static/app/views/issueDetails/streamline/header/header.tsx +++ b/static/app/views/issueDetails/streamline/header/header.tsx @@ -124,7 +124,7 @@ export function StreamlinedGroupHeader({event, group, project}: GroupHeaderProps > {primaryTitle} - {isAIDetectedIssue && } + {isAIDetectedIssue && } {issueTypeConfig.eventAndUserCounts.enabled && ( From 91739d162d53d60fbf20711f0c72c6f170c61f93 Mon Sep 17 00:00:00 2001 From: gricha <875316+gricha@users.noreply.github.com> Date: Wed, 27 May 2026 16:36:19 +0000 Subject: [PATCH 06/37] release: 26.5.1 --- CHANGES | 734 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 2 +- src/sentry/conf/server.py | 2 +- 3 files changed, 736 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 3b101297dcb7..94c6903d9410 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,737 @@ +26.5.1 +------ + +### New Features ✨ + +#### Apigw + +- Expose proxy latency metrics by target by @gi0baro in [#116086](https://github.com/getsentry/sentry/pull/116086) +- Add non-orgid/slug endpoints to proxied cell requests by @gi0baro in [#115930](https://github.com/getsentry/sentry/pull/115930) + +#### Autofix + +- Allow non seat based seer to skip setup in [#116208](https://github.com/getsentry/sentry/pull/116208) +- Switch inspection to single llm call using gemini flas… by @Zylphrex in [#116071](https://github.com/getsentry/sentry/pull/116071) +- Autofix introspection analytics by @Zylphrex in [#115891](https://github.com/getsentry/sentry/pull/115891) +- Add UI labels for missing AutofixReferrer values by @chromy in [#115655](https://github.com/getsentry/sentry/pull/115655) +- Render line numbers in autofix evidence by @Zylphrex in [#115649](https://github.com/getsentry/sentry/pull/115649) + +#### Cells + +- Remove cross-org feature gating from notification settings by @lynnagara in [#115829](https://github.com/getsentry/sentry/pull/115829) +- Add cell-routing mode to devservices by @lynnagara in [#115737](https://github.com/getsentry/sentry/pull/115737) + +#### Cmdk + +- Add Open in Production and Open in Development actions in [#116242](https://github.com/getsentry/sentry/pull/116242) +- Freeze visible action list during keyboard navigation in [#115851](https://github.com/getsentry/sentry/pull/115851) +- Add project search action to command palette by @JonasBa in [#115591](https://github.com/getsentry/sentry/pull/115591) + +#### Conversations + +- Add copy conversation as markdown button in [#116171](https://github.com/getsentry/sentry/pull/116171) +- Swap badge from alpha to beta by @obostjancic in [#115712](https://github.com/getsentry/sentry/pull/115712) +- Add Amplitude analytics to conversation pages by @obostjancic in [#115622](https://github.com/getsentry/sentry/pull/115622) + +#### Dashboards + +- Add span-first support for web vital dashboard in [#115882](https://github.com/getsentry/sentry/pull/115882) +- Validate display type against dataset config by @DominikB2014 in [#115951](https://github.com/getsentry/sentry/pull/115951) +- Require metric_unit in AI tracemetrics aggregates by @DominikB2014 in [#116101](https://github.com/getsentry/sentry/pull/116101) +- Teach AI dashboard generator the tracemetrics aggregate format by @DominikB2014 in [#115480](https://github.com/getsentry/sentry/pull/115480) + +#### Explore + +- Heatmap tooltip trace links by @nikkikapadia in [#115925](https://github.com/getsentry/sentry/pull/115925) +- Link to aggregates from dropdown by @nsdeschenes in [#115789](https://github.com/getsentry/sentry/pull/115789) +- Add Heat Map widget to Explore metrics by @gggritso in [#115608](https://github.com/getsentry/sentry/pull/115608) + +#### Github Enterprise + +- Add frontend pipeline steps for GHE integration setup in [#114367](https://github.com/getsentry/sentry/pull/114367) +- Add API-driven pipeline backend for GHE integration setup in [#114366](https://github.com/getsentry/sentry/pull/114366) +- Allow github.com as a source for the GitHub Enterprise integration by @tnt-sentry in [#115599](https://github.com/getsentry/sentry/pull/115599) + +#### Issues + +- Bring back `SEER_PR_CREATED` activity creation and hide from timeline in [#116233](https://github.com/getsentry/sentry/pull/116233) +- Two-column activity icons, colors by @scttcper in [#115958](https://github.com/getsentry/sentry/pull/115958) +- Unify issue activity streams by @scttcper in [#115848](https://github.com/getsentry/sentry/pull/115848) +- Add activity feed v2 flag by @scttcper in [#115966](https://github.com/getsentry/sentry/pull/115966) +- Consolidate activity comment input by @scttcper in [#115824](https://github.com/getsentry/sentry/pull/115824) +- Replace DebugMeta store with context by @scttcper in [#115842](https://github.com/getsentry/sentry/pull/115842) + +#### Low Value Spans + +- Add configuration issue UI in [#116271](https://github.com/getsentry/sentry/pull/116271) +- Add Snuba referrer for detector by @vgrozdanic in [#115980](https://github.com/getsentry/sentry/pull/115980) +- Add low-value span issue UI by @ArthurKnaus in [#115870](https://github.com/getsentry/sentry/pull/115870) +- Add low-value span issue type by @ArthurKnaus in [#115868](https://github.com/getsentry/sentry/pull/115868) + +#### Onboarding + +- Link selected repository to project after creation by @wedamija in [#115761](https://github.com/getsentry/sentry/pull/115761) +- Update Hono onboarding with `@sentry/hono` by @s1gr1d in [#115476](https://github.com/getsentry/sentry/pull/115476) + +#### Ourlogs + +- Reduce modal export rows limit to 10k by @JoshuaKGoldberg in [#116180](https://github.com/getsentry/sentry/pull/116180) +- Show estimated total dataset size in needle-in-haystack searches by @JoshuaKGoldberg in [#115731](https://github.com/getsentry/sentry/pull/115731) +- Implement pinned logs with sticky header (part 1) by @JoshuaKGoldberg in [#115102](https://github.com/getsentry/sentry/pull/115102) +- Add 'Group by attribute' to log property context menu by @JoshuaKGoldberg in [#115420](https://github.com/getsentry/sentry/pull/115420) + +#### Preprod + +- Display snapshot image tags in card headers in [#115723](https://github.com/getsentry/sentry/pull/115723) +- Display images_skipped in snapshot table by @NicoHinderling in [#116074](https://github.com/getsentry/sentry/pull/116074) +- Add images_skipped to builds API response by @NicoHinderling in [#116073](https://github.com/getsentry/sentry/pull/116073) +- Display skipped images in snapshots UI by @NicoHinderling in [#116041](https://github.com/getsentry/sentry/pull/116041) +- Expose is_selective flag in snapshot details API response by @NicoHinderling in [#115832](https://github.com/getsentry/sentry/pull/115832) +- Add Snapshot status check rules API by @cameroncooke in [#115621](https://github.com/getsentry/sentry/pull/115621) + +#### Search + +- Add recommended sort option to issue stream dropdown in [#116197](https://github.com/getsentry/sentry/pull/116197) +- Surface recommended sort in UI when active via query param in [#116186](https://github.com/getsentry/sentry/pull/116186) +- Register feature flag for recommended issue sort by @roggenkemper in [#116191](https://github.com/getsentry/sentry/pull/116191) + +#### Seer + +- Add structured LLM context for replay list and detail pages in [#116045](https://github.com/getsentry/sentry/pull/116045) +- Always show action buttons in explorer chat blocks by @ChrisandraVaz in [#116049](https://github.com/getsentry/sentry/pull/116049) +- Add bulk Seer project connected repos endpoint by @srest2021 in [#115942](https://github.com/getsentry/sentry/pull/115942) +- Add Seer project connected repo endpoint by @srest2021 in [#115199](https://github.com/getsentry/sentry/pull/115199) +- Add structured LLM context for explore logs trace route by @Mihir-Mavalankar in [#116036](https://github.com/getsentry/sentry/pull/116036) +- Add CRUD helpers for Seer project repos by @srest2021 in [#115904](https://github.com/getsentry/sentry/pull/115904) +- Add structured LLM context for issue detail sub-tabs by @Mihir-Mavalankar in [#115936](https://github.com/getsentry/sentry/pull/115936) +- Add bulk-project Seer settings endpoint by @srest2021 in [#115234](https://github.com/getsentry/sentry/pull/115234) +- Add helper for bulk updating Seer project settings by @srest2021 in [#115756](https://github.com/getsentry/sentry/pull/115756) +- Scope /conversations slash command lookup with start/end/project by @chromy in [#115785](https://github.com/getsentry/sentry/pull/115785) +- Add single-project Seer settings endpoint by @srest2021 in [#115230](https://github.com/getsentry/sentry/pull/115230) +- Add SeerRun FK to SeerNightShiftRun by @trevor-e in [#115694](https://github.com/getsentry/sentry/pull/115694) +- Add SeerWorkflowConfig model and link to night shift runs by @trevor-e in [#115615](https://github.com/getsentry/sentry/pull/115615) +- Mirror last_triggered_at to SeerRun on autofix triggers by @trevor-e in [#115611](https://github.com/getsentry/sentry/pull/115611) + +#### Tracemetrics + +- Include equations in Add to Dashboard by @narsaynorath in [#116141](https://github.com/getsentry/sentry/pull/116141) +- Convert equation alias to full equation for queries by @narsaynorath in [#116047](https://github.com/getsentry/sentry/pull/116047) +- Open in Explore for metrics dashboard widgets by @narsaynorath in [#115805](https://github.com/getsentry/sentry/pull/115805) +- Lazy load trace details per metric by @nsdeschenes in [#115066](https://github.com/getsentry/sentry/pull/115066) + +#### Webhooks + +- Add dry run check to sentry app webhook path in [#116265](https://github.com/getsentry/sentry/pull/116265) +- Add payload validation during dual-write migration in [#116040](https://github.com/getsentry/sentry/pull/116040) +- Add metrics for legacy webhook migration validation by @Christinarlong in [#116039](https://github.com/getsentry/sentry/pull/116039) +- Wire new service with feature-flagged routing by @Christinarlong in [#115747](https://github.com/getsentry/sentry/pull/115747) +- Add standalone legacy webhook service module by @Christinarlong in [#115688](https://github.com/getsentry/sentry/pull/115688) +- Register legacy webhook migration feature flags by @Christinarlong in [#115669](https://github.com/getsentry/sentry/pull/115669) + +#### Other + +- (aci) Add sort param to workflow group history endpoint in [#116031](https://github.com/getsentry/sentry/pull/116031) +- (alerts) Add cleanup task to NotificationMessage in [#116027](https://github.com/getsentry/sentry/pull/116027) +- (amplitude) Track whether users are viewing sentry-built dashboards by @bcoe in [#116138](https://github.com/getsentry/sentry/pull/116138) +- (api-docs) Publish project event details endpoint in [#116059](https://github.com/getsentry/sentry/pull/116059) +- (apigateway) Add separated async `apigw` package by @gi0baro in [#115624](https://github.com/getsentry/sentry/pull/115624) +- (button) Add `size` prop to `ButtonBar` via `SizeContext` by @natemoo-re in [#115728](https://github.com/getsentry/sentry/pull/115728) +- (ci) Add merge_base_strategy tag to Jest CI runs by @ryan953 in [#115967](https://github.com/getsentry/sentry/pull/115967) +- (data-forwarding) Enable retries for data forwarders via task dispatch by @leeandher in [#115511](https://github.com/getsentry/sentry/pull/115511) +- (dev) Add SENTRY_CELL_ROUTING env var that runs cell-routing mode locally by @lynnagara in [#115852](https://github.com/getsentry/sentry/pull/115852) +- (dynamic-sampling) Add per-project volume query in [#114286](https://github.com/getsentry/sentry/pull/114286) +- (examples) Add task that produces by @bmckerry in [#115820](https://github.com/getsentry/sentry/pull/115820) +- (explorer) Add query parameter to explorer-runs API by @JonasBa in [#115760](https://github.com/getsentry/sentry/pull/115760) +- (integrations) Disable auth token creation button without perms by @cvxluo in [#115769](https://github.com/getsentry/sentry/pull/115769) +- (markdown) Expose default components via `Default` prop by @natemoo-re in [#115745](https://github.com/getsentry/sentry/pull/115745) +- (options) Add timing metric to options.get() by @kenzoengineer in [#115762](https://github.com/getsentry/sentry/pull/115762) +- (profiling) Add task for taskbroker passthrough mode by @untitaker in [#115065](https://github.com/getsentry/sentry/pull/115065) +- (repositories) Add project repo-link endpoint by @wedamija in [#115754](https://github.com/getsentry/sentry/pull/115754) +- (routes) Add redirect from /snapshots/ to explore releases by @NicoHinderling in [#116053](https://github.com/getsentry/sentry/pull/116053) +- (scm) Add streaming integration-proxy which accepts any 'Accepts' header value by @cmanallen in [#115917](https://github.com/getsentry/sentry/pull/115917) +- (self-healing) Add support for seer activities in workflow engine by @saponifi3d in [#115933](https://github.com/getsentry/sentry/pull/115933) +- (settings) Add 'Recent Error Events' column to project environments by @JoshuaKGoldberg in [#115902](https://github.com/getsentry/sentry/pull/115902) +- (source-map-config-issues) Updating processing errors metric by @Abdkhan14 in [#115822](https://github.com/getsentry/sentry/pull/115822) +- (spans) Add separate Redis cluster setting for span deduplication by @untitaker in [#116010](https://github.com/getsentry/sentry/pull/116010) +- (trace-waterfall) Small tweaks to trace-waterfall tab by @nsdeschenes in [#115584](https://github.com/getsentry/sentry/pull/115584) +- (ui) Add debug FeatureBadge variant by @chromy in [#116000](https://github.com/getsentry/sentry/pull/116000) +- Flags and rpc for frontend code search tool by @shruthilayaj in [#116098](https://github.com/getsentry/sentry/pull/116098) +- Add SENTRY_ALLOWED_IPS to allow IP, overwrite SENTRY_DISALLOWED… by @fe80 in [#115773](https://github.com/getsentry/sentry/pull/115773) +- Add Relay measurements conversion feature by @loewenheim in [#115979](https://github.com/getsentry/sentry/pull/115979) +- Track read options via seen logline by @joshuarli in [#115610](https://github.com/getsentry/sentry/pull/115610) +- Add toggle to migrate to billing platform by @noahsmartin in [#115895](https://github.com/getsentry/sentry/pull/115895) + +### Bug Fixes 🐛 + +#### Alerts + +- Handle gte/lte condition types in metric alert serializers by @kcons in [#115972](https://github.com/getsentry/sentry/pull/115972) +- Update migration to not remove FK to group by @ceorourke in [#115932](https://github.com/getsentry/sentry/pull/115932) +- Surface API error messages in create/update toasts by @malwilley in [#115894](https://github.com/getsentry/sentry/pull/115894) +- Batch NotificationMessage delete metric alert rows by @ceorourke in [#115726](https://github.com/getsentry/sentry/pull/115726) + +#### Api + +- Correctly parse `full` parameter in project events endpoint in [#116216](https://github.com/getsentry/sentry/pull/116216) +- Validate IDs in OrganizationGroupIndexEndpoint.delete by @kcons in [#115770](https://github.com/getsentry/sentry/pull/115770) + +#### Conversations + +- Restore side-by-side layout for platform option dropdown in [#116272](https://github.com/getsentry/sentry/pull/116272) +- Improve tool badge rendering and overflow behavior by @obostjancic in [#115880](https://github.com/getsentry/sentry/pull/115880) +- Improve truncation of non-UUID conversation IDs by @sentry-junior in [#115978](https://github.com/getsentry/sentry/pull/115978) + +#### Dashboards + +- Raise widget description limit to 350 by @DominikB2014 in [#116185](https://github.com/getsentry/sentry/pull/116185) +- Propagate global filters in Open in Issues link by @DominikB2014 in [#116105](https://github.com/getsentry/sentry/pull/116105) +- Stop widget header action clicks from bubbling by @skaasten in [#116096](https://github.com/getsentry/sentry/pull/116096) +- Anchor Editors dropdown to the right edge of the trigger by @skaasten in [#116104](https://github.com/getsentry/sentry/pull/116104) +- Reset table fields when switching from details widget by @DominikB2014 in [#115788](https://github.com/getsentry/sentry/pull/115788) +- Prevent sticky navbar misalignment on scroll by @priscilawebdev in [#115716](https://github.com/getsentry/sentry/pull/115716) + +#### Discover + +- Add missing check for DiscoverSavedQueryVisitEndpoint in [#116187](https://github.com/getsentry/sentry/pull/116187) +- Add org id to project filter by @nsdeschenes in [#116174](https://github.com/getsentry/sentry/pull/116174) + +#### Dynamic Sampling + +- Use the correct field name for dynamic sampling project id in [#116279](https://github.com/getsentry/sentry/pull/116279) +- Update run_eap_spans_table_query_in_chunks to yield individual rows and adjust tests accordingly by @constantinius in [#115995](https://github.com/getsentry/sentry/pull/115995) + +#### Events + +- Debug param wasn't being passed down correctly in [#116152](https://github.com/getsentry/sentry/pull/116152) +- Correctly parse full parameter in group hashes endpoint in [#116219](https://github.com/getsentry/sentry/pull/116219) + +#### Explore + +- Use unique ids for visuals in [#116204](https://github.com/getsentry/sentry/pull/116204) +- Cross events date selector allow 7d anytime within 30 days by @nikkikapadia in [#116099](https://github.com/getsentry/sentry/pull/116099) +- Increase strictness on URLs by @nsdeschenes in [#115881](https://github.com/getsentry/sentry/pull/115881) +- Pymark fail on test for arrays in detail endpoint by @manessaraj in [#115828](https://github.com/getsentry/sentry/pull/115828) + +#### Integrations + +- Validate user-provided IDs in webhooks by @kcons in [#115910](https://github.com/getsentry/sentry/pull/115910) +- Replace useIntegrationTabs with nuqs useQueryState by @ryan953 in [#115738](https://github.com/getsentry/sentry/pull/115738) + +#### Issues + +- Align collapsed activity row in [#116266](https://github.com/getsentry/sentry/pull/116266) +- Fix undefined variable in `StreamGroupSerializerSnuba` feature flag check in [#116259](https://github.com/getsentry/sentry/pull/116259) +- Move user serialization out of loop in ignored issues handler in [#116246](https://github.com/getsentry/sentry/pull/116246) +- Fix sidebar comment box horizontal overflow in [#116209](https://github.com/getsentry/sentry/pull/116209) +- Match short id when combined with filters in [#116153](https://github.com/getsentry/sentry/pull/116153) +- Make GroupSearchViewPermission fail closed for unknown object types by @roggenkemper in [#116183](https://github.com/getsentry/sentry/pull/116183) +- Provide correct value for `search.sort` SDK tag by @shashjar in [#116065](https://github.com/getsentry/sentry/pull/116065) +- Use full URL for open link button in breadcrumb messages by @scttcper in [#115911](https://github.com/getsentry/sentry/pull/115911) +- Enforce project access on event ID lookup endpoint by @oioki in [#115784](https://github.com/getsentry/sentry/pull/115784) +- Stop double-emitting issue activities for Seer PR created by @shashjar in [#115749](https://github.com/getsentry/sentry/pull/115749) +- Add int ID validation to a few endpoints by @kcons in [#115690](https://github.com/getsentry/sentry/pull/115690) +- Search org members for note mentions by @scttcper in [#115614](https://github.com/getsentry/sentry/pull/115614) + +#### Metrics + +- Resolve flaky metrics tab tests in [#116280](https://github.com/getsentry/sentry/pull/116280) +- Default to largest interval when using heatmaps visualization by @nikkikapadia in [#116129](https://github.com/getsentry/sentry/pull/116129) + +#### Monitors + +- Surface schedule config errors on cron form fields by @malwilley in [#116016](https://github.com/getsentry/sentry/pull/116016) +- Add tooltip for disabled project in edits by @JoshuaKGoldberg in [#115931](https://github.com/getsentry/sentry/pull/115931) + +#### Onboarding + +- Remove broken aria-label from RadioGroup radio inputs by @scttcper in [#116032](https://github.com/getsentry/sentry/pull/116032) +- Include shared feedback for Hono onbarding by @s1gr1d in [#115721](https://github.com/getsentry/sentry/pull/115721) + +#### Perforce + +- Update onboarding frontend for Unicode support by @mujacica in [#116005](https://github.com/getsentry/sentry/pull/116005) +- Support Unicode Perforce server connections by @mujacica in [#115775](https://github.com/getsentry/sentry/pull/115775) + +#### Preprod + +- Reduce snapshot download concurrency to prevent stream failures in [#116267](https://github.com/getsentry/sentry/pull/116267) +- Reapply "Include image key and field path in snapshot validation errors" by @runningcode in [#115987](https://github.com/getsentry/sentry/pull/115987) +- Remove native lazy loading from LazyImage component by @NicoHinderling in [#115922](https://github.com/getsentry/sentry/pull/115922) +- Eliminate race condition in snapshot status check posting by @NicoHinderling in [#115650](https://github.com/getsentry/sentry/pull/115650) +- Skip strict jsonschema for snapshot image metadata by @runningcode in [#115720](https://github.com/getsentry/sentry/pull/115720) +- Restore extra field passthrough in snapshot image responses by @NicoHinderling in [#115658](https://github.com/getsentry/sentry/pull/115658) +- Change snapshot image tags from list to dict by @NicoHinderling in [#115643](https://github.com/getsentry/sentry/pull/115643) + +#### Replays + +- Shrink timeline hover timestamp in [#116268](https://github.com/getsentry/sentry/pull/116268) +- Remove timeline icon z-index workaround in [#116255](https://github.com/getsentry/sentry/pull/116255) +- Remove extra padding from BodyGrid in replayLayout by @sentry-junior in [#116156](https://github.com/getsentry/sentry/pull/116156) +- Disable breadcrumbs autoscroll on user scroll by @JoshuaKGoldberg in [#115914](https://github.com/getsentry/sentry/pull/115914) +- Correct query invalidation on refresh by @JoshuaKGoldberg in [#115629](https://github.com/getsentry/sentry/pull/115629) +- Allow org admins to bulk delete replays by @jameskeane in [#115886](https://github.com/getsentry/sentry/pull/115886) +- Make link copy button accessible and non-variable width by @JoshuaKGoldberg in [#115598](https://github.com/getsentry/sentry/pull/115598) + +#### Search + +- Prevent Ask AI from doubling pasted query text in [#116050](https://github.com/getsentry/sentry/pull/116050) +- Hide size limit prompt while filtering by @nsdeschenes in [#115816](https://github.com/getsentry/sentry/pull/115816) + +#### Seer + +- Sort autofix project table by slug instead of name by @mrduncan in [#115642](https://github.com/getsentry/sentry/pull/115642) +- Keep repo loading indicator active by @scttcper in [#115854](https://github.com/getsentry/sentry/pull/115854) +- Pass issue short ID to coding agents by @JoshFerge in [#115838](https://github.com/getsentry/sentry/pull/115838) +- Make ToolResult.content optional to prevent Pydantic validation error by @sentry in [#115630](https://github.com/getsentry/sentry/pull/115630) + +#### Settings + +- Fix CI permission checkbox not reflecting state by @scttcper in [#116055](https://github.com/getsentry/sentry/pull/116055) +- Restore title on accept-invite and accept-transfer pages by @natemoo-re in [#116013](https://github.com/getsentry/sentry/pull/116013) +- Fix Seer drawer stopping point not changing on mutate from "No Automation" by @srest2021 in [#115847](https://github.com/getsentry/sentry/pull/115847) + +#### Snapshots + +- Add instrumentation logging to snapshot download stream in [#116079](https://github.com/getsentry/sentry/pull/116079) +- Add timeout override for snapshot download in emmett gateway by @NicoHinderling in [#116078](https://github.com/getsentry/sentry/pull/116078) + +#### Tests + +- Don't include trace context in symbolicator snapshots in [#116275](https://github.com/getsentry/sentry/pull/116275) +- Use findByRole for async options in opJsonPath.spec.tsx by @sentry in [#115645](https://github.com/getsentry/sentry/pull/115645) +- Correct monitor form crontab test with fireEvent by @sentry in [#115644](https://github.com/getsentry/sentry/pull/115644) +- Update staleTime and add default mocks for external issue tests by @sentry in [#115646](https://github.com/getsentry/sentry/pull/115646) + +#### Tracemetrics + +- Use equation alias format for widget builder in [#116213](https://github.com/getsentry/sentry/pull/116213) +- Expand selector dropdown menu width to 100% by @narsaynorath in [#116026](https://github.com/getsentry/sentry/pull/116026) +- Drop placeholder unit and always use none by @narsaynorath in [#116007](https://github.com/getsentry/sentry/pull/116007) +- Pass project and env in request filters for filter by @narsaynorath in [#115920](https://github.com/getsentry/sentry/pull/115920) + +#### Ui + +- Add inset focus ring to SimpleTable header cells in [#116276](https://github.com/getsentry/sentry/pull/116276) +- Increase dropdown z-index to appear above sidebar by @jameskeane in [#116139](https://github.com/getsentry/sentry/pull/116139) +- Add self signed package to support https by @scttcper in [#115941](https://github.com/getsentry/sentry/pull/115941) + +#### Workflow Engine + +- Sanitize corrupted dynamic_form_fields choice labels by @malwilley in [#115855](https://github.com/getsentry/sentry/pull/115855) +- Normalize error.handled values to 0/1 by @kcons in [#115740](https://github.com/getsentry/sentry/pull/115740) + +#### Other + +- (a11y) Add missing alt attributes to context icons and feedback images by @sentry-junior in [#115772](https://github.com/getsentry/sentry/pull/115772) +- (agents) Use minVersion in SDK update alert for consistency by @obostjancic in [#115714](https://github.com/getsentry/sentry/pull/115714) +- (api-docs) Correct event/replay/processing-error ID schemas in [#116201](https://github.com/getsentry/sentry/pull/116201) +- (apigw) Disable asyncpg statement cache (issues with pgbouncer) by @gi0baro in [#115992](https://github.com/getsentry/sentry/pull/115992) +- (attachments) Infer MIME type from filename when stored as octet-stream by @sentry-junior in [#115977](https://github.com/getsentry/sentry/pull/115977) +- (auth) Verify primary email on password reset by @michelletran-sentry in [#115651](https://github.com/getsentry/sentry/pull/115651) +- (autofix) Prevent loading spinner clip in artifact loading card by @priscilawebdev in [#115988](https://github.com/getsentry/sentry/pull/115988) +- (billing) Added fix to convert snuba sentry enum to the proto enum for usage stats by @krithikravi in [#115856](https://github.com/getsentry/sentry/pull/115856) +- (code-mapping) Update codeowners GET endpoint and tests in [#116309](https://github.com/getsentry/sentry/pull/116309) +- (codeblock) Improve nested scroll by @natemoo-re in [#115839](https://github.com/getsentry/sentry/pull/115839) +- (crons) De-flake "prefills with an existing monitor" test by @priscilawebdev in [#115782](https://github.com/getsentry/sentry/pull/115782) +- (cross-events) Correct styling based off date selection by @nsdeschenes in [#116124](https://github.com/getsentry/sentry/pull/116124) +- (cursored-scheduler) Recalculate batch size on tick interval change by @roggenkemper in [#115888](https://github.com/getsentry/sentry/pull/115888) +- (data_export) Cap export row limit at 10k for all callers by @manessaraj in [#116048](https://github.com/getsentry/sentry/pull/116048) +- (escalating) Register issue_velocity referrer in Referrer enum by @cvxluo in [#115812](https://github.com/getsentry/sentry/pull/115812) +- (feedback) Downgrade log level for insufficient feedback count in [#116247](https://github.com/getsentry/sentry/pull/116247) +- (forms) Preserve choice value types when submitting sentry app forms by @priscilawebdev in [#115869](https://github.com/getsentry/sentry/pull/115869) +- (grouping) Parameterize error message fingerprint variables by @lobsterkatie in [#115496](https://github.com/getsentry/sentry/pull/115496) +- (issue search) Fix invalid search query error message for device classes in [#116243](https://github.com/getsentry/sentry/pull/116243) +- (issue-detection) Add plural KBLayouts_iPhone.dat to FileIO ignore list by @roggenkemper in [#116182](https://github.com/getsentry/sentry/pull/116182) +- (jira) Bind JWT iss to body clientKey on install webhook by @michelletran-sentry in [#114225](https://github.com/getsentry/sentry/pull/114225) +- (kafkapublisher) Leaks memory: rdkafka stats grow without poll() in [#116123](https://github.com/getsentry/sentry/pull/116123) +- (members) Scope invite-request role updates to caller's allowed roles by @oioki in [#115807](https://github.com/getsentry/sentry/pull/115807) +- (migrations) Get rid of progress bar by @ceorourke in [#115691](https://github.com/getsentry/sentry/pull/115691) +- (mypy) Fix import location by @kcons in [#115654](https://github.com/getsentry/sentry/pull/115654) +- (ourlogs) Reset column sort to default on third click by @JoshuaKGoldberg in [#115751](https://github.com/getsentry/sentry/pull/115751) +- (pageFilters) Clear shift-click anchor on empty selection by @priscilawebdev in [#115472](https://github.com/getsentry/sentry/pull/115472) +- (profiles) Indicate invalid page URL state as error by @JoshuaKGoldberg in [#115897](https://github.com/getsentry/sentry/pull/115897) +- (profiling) Render single-sample continuous profile chunks in [#116234](https://github.com/getsentry/sentry/pull/116234) +- (rate-limit) Tighten rate limits on test notification endpoints by @nora-shap in [#115613](https://github.com/getsentry/sentry/pull/115613) +- (ratelimits) Handle AnonymousUser missing is_sentry_app attribute in [#116251](https://github.com/getsentry/sentry/pull/116251) +- (relay) Make trustedRelays optional on Organization type by @TkDodo in [#116014](https://github.com/getsentry/sentry/pull/116014) +- (releases) Pass Environment objects to get_latest_release by @mrduncan in [#115637](https://github.com/getsentry/sentry/pull/115637) +- (repositories) Fix deletion ordering for ProjectRepository children by @wedamija in [#115739](https://github.com/getsentry/sentry/pull/115739) +- (security) Add project-level access check to GroupEventJsonView by @roggenkemper in [#116184](https://github.com/getsentry/sentry/pull/116184) +- (self-hosted) Avoid install wizard mail TLS/SSL immutable errors by @aldy505 in [#114011](https://github.com/getsentry/sentry/pull/114011) +- (static) Add missing nonce attribute on app.js preload link by @oioki in [#115984](https://github.com/getsentry/sentry/pull/115984) +- (supergroups) Move to post process task in [#116195](https://github.com/getsentry/sentry/pull/116195) +- (tabs) Stop tooltips in overflowMenuItems from crashing the page by @TkDodo in [#115993](https://github.com/getsentry/sentry/pull/115993) +- (traces) Handle deleted groups in trace endpoint in [#116248](https://github.com/getsentry/sentry/pull/116248) +- (web) Redirect /scraps to stories by @priscilawebdev in [#115776](https://github.com/getsentry/sentry/pull/115776) +- (webauthn) Handle missing WebAuthn challenge data in [#116167](https://github.com/getsentry/sentry/pull/116167) +- (webhooks) Route sentry app actions through send_alert_webhook_v2 in new path in [#115975](https://github.com/getsentry/sentry/pull/115975) +- (workflow) Use Group cache in get_group_to_groupevent by @kcons in [#115960](https://github.com/getsentry/sentry/pull/115960) +- (workflows) Filter out workflows from other organizations in [#116075](https://github.com/getsentry/sentry/pull/116075) +- Add catch-all path to explore route and redirect to index by @adrianviquez in [#116066](https://github.com/getsentry/sentry/pull/116066) +- Revert "fix(ourlogs): stabilized column widths during scrolling (#115389)" by @getsentry-bot in [84d0139e](https://github.com/getsentry/sentry/commit/84d0139e1cc325da0c0e75380bc7dc3099c5f400) + +### Documentation 📚 + +- (replays) Fix OpenAPI schema/example for replay details response by @JoshFerge in [#115752](https://github.com/getsentry/sentry/pull/115752) +- (scraps) Render to HTML pattern by @natemoo-re in [#115943](https://github.com/getsentry/sentry/pull/115943) +- (snapshots) Add public OpenAPI documentation for snapshot endpoints in [#116231](https://github.com/getsentry/sentry/pull/116231) + +### Internal Changes 🔧 + +#### Admin + +- Migrate forkCustomer off browserHistory by @evanpurkhiser in [#115915](https://github.com/getsentry/sentry/pull/115915) +- Drop browserHistory and HOCs from ResultGrid by @evanpurkhiser in [#115908](https://github.com/getsentry/sentry/pull/115908) + +#### Alerts + +- Clean up usage of AlertRuleSerializerResponse in [#116218](https://github.com/getsentry/sentry/pull/116218) +- Remove AlertRuleSerializer in [#116052](https://github.com/getsentry/sentry/pull/116052) +- Remove PUT and POST legacy paths for metric alerts by @ceorourke in [#116017](https://github.com/getsentry/sentry/pull/116017) +- Fully remove metric alert columns on NotificationMessage by @ceorourke in [#116025](https://github.com/getsentry/sentry/pull/116025) +- Remove legacy issue alert delete endpoint code by @ceorourke in [#115954](https://github.com/getsentry/sentry/pull/115954) +- Add index on date_added, soft remove metric alert colu… by @ceorourke in [#115823](https://github.com/getsentry/sentry/pull/115823) +- Remove legacy issue alert GET endpoint code by @ceorourke in [#115948](https://github.com/getsentry/sentry/pull/115948) +- Migrate issue rule editor off browserHistory by @evanpurkhiser in [#115924](https://github.com/getsentry/sentry/pull/115924) +- Remove legacy metric alerts code by @ceorourke in [#115865](https://github.com/getsentry/sentry/pull/115865) +- Remove incident serializer usages by @ceorourke in [#115845](https://github.com/getsentry/sentry/pull/115845) +- Remove legacy metric alert handlers by @ceorourke in [#115850](https://github.com/getsentry/sentry/pull/115850) +- Remove metric alert columns on NotificationMessage by @ceorourke in [#115578](https://github.com/getsentry/sentry/pull/115578) +- Replace AlertStore with GlobalAlertProvider + useGlobalAlerts by @evanpurkhiser in [#115315](https://github.com/getsentry/sentry/pull/115315) +- Clean up old metric alert rows in NotificationMessage by @ceorourke in [#115647](https://github.com/getsentry/sentry/pull/115647) +- Remove unused team alerts endpoints by @ceorourke in [#115339](https://github.com/getsentry/sentry/pull/115339) +- Remove team alerts triggered modal by @ceorourke in [#115336](https://github.com/getsentry/sentry/pull/115336) + +#### Api + +- Type nullable fields in the base group serializer by @cvxluo in [#116068](https://github.com/getsentry/sentry/pull/116068) +- Move `GroupEventDetailsResponse` to event serializer module by @cvxluo in [#116058](https://github.com/getsentry/sentry/pull/116058) +- Resolve suggested_api from Django route names by @strongs in [#115907](https://github.com/getsentry/sentry/pull/115907) +- Migrate auth-error navigation off browserHistory by @evanpurkhiser in [#115935](https://github.com/getsentry/sentry/pull/115935) +- Move to_valid_int_id to a more central location by @kcons in [#115581](https://github.com/getsentry/sentry/pull/115581) + +#### Apigw + +- Add `abort_with_json` as an util, allow config httpx client limits by @gi0baro in [#116037](https://github.com/getsentry/sentry/pull/116037) +- Enhance proxy implementation by @gi0baro in [#115892](https://github.com/getsentry/sentry/pull/115892) + +#### Autofix + +- Remove SCM requirement from autofix in [#116206](https://github.com/getsentry/sentry/pull/116206) +- Remove legacy autofix path from GroupAutofixEndpoint by @chromy in [#116164](https://github.com/getsentry/sentry/pull/116164) +- Always use explorer mode in GroupAutofixEndpoint by @chromy in [#116162](https://github.com/getsentry/sentry/pull/116162) +- Remove old useAutofixData hook by @Zylphrex in [#116103](https://github.com/getsentry/sentry/pull/116103) +- Remove intelligence level from group ai autofix endpoint by @Zylphrex in [#116145](https://github.com/getsentry/sentry/pull/116145) +- Add log for autofix introspection reason by @Zylphrex in [#116132](https://github.com/getsentry/sentry/pull/116132) +- Remove unused autofix v1 UI by @Zylphrex in [#116100](https://github.com/getsentry/sentry/pull/116100) +- Use new Markdown primitive in v3 cards by @priscilawebdev in [#115879](https://github.com/getsentry/sentry/pull/115879) +- Check repo connected before starting autofix by @Zylphrex in [#115648](https://github.com/getsentry/sentry/pull/115648) + +#### Conversations + +- Adopt scraps primitives for 4 wrappers by @priscilawebdev in [#116082](https://github.com/getsentry/sentry/pull/116082) +- Default to 24h period in sidebar link by @obostjancic in [#115873](https://github.com/getsentry/sentry/pull/115873) + +#### Dashboards + +- Remove text widget flag defintion in [#116212](https://github.com/getsentry/sentry/pull/116212) +- Remove text widget flag references frontend in [#116210](https://github.com/getsentry/sentry/pull/116210) +- Remove text widget flag references backend in [#116207](https://github.com/getsentry/sentry/pull/116207) +- Migrate utils.tsx off browserHistory by @evanpurkhiser in [#115923](https://github.com/getsentry/sentry/pull/115923) +- Migrate detail.tsx off browserHistory to useNavigate by @evanpurkhiser in [#115903](https://github.com/getsentry/sentry/pull/115903) + +#### Discover + +- Migrate fieldRenderers off browserHistory by @evanpurkhiser in [#115938](https://github.com/getsentry/sentry/pull/115938) +- Migrate transactionsList off browserHistory by @evanpurkhiser in [#115926](https://github.com/getsentry/sentry/pull/115926) +- Migrate queryList off browserHistory by @evanpurkhiser in [#115913](https://github.com/getsentry/sentry/pull/115913) +- Migrate savedQuery off browserHistory by @evanpurkhiser in [#115912](https://github.com/getsentry/sentry/pull/115912) +- Migrate results.tsx off browserHistory by @evanpurkhiser in [#115909](https://github.com/getsentry/sentry/pull/115909) + +#### Dynamic Sampling + +- In per org pipeline, retrieve the project ids in config retrieval, just once by @shellmayr in [#115983](https://github.com/getsentry/sentry/pull/115983) +- Use already queried data when computing boosted release platform by @cmanallen in [#115792](https://github.com/getsentry/sentry/pull/115792) +- Rename dynamic sampling status enum by @shellmayr in [#115360](https://github.com/getsentry/sentry/pull/115360) +- Cleanup transaction based health check rule by @shellmayr in [#115471](https://github.com/getsentry/sentry/pull/115471) +- Add status for snuba timeouts by @shellmayr in [#115359](https://github.com/getsentry/sentry/pull/115359) + +#### Eslint + +- Turn on no-unsafe-member-access for scraps in [#116004](https://github.com/getsentry/sentry/pull/116004) +- Add curly rule to prettier config section by @sentry-junior in [#116158](https://github.com/getsentry/sentry/pull/116158) +- Enable no-unsafe-call for scraps by @TkDodo in [#115981](https://github.com/getsentry/sentry/pull/115981) +- Enable no-unsafe-arguments in scraps by @TkDodo in [#115877](https://github.com/getsentry/sentry/pull/115877) +- Enable no-unsafe-return for scraps by @TkDodo in [#115722](https://github.com/getsentry/sentry/pull/115722) + +#### Flags + +- Remove organizations:dashboards-drilldown-flow in [#115670](https://github.com/getsentry/sentry/pull/115670) +- Remove organizations:scoped-partner-oauth by @wedamija in [#115675](https://github.com/getsentry/sentry/pull/115675) +- Remove organizations:dashboards-import by @wedamija in [#115671](https://github.com/getsentry/sentry/pull/115671) +- Remove organizations:revoke-org-auth-on-slug-rename by @wedamija in [#114807](https://github.com/getsentry/sentry/pull/114807) +- Remove organizations:tracemetrics-alerts gates (backend) by @wedamija in [#115019](https://github.com/getsentry/sentry/pull/115019) +- Remove organizations:workflow-engine-metric-alert-group-by-creation by @wedamija in [#114805](https://github.com/getsentry/sentry/pull/114805) +- Remove organizations:ourlogs-stats, replace with `organizations:explore-dev-features` and move it to a permanent flag by @wedamija in [#115673](https://github.com/getsentry/sentry/pull/115673) +- Remove organizations:tracemetrics-alerts gates (frontend) by @wedamija in [#115018](https://github.com/getsentry/sentry/pull/115018) +- Remove organizations:performance-mep-reintroduce-histograms by @wedamija in [#115674](https://github.com/getsentry/sentry/pull/115674) +- Remove organizations:ingest-through-trusted-relays-only by @wedamija in [#115682](https://github.com/getsentry/sentry/pull/115682) +- Remove organizations:pr-page by @wedamija in [#115686](https://github.com/getsentry/sentry/pull/115686) +- Remove organizations:performance-remove-metrics-compatibility-fallback by @wedamija in [#115684](https://github.com/getsentry/sentry/pull/115684) +- Remove organizations:performance-transaction-name-only-search by @wedamija in [#115685](https://github.com/getsentry/sentry/pull/115685) +- Remove organizations:starfish-mobile-ui-module by @wedamija in [#115687](https://github.com/getsentry/sentry/pull/115687) +- Move organizations:init-sentry-toolbar to permanent by @wedamija in [#115862](https://github.com/getsentry/sentry/pull/115862) +- Remove organizations:on-demand-metrics-extraction-experimental by @wedamija in [#115683](https://github.com/getsentry/sentry/pull/115683) +- Remove organizations:view-hierarchies-options-dev by @wedamija in [#115678](https://github.com/getsentry/sentry/pull/115678) +- Remove organizations:issues-suspect-tags by @wedamija in [#115680](https://github.com/getsentry/sentry/pull/115680) +- Remove organizations:performance-spans-fields-stats by @wedamija in [#115679](https://github.com/getsentry/sentry/pull/115679) +- Remove organizations:update-action-status by @wedamija in [#115676](https://github.com/getsentry/sentry/pull/115676) +- Remove organizations:sentry-app-webhook-requests by @wedamija in [#114813](https://github.com/getsentry/sentry/pull/114813) + +#### Forms + +- Migrate projectFiltersSettings to scraps form system by @TkDodo in [#115783](https://github.com/getsentry/sentry/pull/115783) +- Migrate highlights settings by @priscilawebdev in [#115778](https://github.com/getsentry/sentry/pull/115778) +- Migrate early features settings by @priscilawebdev in [#115777](https://github.com/getsentry/sentry/pull/115777) +- Migrate keyRateLimitsForm off legacy Form by @priscilawebdev in [#115265](https://github.com/getsentry/sentry/pull/115265) +- Migrate addCodeOwnerModal off legacy Form by @priscilawebdev in [#115256](https://github.com/getsentry/sentry/pull/115256) + +#### Instrumentation Issues + +- Remove issue type config and types by @ArthurKnaus in [#115718](https://github.com/getsentry/sentry/pull/115718) +- Remove fix section UI by @ArthurKnaus in [#115717](https://github.com/getsentry/sentry/pull/115717) +- Remove nav entries and route by @ArthurKnaus in [#115715](https://github.com/getsentry/sentry/pull/115715) + +#### Issues + +- Use standard logging pattern in group details endpoint in [#116262](https://github.com/getsentry/sentry/pull/116262) +- Remove redundant check on `event_id` in [#116261](https://github.com/getsentry/sentry/pull/116261) +- Indicate duration when "Since First Seen" is selected in [#115533](https://github.com/getsentry/sentry/pull/115533) +- Remove grouping store by @scttcper in [#115970](https://github.com/getsentry/sentry/pull/115970) +- Remove the option gating custom tag resolver logic by @shashjar in [#116024](https://github.com/getsentry/sentry/pull/116024) +- Add multiple property to select field schema by @amy-chen23 in [#115814](https://github.com/getsentry/sentry/pull/115814) +- Prevent assigning issues to deactivated users by @amy-chen23 in [#115668](https://github.com/getsentry/sentry/pull/115668) +- Update frontend types after removing unnecessary issue activity metadata for Seer actions by @shashjar in [#115734](https://github.com/getsentry/sentry/pull/115734) +- Remove unnecessary structured metadata under issue activities for Seer actions by @shashjar in [#115732](https://github.com/getsentry/sentry/pull/115732) +- Remove stray `use_flagpole_for_all_features` usage by @lobsterkatie in [#115537](https://github.com/getsentry/sentry/pull/115537) + +#### Jest + +- Mark flaky jest tests - 2026-05-25 by @cursor in [#116121](https://github.com/getsentry/sentry/pull/116121) +- Mark flaky jest tests - 2026-05-18 by @cursor in [#115729](https://github.com/getsentry/sentry/pull/115729) + +#### Onboarding + +- Convert CreateSampleEventButton to functional component by @ryan953 in [#115830](https://github.com/getsentry/sentry/pull/115830) +- Adopt useModal in onboarding flows by @evanpurkhiser in [#115127](https://github.com/getsentry/sentry/pull/115127) + +#### Ourlogs + +- Remove `expanded` and window virtualizer from LogsInfiniteTable by @JoshuaKGoldberg in [#115884](https://github.com/getsentry/sentry/pull/115884) +- Remove ourlogs-table-expando flag backend code by @JoshuaKGoldberg in [#115794](https://github.com/getsentry/sentry/pull/115794) +- Remove ourlogs-table-expando flag frontend code by @JoshuaKGoldberg in [#115793](https://github.com/getsentry/sentry/pull/115793) + +#### Preprod + +- Simplify project filtering in latest base snapshot endpoint in [#116237](https://github.com/getsentry/sentry/pull/116237) +- Optimize snapshot download with connection reuse and progressive streaming by @NicoHinderling in [#116051](https://github.com/getsentry/sentry/pull/116051) +- Use TimeToIdle instead of TimeToLive for upload expiration by @NicoHinderling in [#116033](https://github.com/getsentry/sentry/pull/116033) +- Virtualize snapshot sidebar for 40k image builds by @NicoHinderling in [#115836](https://github.com/getsentry/sentry/pull/115836) +- Replace snapshot status badges with plain text by @mtopo27 in [#115659](https://github.com/getsentry/sentry/pull/115659) +- Remove deprecated snapshot detail TS types and update debug modal by @mtopo27 in [#115653](https://github.com/getsentry/sentry/pull/115653) +- Remove deprecated comparison_run_info and approval_info from snapshot detail API by @mtopo27 in [#115652](https://github.com/getsentry/sentry/pull/115652) + +#### Replays + +- Remove unused data export notifications endpoint in [#116232](https://github.com/getsentry/sentry/pull/116232) +- Replace useFetchSequentialPages with useInfiniteQuery by @ryan953 in [#116115](https://github.com/getsentry/sentry/pull/116115) +- Use shared platform icon resolver by @priscilawebdev in [#115705](https://github.com/getsentry/sentry/pull/115705) + +#### Repositories + +- Simplify ProjectRepoLink serializer and make url better by @wedamija in [#115826](https://github.com/getsentry/sentry/pull/115826) +- Drop old project/repository columns by @wedamija in [#115741](https://github.com/getsentry/sentry/pull/115741) +- Remove `project` and `repo` columns from `SeerProjectRepository` and `RepositoryProjectPathConfig` by @wedamija in [#115663](https://github.com/getsentry/sentry/pull/115663) +- Add unique index on `repository_project` columns by @wedamija in [#115662](https://github.com/getsentry/sentry/pull/115662) +- Remove feature flag branching for RepositoryProjectPathConfig reads by @wedamija in [#115607](https://github.com/getsentry/sentry/pull/115607) +- Remove feature flag branching for SeerProjectRepository reads by @wedamija in [#115606](https://github.com/getsentry/sentry/pull/115606) + +#### Scm + +- Merge integration-proxy endpoints by @cmanallen in [#116028](https://github.com/getsentry/sentry/pull/116028) +- Add quota policy for GitHub API requests by @cmanallen in [#115657](https://github.com/getsentry/sentry/pull/115657) + +#### Seer + +- Move agent access check from entrypoint to operator in [#116143](https://github.com/getsentry/sentry/pull/116143) +- Use `elif` instead of `if` in actionability filter logic for clarity in [#116203](https://github.com/getsentry/sentry/pull/116203) +- Remove seer-slack-workflows and seer-slack-explorer flags in [#116140](https://github.com/getsentry/sentry/pull/116140) +- Simplify block component states by @natemoo-re in [#115589](https://github.com/getsentry/sentry/pull/115589) +- Persist Seer Explorer input draft per run by @aliu39 in [#115919](https://github.com/getsentry/sentry/pull/115919) +- Replace chat history dropdown with searchable CompactSelect by @JonasBa in [#115843](https://github.com/getsentry/sentry/pull/115843) +- Rm severity group-seer option by @kddubey in [#115768](https://github.com/getsentry/sentry/pull/115768) +- Rm severity conditional routing by @kddubey in [#115765](https://github.com/getsentry/sentry/pull/115765) +- Option to route severity to group-seer by @kddubey in [#115702](https://github.com/getsentry/sentry/pull/115702) + +#### Settings + +- Update `action` prop and remove `hasPageFrame` by @natemoo-re in [#115815](https://github.com/getsentry/sentry/pull/115815) +- Update breadcrumbTitle spec for routes prop removal by @ryan953 in [#115866](https://github.com/getsentry/sentry/pull/115866) +- Move routes from prop to useRoutes() in BreadcrumbTitle by @ryan953 in [#115766](https://github.com/getsentry/sentry/pull/115766) +- Convert OrganizationAccessRequests to function component with fetchMutation by @ryan953 in [#115813](https://github.com/getsentry/sentry/pull/115813) +- Replace billing navigation config with a react-hook by @evanpurkhiser in [#115808](https://github.com/getsentry/sentry/pull/115808) + +#### Slack + +- Remove widget unfurl feature flags by @DominikB2014 in [#116128](https://github.com/getsentry/sentry/pull/116128) +- Move ephemeral message sending to workspace module by @leeandher in [#115586](https://github.com/getsentry/sentry/pull/115586) + +#### Snuba + +- Port query subscriptions consumer to taskbroker raw mode in [#116288](https://github.com/getsentry/sentry/pull/116288) +- Update tests for removal of boolean double-writing in [#111421](https://github.com/getsentry/sentry/pull/111421) +- Stop dropping deprecated spans dataset in reset_snuba by @phacops in [#115973](https://github.com/getsentry/sentry/pull/115973) +- Add exception type for snuba timeouts by @shellmayr in [#115362](https://github.com/getsentry/sentry/pull/115362) + +#### Spans + +- Remove tests for deprecated standalone spans storage in [#116147](https://github.com/getsentry/sentry/pull/116147) +- Extract flush_segment pipeline helpers by @lvthanh03 in [#116149](https://github.com/getsentry/sentry/pull/116149) +- Split load_segment_data into helper steps by @lvthanh03 in [#116136](https://github.com/getsentry/sentry/pull/116136) +- Split process_spans into typed pipeline steps by @lvthanh03 in [#115858](https://github.com/getsentry/sentry/pull/115858) +- Add back cumulative flusher log and flushed segments log by @lvthanh03 in [#116015](https://github.com/getsentry/sentry/pull/116015) +- Extract span buffer observability models by @lvthanh03 in [#115849](https://github.com/getsentry/sentry/pull/115849) +- Remove unused dropped_segments logic and zrem cleanup option by @lvthanh03 in [#115806](https://github.com/getsentry/sentry/pull/115806) +- Add isolated load segment data coverage by @lvthanh03 in [#115804](https://github.com/getsentry/sentry/pull/115804) +- Add add-buffer Lua script tests by @lvthanh03 in [#115801](https://github.com/getsentry/sentry/pull/115801) + +#### Ts + +- Remove RouteComponent by @evanpurkhiser in [#115999](https://github.com/getsentry/sentry/pull/115999) +- Remove unused RouteContextInterface type by @evanpurkhiser in [#115996](https://github.com/getsentry/sentry/pull/115996) + +#### Typing + +- Remove `tests.sentry.api.helpers.test_group_index` from mypy ignore list in [#116199](https://github.com/getsentry/sentry/pull/116199) +- Remove `tests.sentry.issues.test_utils` from mypy ignore list in [#116070](https://github.com/getsentry/sentry/pull/116070) + +#### Utils + +- Make ParityChecker print out mismatches in a PII safe way in [#116038](https://github.com/getsentry/sentry/pull/116038) +- Various clarifications in `SafeRolloutComparator` code in [#115946](https://github.com/getsentry/sentry/pull/115946) + +#### Workflow Engine + +- Remove unused const in [#116230](https://github.com/getsentry/sentry/pull/116230) +- Edit flag with the correct prefix in [#116198](https://github.com/getsentry/sentry/pull/116198) + +#### Other + +- (✂️) Remove form leftovers by @TkDodo in [#115724](https://github.com/getsentry/sentry/pull/115724) +- (aci) Minor cleanup to delayed workflow processing by @saponifi3d in [#115758](https://github.com/getsentry/sentry/pull/115758) +- (activity) Remove duplicate call to calculate initial priority from group metadata by @shashjar in [#116067](https://github.com/getsentry/sentry/pull/116067) +- (api-docs) Add GroupDetailsResponse type, params, and example in [#116113](https://github.com/getsentry/sentry/pull/116113) +- (autopilot) Delete autopilot module and all references by @vgrozdanic in [#115466](https://github.com/getsentry/sentry/pull/115466) +- (billing) Bump sentry-protos to 0.13.0 in [#116133](https://github.com/getsentry/sentry/pull/116133) +- (billing-platform) Log requests in service methods by @brendanhsentry in [#115971](https://github.com/getsentry/sentry/pull/115971) +- (bootstrap) Parallelize locale and moment chunk fetches by @JonasBa in [#115727](https://github.com/getsentry/sentry/pull/115727) +- (cells) Remove the includeFeatureFlags query param from the org listing request by @lynnagara in [#115833](https://github.com/getsentry/sentry/pull/115833) +- (ci) Split MDX typechecking into its own gated job by @natemoo-re in [#115744](https://github.com/getsentry/sentry/pull/115744) +- (compactSelect) Remove unused onSectionToggle callback by @TkDodo in [#115809](https://github.com/getsentry/sentry/pull/115809) +- (deps) Update sentry conventions package by @nsdeschenes in [#115989](https://github.com/getsentry/sentry/pull/115989) +- (detectors) Split connected and project alerts into separate sections by @malwilley in [#115947](https://github.com/getsentry/sentry/pull/115947) +- (dynamic-ampling) Add a metric counter to see if we sometimes have implicit-factor < 1 by @constantinius in [#115834](https://github.com/getsentry/sentry/pull/115834) +- (eap) Query typed-colon attribute as boolean instead of number in [#116299](https://github.com/getsentry/sentry/pull/116299) +- (events) Migrate ContextIcon to platformicons by @priscilawebdev in [#115701](https://github.com/getsentry/sentry/pull/115701) +- (explore) Port toolTags to scraps layout primitives by @priscilawebdev in [#116160](https://github.com/getsentry/sentry/pull/116160) +- (flagpole-wildcard-ops) Adding support for not_matches op (python) by @Abdkhan14 in [#115901](https://github.com/getsentry/sentry/pull/115901) +- (github-enterprise) Use monospace font for private key field in [#116303](https://github.com/getsentry/sentry/pull/116303) +- (hooks) Replace HookStore with a plain hook registry by @evanpurkhiser in [#115811](https://github.com/getsentry/sentry/pull/115811) +- (hookStore) Change HookStore to single-value semantics by @evanpurkhiser in [#115796](https://github.com/getsentry/sentry/pull/115796) +- (integrations) Add backfill_github_external_actor.gh_api_fetch_interval_s by @hobzcalvin in [#115763](https://github.com/getsentry/sentry/pull/115763) +- (issueDetails) Collapse ParticipantList wrapper div to a Flex by @evanpurkhiser in [#116175](https://github.com/getsentry/sentry/pull/116175) +- (issueDiff) Refactor event data fetching to use useQueries in [#116042](https://github.com/getsentry/sentry/pull/116042) +- (jira) Add Forge app manifest for Connect-to-Forge migration by @BYK in [#115603](https://github.com/getsentry/sentry/pull/115603) +- (lint) Ban React.Fragment in favor of named Fragment import by @natemoo-re in [#115939](https://github.com/getsentry/sentry/pull/115939) +- (metrics) Split metric attribute tree actions by @nsdeschenes in [#115641](https://github.com/getsentry/sentry/pull/115641) +- (mypy) Rename sort_stronger_modules to sort_weaklist in [#116106](https://github.com/getsentry/sentry/pull/116106) +- (np) Refactors notification context into a new class by @GabeVillalobos in [#113495](https://github.com/getsentry/sentry/pull/113495) +- (organization-create) Drop dead browserHistory comment by @evanpurkhiser in [#115928](https://github.com/getsentry/sentry/pull/115928) +- (overrides) Finish hook → override terminology rename by @evanpurkhiser in [#115825](https://github.com/getsentry/sentry/pull/115825) +- (oxfmt) Ignore pyproject.toml by @sentry-junior in [#116181](https://github.com/getsentry/sentry/pull/116181) +- (pipeline) Use Button busy prop for advancing state by @evanpurkhiser in [#116179](https://github.com/getsentry/sentry/pull/116179) +- (plugins) Inline PluginComponentBase into its two subclasses by @ryan953 in [#116112](https://github.com/getsentry/sentry/pull/116112) +- (profiling) Rename explore/profiling URL to explore/profiles in [#115627](https://github.com/getsentry/sentry/pull/115627) +- (project-detail) Migrate projectCharts off browserHistory by @evanpurkhiser in [#115916](https://github.com/getsentry/sentry/pull/115916) +- (releases) Convert ReleaseIssues to functional component by @ryan953 in [#115698](https://github.com/getsentry/sentry/pull/115698) +- (replay) Rename Breadcrumbs tab to Activity by @DominikB2014 in [#115278](https://github.com/getsentry/sentry/pull/115278) +- (routeAnalytics) Replace HookStore persistCallback with a plain module cell by @evanpurkhiser in [#115810](https://github.com/getsentry/sentry/pull/115810) +- (saved-queries) Align list endpoint access checks with detail by @oioki in [#115379](https://github.com/getsentry/sentry/pull/115379) +- (scraps) Adopt useModal in remaining call sites by @evanpurkhiser in [#115132](https://github.com/getsentry/sentry/pull/115132) +- (search) Add EAP API attribute visibility checks in [#116091](https://github.com/getsentry/sentry/pull/116091) +- (seer-explorer) Replace useSeerExplorerRunId with chat state context by @JonasBa in [#115631](https://github.com/getsentry/sentry/pull/115631) +- (segments) Add local cache for release creation and modification by @cmanallen in [#116173](https://github.com/getsentry/sentry/pull/116173) +- (snapshots) Batch image fetches and add timeouts for snapshot download by @NicoHinderling in [#116076](https://github.com/getsentry/sentry/pull/116076) +- (source-map-processing-errors) Emitting metric irrespective of … by @Abdkhan14 in [#115661](https://github.com/getsentry/sentry/pull/115661) +- (span-buffer) Remove flusher and buffer logger options by @untitaker in [#115487](https://github.com/getsentry/sentry/pull/115487) +- (static) Add preload hint for app.js entrypoint by @JonasBa in [#115800](https://github.com/getsentry/sentry/pull/115800) +- (tasks) Remove base64 encoding for bytes parameters in tasks in [#116293](https://github.com/getsentry/sentry/pull/116293) +- (taskworker) Move devenv for profiles consumer to taskbroker in [#116194](https://github.com/getsentry/sentry/pull/116194) +- (teams) Avoid organization N+1 in team projects by @scttcper in [#115735](https://github.com/getsentry/sentry/pull/115735) +- (test) Remove router return from initializeOrg by @evanpurkhiser in [#116002](https://github.com/getsentry/sentry/pull/116002) +- (tests) Replace `as jest.Mock` casts with `jest.mocked()` by @evanpurkhiser in [#115790](https://github.com/getsentry/sentry/pull/115790) +- (trace) Migrate virtualizedViewManager off browserHistory by @evanpurkhiser in [#115927](https://github.com/getsentry/sentry/pull/115927) +- (traceDrawer) Replace local SectionDivider/VerticalLine with Scraps Separator in [#116168](https://github.com/getsentry/sentry/pull/116168) +- (types) Add mypy types for sentry.search.snuba.executors by @saponifi3d in [#114994](https://github.com/getsentry/sentry/pull/114994) +- (ui) Upgrade lodash, figma connect by @scttcper in [#115950](https://github.com/getsentry/sentry/pull/115950) +- (vercel) Add logs on failure to add project in [#116235](https://github.com/getsentry/sentry/pull/116235) +- (workflows) Avoid a query on Organization in delayed_workflow by @kcons in [#115965](https://github.com/getsentry/sentry/pull/115965) +- Instruct agents to prefer type inference over call-side generics in [#116290](https://github.com/getsentry/sentry/pull/116290) +- Add right padding to seer header copy button in [#116286](https://github.com/getsentry/sentry/pull/116286) +- Remove code coverage stacktrace insights in [#115417](https://github.com/getsentry/sentry/pull/115417) +- Remove autopilot CODEOWNERS entries by @vgrozdanic in [#116085](https://github.com/getsentry/sentry/pull/116085) +- Replace withOrganization with useOrganization in function components by @evanpurkhiser in [#115343](https://github.com/getsentry/sentry/pull/115343) +- Remove withSentryRouter HOC by @evanpurkhiser in [#115949](https://github.com/getsentry/sentry/pull/115949) +- Migrate useRouter callsites to native RR6 hooks by @evanpurkhiser in [#115945](https://github.com/getsentry/sentry/pull/115945) +- Drop unused 'unmigratable' status literal from repo query types by @evanpurkhiser in [#115906](https://github.com/getsentry/sentry/pull/115906) +- Remove unmigratable repositories code path by @evanpurkhiser in [#115905](https://github.com/getsentry/sentry/pull/115905) +- Remove OrganizationConfigRepositoriesEndpoint by @evanpurkhiser in [#115898](https://github.com/getsentry/sentry/pull/115898) +- Remove unused PUT handler from repository details endpoint by @evanpurkhiser in [#115896](https://github.com/getsentry/sentry/pull/115896) +- Bump taskbroker-client to 0.1.15 by @bmckerry in [#115799](https://github.com/getsentry/sentry/pull/115799) +- Mark legacy react-router shim hooks as deprecated by @ryan953 in [#115767](https://github.com/getsentry/sentry/pull/115767) +- Merged Jest changedSince testing into main PR Jest job by @JoshuaKGoldberg in [#115549](https://github.com/getsentry/sentry/pull/115549) +- Replace browserHistory with useNavigate in useCleanQueryParamsOnRouteLeave by @ryan953 in [#115695](https://github.com/getsentry/sentry/pull/115695) +- Remove browserHistory by inlining navigate in upgradeNowModal callers by @ryan953 in [#115755](https://github.com/getsentry/sentry/pull/115755) +- Bump platformicons to 9.5.0 by @priscilawebdev in [#115707](https://github.com/getsentry/sentry/pull/115707) +- Bump new development version by @sentry-release-bot[bot] in [7ea81f9f](https://github.com/getsentry/sentry/commit/7ea81f9fbf91748936a96fa3105058751548bb07) + +### Other + +- fix(relocation) Remove invalid token scopes during export in [#116214](https://github.com/getsentry/sentry/pull/116214) +- chore(relocation) Exclude Email model from relocations v2 in [#116256](https://github.com/getsentry/sentry/pull/116256) +- chore(cells) Mainline org create via control in [#116046](https://github.com/getsentry/sentry/pull/116046) +- deps: Upgrade sentry-scm to 0.16.0 in [#116215](https://github.com/getsentry/sentry/pull/116215) +- chore(relocation) Remove unused outbox handler by @markstory in [#116030](https://github.com/getsentry/sentry/pull/116030) +- fix(relocation) Fix type errors when spawning a task by @markstory in [#116130](https://github.com/getsentry/sentry/pull/116130) +- Fix category missing by @noahsmartin in [#116056](https://github.com/getsentry/sentry/pull/116056) +- chore(relocations) Add bucket_path to RelocationFile by @markstory in [#116035](https://github.com/getsentry/sentry/pull/116035) +- chore(cells) Remove rollout option for connection pooling by @markstory in [#116011](https://github.com/getsentry/sentry/pull/116011) +- fix(ci) Don't capture log messages in RPC schema generation by @markstory in [#116003](https://github.com/getsentry/sentry/pull/116003) +- fix(typing) Remove sentry.middleware.auth from the ignore list by @markstory in [#115798](https://github.com/getsentry/sentry/pull/115798) +- feat(cells) Make organization avatar URL cell compatible by @markstory in [#115689](https://github.com/getsentry/sentry/pull/115689) +- deps(ui): Upgrade Rspack to v2, 124 fewer dependencies by @scttcper in [#113795](https://github.com/getsentry/sentry/pull/113795) +- o11y(seer): Track block content copy in Seer Explorer by @aliu39 in [#115900](https://github.com/getsentry/sentry/pull/115900) +- org-scoped URL for page export by @strongs in [#115844](https://github.com/getsentry/sentry/pull/115844) +- feat(cells) Use connection pools for cell RPC operations by @markstory in [#115827](https://github.com/getsentry/sentry/pull/115827) +- lint: enable jest/prefer-jest-mocked by @evanpurkhiser in [#115791](https://github.com/getsentry/sentry/pull/115791) +- feat(cells); Add org scoping to `GroupTagExportView` by @strongs in [#115841](https://github.com/getsentry/sentry/pull/115841) +- Remove legacy code paths for the combined rule endpoint by @ceorourke in [#115750](https://github.com/getsentry/sentry/pull/115750) +- Auto-create PRs for manual Seer handoff by @JoshFerge in [#115831](https://github.com/getsentry/sentry/pull/115831) +- feat(cells) Provision new orgs through control with feature flag by @markstory in [#115600](https://github.com/getsentry/sentry/pull/115600) +- Chore org index silo metrics by @markstory in [#115664](https://github.com/getsentry/sentry/pull/115664) +- o11y(assisted-query): Track error outcomes and reasons for AI query analytics by @aliu39 in [#115699](https://github.com/getsentry/sentry/pull/115699) +- deps(ui): Upgrade jest to 30.4 by @scttcper in [#115725](https://github.com/getsentry/sentry/pull/115725) + 26.5.0 ------ diff --git a/setup.cfg b/setup.cfg index 9c0c2dcb6465..d6a2160afec6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = sentry -version = 26.6.0.dev0 +version = 26.5.1 description = A realtime logging and aggregation server. long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 8d7b5f2b21f7..40e4eb58fcd8 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2232,7 +2232,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SELF_HOSTED_ERRORS_ONLY = False # only referenced in getsentry to provide the stable beacon version # updated with scripts/bump-version.sh -SELF_HOSTED_STABLE_VERSION = "26.5.0" +SELF_HOSTED_STABLE_VERSION = "26.5.1" # Whether we should look at X-Forwarded-For header or not # when checking REMOTE_ADDR ip addresses From b21513d5908c25155bcac54f2d550e319424a18b Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 27 May 2026 09:42:27 -0700 Subject: [PATCH 07/37] fix(pageFilters): Sort bookmarked projects above non-member projects (#116196) Prioritize bookmarked projects over membership status in our project page filter sort Co-authored-by: Claude Opus 4.6 --- .../project/projectPageFilter.spec.tsx | 41 +++++++++++++++++++ .../pageFilters/project/projectPageFilter.tsx | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx index 7024791c2a91..70a2daaa6883 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.spec.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.spec.tsx @@ -731,6 +731,47 @@ describe('ProjectPageFilter', () => { expect(within(projectRows[3]!).getByText('regular-project-b')).toBeInTheDocument(); }); + it('sorts bookmarked non-member projects above unbookmarked member projects', async () => { + const projectsWithMixedMembership = [ + ProjectFixture({id: '1', slug: 'selected-project', isMember: true}), + ProjectFixture({id: '2', slug: 'member-project', isMember: true}), + ProjectFixture({ + id: '3', + slug: 'bookmarked-non-member', + isMember: false, + isBookmarked: true, + }), + ProjectFixture({id: '4', slug: 'non-member-project', isMember: false}), + ]; + + ProjectsStore.loadInitialData(projectsWithMixedMembership); + + PageFiltersStore.onInitializeUrlState({ + projects: [1], + environments: [], + datetime: {start: null, end: null, period: '14d', utc: null}, + }); + + render(, { + organization, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/issues/', query: {project: '1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'selected-project'})); + + const projectRows = screen.getAllByRole('row'); + + // Skip the 2 special items (All Projects, My Projects) + expect(within(projectRows[2]!).getByText('selected-project')).toBeInTheDocument(); + expect( + within(projectRows[3]!).getByText('bookmarked-non-member') + ).toBeInTheDocument(); + expect(within(projectRows[4]!).getByText('member-project')).toBeInTheDocument(); + expect(within(projectRows[5]!).getByText('non-member-project')).toBeInTheDocument(); + }); + it('maintains stable sort when bookmarking, then applies new sort on menu reopen', async () => { const mockApi = MockApiClient.addMockResponse({ method: 'PUT', diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 0c560b153ae6..46573bbef4a8 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -352,8 +352,8 @@ export function ProjectPageFilter({ bookmarkedSnapshotRef.current ? [ !lastSelected.includes(parseInt(project.id, 10)), - !project.isMember, !bookmarkedSnapshotRef.current.has(project.id), + !project.isMember, project.slug, ] : [ From 2fd64c27273945d2958c53ccc3947cd7121b0547 Mon Sep 17 00:00:00 2001 From: Richard Roggenkemper <46740234+roggenkemper@users.noreply.github.com> Date: Wed, 27 May 2026 12:45:03 -0400 Subject: [PATCH 08/37] feat(utils): Add shuffle option to CursoredScheduler (#116297) Add a `shuffle` parameter to `CursoredScheduler` that randomizes the PK processing order at cycle start. Defaults to `False` for backward compatibility. The CursoredScheduler currently always processes items in ascending PK order. When items have varying processing yield, this creates a deterministic pattern of peaks and dips that repeats every cycle. Shuffling distributes high-yield items randomly across the cycle, smoothing throughput over time. --- src/sentry/utils/cursored_scheduler.py | 6 +++ tests/sentry/utils/test_cursored_scheduler.py | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/sentry/utils/cursored_scheduler.py b/src/sentry/utils/cursored_scheduler.py index 90428df867f4..af81dc1d8275 100644 --- a/src/sentry/utils/cursored_scheduler.py +++ b/src/sentry/utils/cursored_scheduler.py @@ -58,6 +58,7 @@ def is_eligible(pk: int) -> bool: import logging import math +import random import time from collections.abc import Callable from datetime import timedelta @@ -136,6 +137,7 @@ def __init__( cycle_duration: timedelta, lock_duration: int = DEFAULT_LOCK_DURATION_SECONDS, validate_item: Callable[[int], bool] | None = None, + shuffle: bool = False, ): self.name = name self.schedule_key = schedule_key @@ -151,6 +153,7 @@ def __init__( self.cache_ttl = int(cycle_duration.total_seconds() * 2) self.lock_duration = lock_duration self.validate_item = validate_item + self.shuffle = shuffle self._metric_tags = {"scheduler": name} @property @@ -233,6 +236,9 @@ def _initialize_cycle(self) -> int: all_pks = list(self.queryset.order_by("pk").values_list("pk", flat=True)) + if self.shuffle: + random.shuffle(all_pks) + client = self._get_redis_client() existing_len = client.llen(self.pk_list_cache_key) diff --git a/tests/sentry/utils/test_cursored_scheduler.py b/tests/sentry/utils/test_cursored_scheduler.py index bc7c3bc4affc..df8550bb993c 100644 --- a/tests/sentry/utils/test_cursored_scheduler.py +++ b/tests/sentry/utils/test_cursored_scheduler.py @@ -447,6 +447,48 @@ def test_no_recalculation_when_interval_unchanged(self): scheduler.tick() assert self.mock_task.delay.call_count == 10 + def test_shuffle_randomizes_pk_order(self): + """When shuffle=True, PKs are not in ascending order.""" + ois = self._create_org_integrations(30) + sorted_pks = [oi.pk for oi in ois] + + scheduler = CursoredScheduler( + name="test_scheduler", + schedule_key="test-scheduler-beat", + queryset=OrganizationIntegration.objects.filter( + integration__provider="github", + status=ObjectStatus.ACTIVE, + ), + task=self.mock_task, + cycle_duration=timedelta(minutes=3), + shuffle=True, + ) + + # Complete entire cycle to collect all dispatched PKs + all_dispatched: list[int] = [] + while scheduler.tick(): + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + self.mock_task.reset_mock() + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + + assert set(all_dispatched) == set(sorted_pks) + assert all_dispatched != sorted_pks + + def test_shuffle_false_preserves_pk_order(self): + """When shuffle=False (default), PKs are in ascending PK order.""" + ois = self._create_org_integrations(30) + sorted_pks = [oi.pk for oi in ois] + + scheduler = self._make_scheduler() + + all_dispatched: list[int] = [] + while scheduler.tick(): + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + self.mock_task.reset_mock() + all_dispatched.extend(c.args[0] for c in self.mock_task.delay.call_args_list) + + assert all_dispatched == sorted_pks + def test_interval_decrease_halves_batch_size(self): """When tick interval is halved, batch size is halved for remaining items.""" self._create_org_integrations(30) From c9c461506030aae3c8e58b814b746a897237eb46 Mon Sep 17 00:00:00 2001 From: "sentry-release-bot[bot]" <180476844+sentry-release-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 16:48:13 +0000 Subject: [PATCH 09/37] meta: Bump new development version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index d6a2160afec6..9c0c2dcb6465 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = sentry -version = 26.5.1 +version = 26.6.0.dev0 description = A realtime logging and aggregation server. long_description = file: README.md long_description_content_type = text/markdown From 8f5cbe97b5cbd7a9c7eb67abe486ebd1ea759256 Mon Sep 17 00:00:00 2001 From: Max Topolsky <30879163+mtopo27@users.noreply.github.com> Date: Wed, 27 May 2026 12:50:52 -0400 Subject: [PATCH 10/37] feat(preprod): Add structured tags to snapshot test metadata (#116307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all custom metadata (theme, variant, size, state, etc.) from flat top-level keys into a structured `tags: Record` field across all 14 snapshot test files. This makes snapshot properties filterable and queryable on the product side without relying on `extra = "allow"` passthrough fields. **Framework type changes** `SnapshotTestMetadata` replaces the previous `Record` parameter on `it.snapshot()` and `it.snapshot.each()`. The new type restricts metadata to three keys: `group`, `display_name`, and `tags`. The `SnapshotImageMetadata` output interface gains a matching `tags` field. No backend changes needed — `ImageMetadata` in `manifest.py` already has `tags: dict[str, str] | None` with coercion. **Per-file tag mapping** Every snapshot test now passes an explicit `tags` object containing the relevant axes for that component — `theme` is universal, `size`/`variant`/`state` vary by file. An `area` tag distinguishes core design system primitives (`core`) from product-side snapshot UI (`snapshots`). Three files (checkbox, radio, switch) that previously had no metadata now gain tags for the first time. --------- Co-authored-by: Claude Opus 4.6 --- .../components/core/alert/alert.snapshots.tsx | 6 +- .../components/core/badge/badge.snapshots.tsx | 2 +- .../core/button/button.snapshots.tsx | 3 + .../core/checkbox/checkbox.snapshots.tsx | 57 ++++++++----- .../core/input/inputGroup.snapshots.tsx | 80 +++++++++++-------- .../components/core/radio/radio.snapshots.tsx | 72 ++++++++++------- .../core/switch/switch.snapshots.tsx | 72 ++++++++++------- .../components/core/text/text.snapshots.tsx | 44 +++++----- .../preprodBuildsSnapshotTable.snapshots.tsx | 11 ++- .../diffImageDisplay.snapshots.tsx | 4 +- .../singleImageDisplay.snapshots.tsx | 2 +- .../main/snapshotCards.snapshots.tsx | 18 ++--- .../snapshotSidebarContent.snapshots.tsx | 8 +- .../snapshots/snapshot-framework.ts | 27 ++++--- .../snapshots/snapshot-image-metadata.ts | 11 +++ tests/js/sentry-test/snapshots/snapshot.ts | 21 +++-- 16 files changed, 261 insertions(+), 177 deletions(-) diff --git a/static/app/components/core/alert/alert.snapshots.tsx b/static/app/components/core/alert/alert.snapshots.tsx index 8492a95cb5fc..9e13a0889489 100644 --- a/static/app/components/core/alert/alert.snapshots.tsx +++ b/static/app/components/core/alert/alert.snapshots.tsx @@ -24,7 +24,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant)}) + variant => ({tags: {variant: String(variant), area: 'core'}}) ); it.snapshot.each([ @@ -44,7 +44,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant), showIcon: 'false'}) + variant => ({tags: {variant: String(variant), showIcon: 'false', area: 'core'}}) ); it.snapshot.each([ @@ -64,7 +64,7 @@ describe('Alert', () => { ), - variant => ({theme: themeName, variant: String(variant), system: 'true'}) + variant => ({tags: {variant: String(variant), system: 'true', area: 'core'}}) ); }); }); diff --git a/static/app/components/core/badge/badge.snapshots.tsx b/static/app/components/core/badge/badge.snapshots.tsx index 8541a9bfc648..7a3dea24025b 100644 --- a/static/app/components/core/badge/badge.snapshots.tsx +++ b/static/app/components/core/badge/badge.snapshots.tsx @@ -31,7 +31,7 @@ describe('Badge', () => { ), - variant => ({theme: themeName, variant: String(variant)}) + variant => ({tags: {variant: String(variant), area: 'core'}}) ); }); }); diff --git a/static/app/components/core/button/button.snapshots.tsx b/static/app/components/core/button/button.snapshots.tsx index 3166a36df39a..545d826f761f 100644 --- a/static/app/components/core/button/button.snapshots.tsx +++ b/static/app/components/core/button/button.snapshots.tsx @@ -46,6 +46,7 @@ describe('Button', () => { { group: `${themeName} – without icon`, display_name: `${themeName} / ${variant} / ${size} / without icon`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); @@ -61,6 +62,7 @@ describe('Button', () => { { group: `${themeName} – with icon`, display_name: `${themeName} / ${variant} / ${size} / with icon`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); @@ -79,6 +81,7 @@ describe('Button', () => { { group: `${themeName} – icon-only`, display_name: `${themeName} / ${variant} / ${size} / icon-only`, + tags: {variant: String(variant), size: String(size), area: 'core'}, } ); }); diff --git a/static/app/components/core/checkbox/checkbox.snapshots.tsx b/static/app/components/core/checkbox/checkbox.snapshots.tsx index 10889450c172..3b3e494819f8 100644 --- a/static/app/components/core/checkbox/checkbox.snapshots.tsx +++ b/static/app/components/core/checkbox/checkbox.snapshots.tsx @@ -17,31 +17,44 @@ describe('Checkbox', () => { {}} /> - ) + ), + checked => ({tags: {checked: String(checked), area: 'core'}}) ); - it.snapshot.each(['xs', 'sm', 'md'])('size-%s', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['xs', 'sm', 'md'])( + 'size-%s', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/input/inputGroup.snapshots.tsx b/static/app/components/core/input/inputGroup.snapshots.tsx index 4115e463bdc8..721e608db4fb 100644 --- a/static/app/components/core/input/inputGroup.snapshots.tsx +++ b/static/app/components/core/input/inputGroup.snapshots.tsx @@ -22,43 +22,55 @@ describe('InputGroup', () => { ), - size => ({theme: themeName, size: String(size)}) + size => ({tags: {size: String(size), area: 'core'}}) ); - it.snapshot('disabled', () => ( - -
- - - -
-
- )); + it.snapshot( + 'disabled', + () => ( + +
+ + + +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('with-leading-items', () => ( - -
- - - - - - -
-
- )); + it.snapshot( + 'with-leading-items', + () => ( + +
+ + + + + + +
+
+ ), + {tags: {area: 'core'}} + ); - it.snapshot('with-leading-items-disabled', () => ( - -
- - - - - - -
-
- )); + it.snapshot( + 'with-leading-items-disabled', + () => ( + +
+ + + + + + +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/radio/radio.snapshots.tsx b/static/app/components/core/radio/radio.snapshots.tsx index 47394929fb51..6f5bce5c0071 100644 --- a/static/app/components/core/radio/radio.snapshots.tsx +++ b/static/app/components/core/radio/radio.snapshots.tsx @@ -9,36 +9,52 @@ const themes = {light: lightTheme, dark: darkTheme}; describe('Radio', () => { describe.each(['light', 'dark'] as const)('theme-%s', themeName => { - it.snapshot.each<'sm' | 'md'>(['sm', 'md'])('size-%s-unchecked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each<'sm' | 'md'>(['sm', 'md'])( + 'size-%s-unchecked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size, area: 'core'}}) + ); - it.snapshot.each<'sm' | 'md'>(['sm', 'md'])('size-%s-checked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each<'sm' | 'md'>(['sm', 'md'])( + 'size-%s-checked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size, checked: 'true', area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/switch/switch.snapshots.tsx b/static/app/components/core/switch/switch.snapshots.tsx index d4bec807b47f..e8465d9326c3 100644 --- a/static/app/components/core/switch/switch.snapshots.tsx +++ b/static/app/components/core/switch/switch.snapshots.tsx @@ -9,36 +9,52 @@ const themes = {light: lightTheme, dark: darkTheme}; describe('Switch', () => { describe.each(['light', 'dark'] as const)('theme-%s', themeName => { - it.snapshot.each(['sm', 'lg'])('size-%s-unchecked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['sm', 'lg'])( + 'size-%s-unchecked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), area: 'core'}}) + ); - it.snapshot.each(['sm', 'lg'])('size-%s-checked', size => ( - -
- {}} /> -
-
- )); + it.snapshot.each(['sm', 'lg'])( + 'size-%s-checked', + size => ( + +
+ {}} /> +
+
+ ), + size => ({tags: {size: String(size), checked: 'true', area: 'core'}}) + ); - it.snapshot('disabled-unchecked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-unchecked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', area: 'core'}} + ); - it.snapshot('disabled-checked', () => ( - -
- {}} /> -
-
- )); + it.snapshot( + 'disabled-checked', + () => ( + +
+ {}} /> +
+
+ ), + {tags: {disabled: 'true', checked: 'true', area: 'core'}} + ); }); }); diff --git a/static/app/components/core/text/text.snapshots.tsx b/static/app/components/core/text/text.snapshots.tsx index 2512cd819d00..b0e44d9377b1 100644 --- a/static/app/components/core/text/text.snapshots.tsx +++ b/static/app/components/core/text/text.snapshots.tsx @@ -19,7 +19,7 @@ describe('Text', () => { ), - size => ({theme: themeName, size}) + size => ({tags: {size, area: 'core'}}) ); it.snapshot.each([ @@ -39,7 +39,7 @@ describe('Text', () => { ), - variant => ({theme: themeName, variant}) + variant => ({tags: {variant, area: 'core'}}) ); it.snapshot( @@ -51,7 +51,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -63,7 +63,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -75,7 +75,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -87,7 +87,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -99,7 +99,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -111,7 +111,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -123,7 +123,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -135,7 +135,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -147,7 +147,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot.each(['left', 'center', 'right', 'justify'] as const)( @@ -161,7 +161,7 @@ describe('Text', () => { ), - align => ({theme: themeName, align}) + align => ({tags: {align, area: 'core'}}) ); it.snapshot.each(['compressed', 'comfortable'] as const)( @@ -176,7 +176,7 @@ describe('Text', () => { ), - density => ({theme: themeName, density}) + density => ({tags: {density, area: 'core'}}) ); it.snapshot( @@ -190,7 +190,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -204,7 +204,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot.each(['balance', 'pretty', 'nowrap', 'stable'] as const)( @@ -218,7 +218,7 @@ describe('Text', () => { ), - textWrap => ({theme: themeName, textWrap}) + textWrap => ({tags: {textWrap, area: 'core'}}) ); it.snapshot.each(['nowrap', 'pre', 'pre-line', 'pre-wrap'] as const)( @@ -230,7 +230,7 @@ describe('Text', () => { ), - wrap => ({theme: themeName, wrap}) + wrap => ({tags: {wrap, area: 'core'}}) ); // === Combined props === @@ -245,7 +245,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -259,7 +259,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -273,7 +273,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -287,7 +287,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); it.snapshot( @@ -302,7 +302,7 @@ describe('Text', () => { ), - {theme: themeName} + {tags: {area: 'core'}} ); }); }); diff --git a/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx b/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx index ca955081a0d1..1769a9569292 100644 --- a/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx +++ b/static/app/components/preprod/preprodBuildsSnapshotTable.snapshots.tsx @@ -100,8 +100,7 @@ describe('PreprodBuildsSnapshotTable', () => { } it.snapshot('status-approved', () => renderTable(makeBuild()), { - theme: themeName, - state: 'status-approved', + tags: {area: 'snapshots'}, }); it.snapshot( @@ -122,7 +121,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'status-needs-approval'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -143,7 +142,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'status-no-base-build'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -154,7 +153,7 @@ describe('PreprodBuildsSnapshotTable', () => { snapshot_comparison_info: undefined, }) ), - {theme: themeName, state: 'status-no-comparison'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -175,7 +174,7 @@ describe('PreprodBuildsSnapshotTable', () => { }, }) ), - {theme: themeName, state: 'changes-no-changes'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx index e3bb3e7519f0..77e4511821d9 100644 --- a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx @@ -120,7 +120,7 @@ describe('DiffImageDisplay', () => { /> ), - diffMode => ({theme: themeName, state: diffMode}) + () => ({tags: {area: 'snapshots'}}) ); it.snapshot( @@ -136,7 +136,7 @@ describe('DiffImageDisplay', () => { /> ), - {theme: themeName, state: 'split-missing-diff-image-key'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx index d48b63ef7f74..c11e20ae930b 100644 --- a/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/imageDisplay/singleImageDisplay.snapshots.tsx @@ -73,7 +73,7 @@ describe('SingleImageDisplay', () => { /> ), - {theme: themeName, state: 'basic-image-display'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx index 4d074f99aae8..0a59185219bb 100644 --- a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx @@ -193,7 +193,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'card-header-display-name-and-filename'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -203,7 +203,7 @@ describe('SnapshotCards', () => { ), - {theme: themeName, state: 'card-header-filename-only'} + {tags: {area: 'snapshots'}} ); function snapshotCardHeaderStatus({ @@ -227,7 +227,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: `card-header-${state}`} + {tags: {area: 'snapshots'}} ); } @@ -259,7 +259,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'card-header-static'} + {tags: {area: 'snapshots'}} ); function snapshotPairCard({ @@ -292,7 +292,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: `pair-card-${state}`} + {tags: {area: 'snapshots'}} ); } @@ -337,7 +337,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-added-selected-with-display-name'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -356,7 +356,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-removed-unselected'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -376,7 +376,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-renamed-with-pair-metadata'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -395,7 +395,7 @@ describe('SnapshotCards', () => { /> ), - {theme: themeName, state: 'image-card-solo-filename-only-no-status'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx index 60d62c6fb7c0..c85240971e40 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx @@ -73,7 +73,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'default'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -92,7 +92,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'active-group'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -110,7 +110,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'filtered'} + {tags: {area: 'snapshots'}} ); it.snapshot( @@ -128,7 +128,7 @@ describe('SnapshotSidebarContent', () => { /> ), - {theme: themeName, state: 'no-results'} + {tags: {area: 'snapshots'}} ); }); }); diff --git a/tests/js/sentry-test/snapshots/snapshot-framework.ts b/tests/js/sentry-test/snapshots/snapshot-framework.ts index 0c7f275eb8e4..6284fa4321ff 100644 --- a/tests/js/sentry-test/snapshots/snapshot-framework.ts +++ b/tests/js/sentry-test/snapshots/snapshot-framework.ts @@ -1,11 +1,13 @@ import type {ReactElement} from 'react'; import {closeBrowser, takeSnapshot} from './snapshot'; +import type {SnapshotTestMetadata} from './snapshot-image-metadata'; interface SnapshotDetails { displayName: string; fileSlug: string; group: string | null; + theme: string | undefined; } /** @@ -26,20 +28,23 @@ function parseSnapshotDetails(testName: string, fallbackName: string): SnapshotD displayName: fallbackName, fileSlug: fallbackName.toLowerCase(), group: null, + theme: undefined, }; } - const group = parts[0]!.trim().replace(/\s+/g, '/'); + const ancestry = parts[0]!.trim(); + const group = ancestry.replace(/\s+/g, '/'); const displayName = parts[1]!.trim(); const fileSlug = `${group}/${displayName}`.replace(/\s+/g, '').toLowerCase(); + const themeMatch = ancestry.match(/\b(light|dark)\b/); - return {displayName, fileSlug, group}; + return {displayName, fileSlug, group, theme: themeMatch?.[1]}; } function snapshotTest( name: string, renderFn: () => ReactElement, - metadata: Record = {} + metadata: SnapshotTestMetadata = {} ): void { test(`snapshot: ${name}`, async () => { const {testPath, currentTestName} = expect.getState(); @@ -47,17 +52,15 @@ function snapshotTest( throw new Error('Could not determine test file path'); } - const {displayName, fileSlug, group} = parseSnapshotDetails( - currentTestName ?? '', - name - ); + const details = parseSnapshotDetails(currentTestName ?? '', name); await takeSnapshot({ - fileSlug, - displayName, + fileSlug: details.fileSlug, + displayName: details.displayName, renderFn, testFilePath: testPath, - group, + group: details.group, + theme: details.theme, metadata, }); }); @@ -67,7 +70,7 @@ snapshotTest.each = function snapshotEach(table: T[]) { return ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => Record + metadataFn?: (value: T) => SnapshotTestMetadata ) => { for (const value of table) { const testName = name.replace('%s', String(value)); @@ -91,7 +94,7 @@ declare global { ) => ( name: string, renderFn: (value: T) => ReactElement, - metadataFn?: (value: T) => Record + metadataFn?: (value: T) => SnapshotTestMetadata ) => void; }; } diff --git a/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts b/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts index c4533ab34e45..2689f37b1ba3 100644 --- a/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts +++ b/tests/js/sentry-test/snapshots/snapshot-image-metadata.ts @@ -5,5 +5,16 @@ export interface SnapshotImageMetadata { test_file_path: string; }; group?: string | null; + tags?: Record; // Skip height, width and image_file_name as they're handled by the CLI } + +type SnapshotArea = 'core' | 'snapshots'; + +type SnapshotTags = {area: SnapshotArea} & Record; + +export interface SnapshotTestMetadata { + display_name?: string; + group?: string; + tags?: SnapshotTags; +} diff --git a/tests/js/sentry-test/snapshots/snapshot.ts b/tests/js/sentry-test/snapshots/snapshot.ts index bda0db759307..a47108633ac4 100644 --- a/tests/js/sentry-test/snapshots/snapshot.ts +++ b/tests/js/sentry-test/snapshots/snapshot.ts @@ -10,7 +10,10 @@ import {CacheProvider} from '@emotion/react'; import createEmotionServer from '@emotion/server/create-instance'; import {chromium, type Browser} from 'playwright'; -import type {SnapshotImageMetadata} from 'sentry-test/snapshots/snapshot-image-metadata'; +import type { + SnapshotImageMetadata, + SnapshotTestMetadata, +} from 'sentry-test/snapshots/snapshot-image-metadata'; const PROJECT_ROOT = path.resolve(__dirname, '../../../..'); const FONTS_DIR = path.resolve(PROJECT_ROOT, 'static/fonts'); @@ -101,9 +104,10 @@ interface TakeSnapshotOptions { displayName: string; fileSlug: string; group: string | null; - metadata: Record; + metadata: SnapshotTestMetadata; renderFn: () => ReactElement; testFilePath: string; + theme: string | undefined; } export async function takeSnapshot({ @@ -112,6 +116,7 @@ export async function takeSnapshot({ renderFn, testFilePath, group, + theme, metadata, }: TakeSnapshotOptions): Promise { const element = renderFn(); @@ -142,10 +147,16 @@ export async function takeSnapshot({ mkdirSync(outputDir, {recursive: true}); } + const autoTags: Record = {}; + if (theme) { + autoTags.theme = theme; + } + const tags = {...autoTags, ...metadata.tags}; + const meta: SnapshotImageMetadata = { - display_name: displayName, - group, - ...metadata, + display_name: metadata.display_name ?? displayName, + group: metadata.group ?? group, + tags: Object.keys(tags).length > 0 ? tags : undefined, context: {test_file_path: relativePath}, }; From 53b64b54ed38d348ef4019f0362a3c1acb537549 Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 27 May 2026 11:57:45 -0500 Subject: [PATCH 11/37] feat(flagpole): Register onboarding-scm-project-creation-experiment feature flag (#116189) ## Summary - Registers `organizations:onboarding-scm-project-creation-experiment` in `temporary.py` for the SCM-first project creation wizard A/B test. - Uses `FLAGPOLE` handler with `api_expose=True` so the frontend can read it via `useExperiment()` to gate the new wizard against the legacy `createProject.tsx` form. Companion PR in sentry-options-automator: https://github.com/getsentry/sentry-options-automator/pull/7930 Refs VDY-73 ## Test plan - [ ] Verify flag is accessible via the org serializer - [ ] Confirm experiment assignment works with `useExperiment()` hook once the wizard scaffold (VDY-73 follow-up) lands --- 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 3c1652652315..e6e016d31261 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -177,6 +177,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:onboarding-scm-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Experiment: SCM onboarding project details A/B test manager.add("organizations:onboarding-scm-project-details-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Experiment: SCM-first project creation wizard A/B test (project creation flow, not new-org onboarding) + manager.add("organizations:onboarding-scm-project-creation-experiment", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable large ownership rule file size limit manager.add("organizations:ownership-size-limit-large", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable xlarge ownership rule file size limit From 7e849efd503173ac215464006c308388b5b18c09 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Wed, 27 May 2026 13:02:51 -0400 Subject: [PATCH 12/37] fix(explore): y-axis formatting decimal truncation for heatmaps (#116144) This fixes y-axis formatting for all charts but it was a needed fix for heat maps. Initially the y-axis would preserve decimal points up to 20 spaces (that's way too much) and it would take up so much space on the chart. This is especially seen with the logarithmic heat maps y-axis. I've changed this to use the default in `formatNumberWithDynamicDecimalPoints` or 4 decimal points in really small numbers. This way the y-axis values are shown with enough detail but don't take up a big chunk of space. | Before | After | |--------|--------| | image | image | --- static/app/utils/number/NUMBER_FORMATTING.md | 4 + .../formatters/formatYAxisValue.spec.tsx | 97 +++++++++++++++++++ .../formatters/formatYAxisValue.tsx | 94 ++++++++++++++++++ .../heatMapWidgetVisualization.tsx | 2 +- 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx create mode 100644 static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx diff --git a/static/app/utils/number/NUMBER_FORMATTING.md b/static/app/utils/number/NUMBER_FORMATTING.md index 616260ee7e10..13bf6f13d87c 100644 --- a/static/app/utils/number/NUMBER_FORMATTING.md +++ b/static/app/utils/number/NUMBER_FORMATTING.md @@ -266,6 +266,10 @@ Each entry shows the function signature, the rounding/precision logic, and concr [formatYAxisValue.tsx](static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue.tsx) · Integers → `formatAbbreviatedNumber`. Non-integers → `toLocaleString({maximumFractionDigits: 20})` (full precision, trusts ECharts to provide round values). +### `formatYAxisValue(value, 'number'/'integer', ...)` + +[formatYAxisValue.tsx](static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx) · NOTE: This function is ONLY for HEAT MAPS! Integers → `formatAbbreviatedNumber`. Non-integers → `formatNumberWithDynamicDecimalPoints(value)` (ECharts treats heat map y-axis as categories so it will not do a great job at formatting and providing round values. Hence we are rounding them off ourselves). + ### `formatTooltipValue(value, 'number'/'integer', ...)` [formatTooltipValue.tsx](static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue.tsx) · `toLocaleString({maximumFractionDigits: 4})`. If `0 < value < 0.0001`: switches to `{maximumSignificantDigits: 4}` to avoid `"0.0000"`. diff --git a/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx new file mode 100644 index 000000000000..70d026197325 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.spec.tsx @@ -0,0 +1,97 @@ +import {formatYAxisValue} from './formatYAxisValue'; + +describe('formatYAxisValue', () => { + describe('integer', () => { + it.each([ + [0, '0'], + [17, '17'], + [171, '171'], + [17111, '17K'], + [17_000_110, '17M'], + [1_000_110_000, '1B'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'integer')).toEqual(formattedValue); + }); + }); + + describe('number', () => { + it.each([ + [0.000033452, '0.00003345'], + [0.00003, '0.00003'], + [17.1238, '17.12'], + [170, '170'], + [17111, '17K'], + [17_000_110, '17M'], + [1772313.1, '1,772,313.1'], + [1772313.11123, '1,772,313.11'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'number')).toEqual(formattedValue); + }); + }); + + describe('percentage', () => { + it.each([ + [0, '0'], + [0.00005, '0.005%'], + [0.712, '71.2%'], + [17.123, '1,712.3%'], + [1, '100%'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'percentage')).toEqual(formattedValue); + }); + }); + + describe('duration', () => { + it.each([ + [0, 'millisecond', '0'], + [0.712, 'second', '712ms'], + [1230, 'second', '20.5min'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'duration', unit)).toEqual(formattedValue); + }); + }); + + describe('size', () => { + it.each([ + [0, 'byte', '0'], + [0.712, 'megabyte', '712 KB'], + [1231, 'kibibyte', '1.2 MiB'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'size', unit)).toEqual(formattedValue); + }); + }); + + describe('rate', () => { + it.each([ + [0, '1/second', '0'], + [-3, '1/second', '-3/s'], + [0.712, '1/second', '0.712/s'], + [12700, '1/second', '12.7K/s'], + [0.0003, '1/second', '0.0003/s'], + [0.00000153, '1/second', '0.00000153/s'], + [0.35, '1/second', '0.35/s'], + [10, '1/second', '10/s'], + // eslint-disable-next-line unicorn/no-zero-fractions + [10.0, '1/second', '10/s'], + [1231, '1/minute', '1.231K/min'], + [110000, '1/second', '110K/s'], + [110001, '1/second', '110.001K/s'], + [123456789, '1/second', '123.457M/s'], + ])('Formats %s as %s', (value, unit, formattedValue) => { + expect(formatYAxisValue(value, 'rate', unit)).toEqual(formattedValue); + }); + }); + + describe('currency', () => { + it.each([ + [0, '0'], + [17, '$17'], + [171, '$171'], + [17111, '$17.11K'], + [17_000_110, '$17M'], + [1_000_110_000, '$1B'], + ])('Formats %s as %s', (value, formattedValue) => { + expect(formatYAxisValue(value, 'currency')).toEqual(formattedValue); + }); + }); +}); diff --git a/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx new file mode 100644 index 000000000000..8b90133eb5c6 --- /dev/null +++ b/static/app/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue.tsx @@ -0,0 +1,94 @@ +import {formatBytesBase2} from 'sentry/utils/bytes/formatBytesBase2'; +import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; +import { + ABYTE_UNITS, + DurationUnit, + RATE_UNIT_LABELS, + RateUnit, + SizeUnit, +} from 'sentry/utils/discover/fields'; +import {formatAbbreviatedNumber, formatDollars} from 'sentry/utils/formatters'; +import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints'; +import {formatPercentage} from 'sentry/utils/number/formatPercentage'; +import {convertDuration} from 'sentry/utils/unitConversion/convertDuration'; +import {convertSize} from 'sentry/utils/unitConversion/convertSize'; +import { + NUMBER_MIN_VALUE, + NUMBER_MAX_FRACTION_DIGITS, +} from 'sentry/views/dashboards/widgets/common/settings'; +import { + isADurationUnit, + isARateUnit, + isASizeUnit, +} from 'sentry/views/dashboards/widgets/common/typePredicates'; +import {formatYAxisDuration} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisDuration'; + +/** + * Format a value for the Y axis on an ECharts heat map graph. + * + * The values on the Y axis are chosen by ECharts. ECharts will automatically + * select, when possible, nice round values. With heat maps this is not the case. + * Since the Y axis in heat maps are considered categories to ECharts, + * We need to format the values ourselves to the precision we'd like to see, + * especially with floating point numbers. + * + * The rest of the logic is the same as the time series widget Y axis formatter + * (static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue.tsx). + */ +export function formatYAxisValue(value: number, type: string, unit?: string): string { + if (value === 0) { + return '0'; + } + + switch (type) { + case 'integer': + return formatAbbreviatedNumber(value); + case 'number': + if (Number.isInteger(value)) { + return formatAbbreviatedNumber(value); + } + if (value > 0 && value < NUMBER_MIN_VALUE) { + return value.toLocaleString(undefined, { + maximumSignificantDigits: NUMBER_MAX_FRACTION_DIGITS, + }); + } + return formatNumberWithDynamicDecimalPoints(value); + case 'percentage': + return formatPercentage(value, 3); + case 'duration': { + const durationUnit = isADurationUnit(unit) ? unit : DurationUnit.MILLISECOND; + const durationInMilliseconds = convertDuration( + value, + durationUnit, + DurationUnit.MILLISECOND + ); + return formatYAxisDuration(durationInMilliseconds); + } + case 'size': { + const sizeUnit = isASizeUnit(unit) ? unit : SizeUnit.BYTE; + const sizeInBytes = convertSize(value, sizeUnit, SizeUnit.BYTE); + + const formatter = ABYTE_UNITS.includes(unit ?? 'byte') + ? formatBytesBase10 + : formatBytesBase2; + + return formatter(sizeInBytes); + } + case 'rate': { + // Always show rate in the original dataset's unit. If the unit is not + // appropriate, always convert the unit in the original dataset first. + // This way, named rate functions like `epm()` will be shows in per minute + // units + const rateUnit = isARateUnit(unit) ? unit : RateUnit.PER_SECOND; + return `${value.toLocaleString(undefined, { + notation: 'compact', + maximumSignificantDigits: 6, + })}${RATE_UNIT_LABELS[rateUnit]}`; + } + case 'currency': { + return formatDollars(value); + } + default: + return value.toString(); + } +} diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index 37ddc7aa4b24..a5a00be295d4 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -21,10 +21,10 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint'; import {useOrganization} from 'sentry/utils/useOrganization'; import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; +import {formatYAxisValue} from 'sentry/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue'; import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized'; import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue'; import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; -import {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue'; import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings'; import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; From 0a644924ac843e025391abe90961168d323a52bd Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Wed, 27 May 2026 10:11:36 -0700 Subject: [PATCH 13/37] ref(utils): Small `SafeRolloutComparator` refactors (#116257) This makes a few small changes to the `SafeRolloutComparator` code. - For the initial round of span-first detector testing, only the comparison part of the comparator is needed. This therefore splits the `check` part of `check_and_choose` off into its own `compare` method. - In cases where only the comparison is being used, it's not accurate to say that either the experimental or control data is being used. Therefore, "both" and "neither" have been added as allowable `source_of_truth` tag values, to account for (as the names would suggest) both results being used or both results being discarded. - Finally, now that it's part of the `compare` method rather than the `check_and_choose` method, for new comparators the `SafeRolloutComparator.check_and_choose` metric has been renamed to `SafeRolloutComparator.compare`. (The one existing comparator now has a flag which preserves the old behavior, so as not to break existing dashboards.) h/t Claude for his help with parts of these changes --- .../search/eap/occurrences/rollout_utils.py | 5 ++ src/sentry/utils/rollout.py | 68 ++++++++++++++----- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/sentry/search/eap/occurrences/rollout_utils.py b/src/sentry/search/eap/occurrences/rollout_utils.py index 4810e1fd885a..d90bafb0d3a5 100644 --- a/src/sentry/search/eap/occurrences/rollout_utils.py +++ b/src/sentry/search/eap/occurrences/rollout_utils.py @@ -1,8 +1,13 @@ from sentry.utils.rollout import SafeRolloutComparator +# TODO: When this experiment is over and we're deleting this class, go remove the check for +# `use_legacy_comparison_metric_name` in `SafeRolloutComparator.compare`. + class EAPOccurrencesComparator(SafeRolloutComparator): ROLLOUT_NAME = "occurrences_on_eap" + # NOTE: Shim to not break existing dashboards. Don't use in new comparators! + use_legacy_comparison_metric_name = True EAP_OCCURRENCES_SHOULD_RUN_EXPERIMENT_OPTION = ( diff --git a/src/sentry/utils/rollout.py b/src/sentry/utils/rollout.py index b3c2f24f79b5..81641f674471 100644 --- a/src/sentry/utils/rollout.py +++ b/src/sentry/utils/rollout.py @@ -1,7 +1,7 @@ import logging import random from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar from sentry import options from sentry.options import register @@ -19,6 +19,8 @@ TData = TypeVar("TData") +SourceOfTruth = Literal["control", "experimental", "neither", "both"] + class SafeRolloutComparator: """ @@ -177,7 +179,7 @@ def _maybe_log_mismatch( cls, *, callsite: str, - use_experimental_data: bool, + source_of_truth: SourceOfTruth, is_exact_match: bool, is_reasonable_match: bool | None, is_experimental_data_nullish: bool | None, @@ -196,7 +198,7 @@ def _maybe_log_mismatch( extra={ "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, - "source_of_truth": ("experimental" if use_experimental_data else "control"), + "source_of_truth": source_of_truth, "exact_match": is_exact_match, "reasonable_match": is_reasonable_match, "is_null_result": is_experimental_data_nullish, @@ -251,26 +253,31 @@ def should_use_experimental_data(cls, callsite: str) -> bool: return use_experimental_data @classmethod - def check_and_choose( + def compare( cls, control_data: TData, experimental_data: TData, callsite: str, + source_of_truth: SourceOfTruth = "neither", is_experimental_data_nullish: bool | None = None, reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, debug_context: dict[str, Any] | None = None, data_serializer: Callable[[TData], Any] | None = None, - ) -> TData: + ) -> None: """ - This function does two things: - - First, it compares control & experimental data and logs info to DataDog. - - Second, it determines which of the inputs should be returned & used downstream. + Compare control & experimental data, emit metrics, and log mismatches. Use this directly + (rather than `check_and_choose`) if you don't need help determining which data to use + downstream - e.g. if you won't be using either branch's data, or if you'll be using both. Inputs: * control_data: Some data from the control branch (e.g. dict[str, str]) * experimental_data: Some data from the experimental branch (of same type as control) * callsite: A unique string identifying place that uses this class. Should be the same as what's passed to `should_check_experiment`. + * source_of_truth: Which branch's data the caller will actually use downstream. Defaults to + "neither" (the typical direct-call case). `check_and_choose` passes "control" or + "experimental" based on the use-experimental-data allowlist; callers using both branches + should pass "both". * is_experimental_data_nullish: Whether the result is a "null result" (e.g. empty array). This helps to determine whether a "match" is significant. * reasonable_match_comparator: Optional predicate for semantic correctness, returning True @@ -281,16 +288,14 @@ def check_and_choose( * data_serializer: Optional serializer for control/experimental payloads in logs. Defaults to `_default_serialize_for_log`. """ - use_experimental_data = cls.should_use_experimental_data(callsite) is_exact_match = control_data == experimental_data is_reasonable_match: bool | None = None - # Part 1: Compare results, log debug info, and emit metrics tags: dict[str, str] = { "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, "exact_match": str(is_exact_match), - "source_of_truth": ("experimental" if use_experimental_data else "control"), + "source_of_truth": source_of_truth, } if is_experimental_data_nullish is not None: @@ -317,7 +322,7 @@ def check_and_choose( try: cls._maybe_log_mismatch( callsite=callsite, - use_experimental_data=use_experimental_data, + source_of_truth=source_of_truth, is_exact_match=is_exact_match, is_reasonable_match=is_reasonable_match, is_experimental_data_nullish=is_experimental_data_nullish, @@ -332,12 +337,41 @@ def check_and_choose( extra={"rollout_name": cls.ROLLOUT_NAME, "callsite": callsite}, ) - metrics.incr( - "SafeRolloutComparator.check_and_choose", - tags=tags, - ) + # TODO: This shim is only used in `EAPOccurrencesComparator`. Once that's deleted, this + # check can go away and we can standardize on emitting just the `compare` metric. + if getattr(cls, "use_legacy_comparison_metric_name", None): + metrics.incr("SafeRolloutComparator.check_and_choose", tags=tags) + else: + metrics.incr("SafeRolloutComparator.compare", tags=tags) + + @classmethod + def check_and_choose( + cls, + control_data: TData, + experimental_data: TData, + callsite: str, + is_experimental_data_nullish: bool | None = None, + reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, + debug_context: dict[str, Any] | None = None, + data_serializer: Callable[[TData], Any] | None = None, + ) -> TData: + """ + Compare control & experimental data (via `compare`), then return whichever branch should be + used downstream based on the use-experimental-data allowlist. - # Part 2: determine which data to return + See `compare` for parameter documentation. + """ + use_experimental_data = cls.should_use_experimental_data(callsite) + cls.compare( + control_data=control_data, + experimental_data=experimental_data, + callsite=callsite, + source_of_truth="experimental" if use_experimental_data else "control", + is_experimental_data_nullish=is_experimental_data_nullish, + reasonable_match_comparator=reasonable_match_comparator, + debug_context=debug_context, + data_serializer=data_serializer, + ) return experimental_data if use_experimental_data else control_data @classmethod From e212fbfbb9b1d20f2be3e9fc96286cc3e62321f2 Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 27 May 2026 12:18:46 -0500 Subject: [PATCH 14/37] ref(onboarding): Decouple SCM step components from OnboardingContext (#115639) ## TL;DR Decouples the four SCM onboarding step components from `OnboardingContext` so they accept all flow state via props. Adapter wrappers in `onboarding.tsx` source those props from context, preserving today's behavior. Unblocks the project creation variant of SCM onboarding. --- Refactor `ScmConnect`, `ScmPlatformFeatures`, `ScmProjectDetails`, and `ScmRepoSelector` to read and write all flow state via props. Three adapter wrappers in `onboarding.tsx` (`ScmConnectAdapter`, `ScmPlatformFeaturesAdapter`, `ScmProjectDetailsAdapter`) source those props from `OnboardingContext`, preserving today's behavior. This unblocks the project creation variant of SCM onboarding (VDY-73 through VDY-78), which needs the same components driven from local wizard state instead of session-storage-backed context. `clearDerivedState` moves out of `ScmRepoSelector` and into the onboarding adapter so callers in other flows can pick their own invalidation strategy. `ProjectDetailsFormState` is now exported from `onboardingContext` so the project-details prop interface can name it. Alternative considered: a shared `ScmFlowContext` that both flows would populate. Rejected for two-consumer scope because it reintroduces a provider wrapper in tests (the simplification this PR delivers) and adds an abstraction layer with marginal payoff at this size. Supersedes #112948. Refs VDY-72 --- .../onboarding/onboardingContext.tsx | 4 +- .../components/scmRepoSelector.spec.tsx | 147 +++-- .../onboarding/components/scmRepoSelector.tsx | 26 +- static/app/views/onboarding/onboarding.tsx | 87 ++- static/app/views/onboarding/scmConnect.tsx | 47 +- .../onboarding/scmPlatformFeatures.spec.tsx | 511 +++++------------- .../views/onboarding/scmPlatformFeatures.tsx | 70 ++- .../onboarding/scmProjectDetails.spec.tsx | 483 +++++------------ .../views/onboarding/scmProjectDetails.tsx | 42 +- 9 files changed, 588 insertions(+), 829 deletions(-) diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 42a327972958..3e51ceeeaa0a 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -12,7 +12,7 @@ import type {AlertRuleOptions} from 'sentry/views/projectInstall/issueAlertOptio * Cleared by the platform features step when the platform changes, so * stale inputs don't carry across platform selections. */ -interface ProjectDetailsFormState { +export interface ProjectDetailsFormState { alertRuleConfig?: AlertRuleOptions; projectName?: string; teamSlug?: string; @@ -34,7 +34,7 @@ type OnboardingContextProps = { selectedRepository?: Repository; }; -export type OnboardingSessionState = { +type OnboardingSessionState = { createdProjectSlug?: string; projectDetailsForm?: ProjectDetailsFormState; selectedFeatures?: ProductSolution[]; diff --git a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx index df195cc2dccb..c17b18cd88fc 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx @@ -4,10 +4,7 @@ import {RepositoryFixture} from 'sentry-fixture/repository'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {ScmRepoSelector} from './scmRepoSelector'; @@ -26,13 +23,24 @@ jest.mock('@tanstack/react-virtual', () => ({ })), })); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface DefaultPropsOverrides { + integration: Integration; + onClearDerivedState?: jest.Mock; + onRepositoryChange?: jest.Mock; + selectedRepository?: Repository; +} + +function defaultProps({ + integration, + onClearDerivedState = jest.fn(), + onRepositoryChange = jest.fn(), + selectedRepository, +}: DefaultPropsOverrides) { + return { + integration, + selectedRepository, + onRepositoryChange, + onClearDerivedState, }; } @@ -56,7 +64,6 @@ describe('ScmRepoSelector', () => { afterEach(() => { MockApiClient.clearMockResponses(); - sessionStorage.clear(); }); it('renders search placeholder', () => { @@ -65,9 +72,8 @@ describe('ScmRepoSelector', () => { body: {repos: []}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); expect(screen.getByText('Search repositories')).toBeInTheDocument(); @@ -79,9 +85,8 @@ describe('ScmRepoSelector', () => { body: {repos: []}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -100,9 +105,8 @@ describe('ScmRepoSelector', () => { body: {detail: 'Internal Error'}, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -123,9 +127,8 @@ describe('ScmRepoSelector', () => { }, }); - render(, { + render(, { organization, - wrapper: makeOnboardingWrapper(), }); await userEvent.click(screen.getByRole('textbox')); @@ -136,7 +139,7 @@ describe('ScmRepoSelector', () => { expect(screen.getByRole('menuitemradio', {name: 'relay'})).toBeInTheDocument(); }); - it('shows selected repo value when one is in context', () => { + it('shows selected repo value when one is provided', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, body: {repos: []}, @@ -147,10 +150,15 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + render( + , + {organization} + ); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); }); @@ -180,15 +188,19 @@ describe('ScmRepoSelector', () => { ], }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); await userEvent.click(screen.getByRole('textbox')); await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'})); await waitFor(() => expect(reposLookup).toHaveBeenCalled()); + expect(onRepositoryChange).toHaveBeenCalled(); }); it('clears the selected repo', async () => { @@ -202,18 +214,23 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); await userEvent.click(await screen.findByTestId('icon-close')); - await waitFor(() => { - expect(screen.queryByText('getsentry/old-repo')).not.toBeInTheDocument(); - }); + await waitFor(() => expect(onRepositoryChange).toHaveBeenCalledWith(undefined)); }); it('does not duplicate selected repo when it appears in results', async () => { @@ -232,10 +249,15 @@ describe('ScmRepoSelector', () => { }, }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + render( + , + {organization} + ); await userEvent.click(screen.getByRole('textbox')); @@ -249,4 +271,49 @@ describe('ScmRepoSelector', () => { screen.queryByRole('menuitemradio', {name: 'getsentry/sentry'}) ).not.toBeInTheDocument(); }); + + it('fires onClearDerivedState exactly once per user-driven repo change', async () => { + // The underlying selection hook calls onRepositoryChange multiple times for + // a single user click (optimistic + resolved/created paths). The derived- + // state callback must only fire once per click so it isn't redundantly + // wiping flow state on every internal update. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, + body: { + repos: [ + { + externalId: '1', + identifier: 'getsentry/sentry', + name: 'sentry', + isInstalled: false, + }, + ], + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: [ + RepositoryFixture({name: 'getsentry/sentry', externalSlug: 'getsentry/sentry'}), + ], + }); + + const onClearDerivedState = jest.fn(); + const onRepositoryChange = jest.fn(); + render( + , + {organization} + ); + + await userEvent.click(screen.getByRole('textbox')); + await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'})); + + await waitFor(() => expect(onRepositoryChange).toHaveBeenCalled()); + expect(onClearDerivedState).toHaveBeenCalledTimes(1); + }); }); diff --git a/static/app/views/onboarding/components/scmRepoSelector.tsx b/static/app/views/onboarding/components/scmRepoSelector.tsx index 9d35f370dd54..fbeb980fbc7f 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.tsx @@ -2,9 +2,8 @@ import {useMemo} from 'react'; import {Select} from '@sentry/scraps/select'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -15,12 +14,23 @@ import {useScmRepoSelection} from './useScmRepoSelection'; interface ScmRepoSelectorProps { integration: Integration; + // Fired once per user-driven change (select or clear) so callers can + // invalidate state derived from the repo (platform, features, created + // project). Distinct from onRepositoryChange because the underlying repo + // selection hook can fire that callback multiple times for one user action + // (optimistic + resolved + error paths). + onClearDerivedState: () => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedRepository: Repository | undefined; } -export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { +export function ScmRepoSelector({ + integration, + onClearDerivedState, + onRepositoryChange, + selectedRepository, +}: ScmRepoSelectorProps) { const organization = useOrganization(); - const {selectedRepository, setSelectedRepository, clearDerivedState} = - useOnboardingContext(); const {reposByIdentifier, dropdownItems, isFetching, isError} = useScmRepos( integration.id, selectedRepository @@ -28,7 +38,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { const {busy, handleSelect, handleRemove} = useScmRepoSelection({ integration, - onSelect: setSelectedRepository, + onSelect: onRepositoryChange, reposByIdentifier, }); @@ -50,9 +60,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { }, [dropdownItems, selectedRepository]); function handleChange(option: {value: string} | null) { - // Changing or clearing the repo invalidates downstream state (platform, - // features, created project) which are all derived from the selected repo. - clearDerivedState(); + onClearDerivedState(); if (option === null) { handleRemove(); diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index e1b505706e6f..c61e4a646a6b 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -69,6 +69,87 @@ const legacyOnboardingSteps: StepDescriptor[] = [ }, ]; +// Adapters bridge the SCM step components — which accept all flow state via +// props — to the onboarding flow's OnboardingContext. They let the same step +// components be reused by other flows (e.g. project creation) that source +// state from somewhere other than session storage. + +function ScmConnectAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedIntegration, + setSelectedIntegration, + selectedRepository, + setSelectedRepository, + clearDerivedState, + } = useOnboardingContext(); + + return ( + + ); +} + +function ScmPlatformFeaturesAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedRepository, + selectedPlatform, + setSelectedPlatform, + selectedFeatures, + setSelectedFeatures, + setProjectDetailsForm, + createdProjectSlug, + setCreatedProjectSlug, + } = useOnboardingContext(); + + return ( + setProjectDetailsForm(undefined)} + onProjectCreated={setCreatedProjectSlug} + onComplete={onComplete} + genBackButton={genBackButton} + /> + ); +} + +function ScmProjectDetailsAdapter({onComplete, genBackButton}: StepProps) { + const { + selectedPlatform, + selectedFeatures, + selectedRepository, + createdProjectSlug, + setCreatedProjectSlug, + projectDetailsForm, + setProjectDetailsForm, + } = useOnboardingContext(); + + return ( + + ); +} + const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.WELCOME, @@ -79,19 +160,19 @@ const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.SCM_CONNECT, title: t('Connect repository'), - Component: ScmConnect, + Component: ScmConnectAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PLATFORM_FEATURES, title: t('Create your first project'), - Component: ScmPlatformFeatures, + Component: ScmPlatformFeaturesAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PROJECT_DETAILS, title: t('Project details'), - Component: ScmProjectDetails, + Component: ScmProjectDetailsAdapter, hasFooter: true, cornerVariant: 'top-left', }, diff --git a/static/app/views/onboarding/scmConnect.tsx b/static/app/views/onboarding/scmConnect.tsx index 96c4299a6233..74f98ac840a8 100644 --- a/static/app/views/onboarding/scmConnect.tsx +++ b/static/app/views/onboarding/scmConnect.tsx @@ -7,10 +7,9 @@ import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {IconCheckmark, IconClose, IconLock} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -22,6 +21,19 @@ import {useScmProviders} from './components/useScmProviders'; import {SCM_STEP_CONTENT_WIDTH} from './consts'; import type {StepProps} from './types'; +interface ScmConnectProps { + // Fired once per user-driven repo change so callers can invalidate state + // derived from the repo (platform, features, created project). See + // ScmRepoSelector for why this is separate from onRepositoryChange. + onClearDerivedState: () => void; + onComplete: StepProps['onComplete']; + onIntegrationChange: (integration: Integration | undefined) => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedIntegration: Integration | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + const SCM_INFO_SECTIONS = [ { title: t('How we use access'), @@ -47,14 +59,16 @@ const SCM_INFO_SECTIONS = [ }, ]; -export function ScmConnect({onComplete, genBackButton}: StepProps) { +export function ScmConnect({ + onClearDerivedState, + onComplete, + onIntegrationChange, + onRepositoryChange, + selectedIntegration, + selectedRepository, + genBackButton, +}: ScmConnectProps) { const organization = useOrganization(); - const { - selectedIntegration, - setSelectedIntegration, - selectedRepository, - setSelectedRepository, - } = useOnboardingContext(); const { scmProviders, isPending, @@ -76,11 +90,11 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { const handleInstall = useCallback( (data: Integration) => { - setSelectedIntegration(data); - setSelectedRepository(undefined); + onIntegrationChange(data); + onRepositoryChange(undefined); refetchIntegrations(); }, - [setSelectedIntegration, setSelectedRepository, refetchIntegrations] + [onIntegrationChange, onRepositoryChange, refetchIntegrations] ); return ( @@ -117,7 +131,12 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { effectiveIntegration.name )} - + ) : ( @@ -212,7 +231,7 @@ export function ScmConnect({onComplete, genBackButton}: StepProps) { }} onClick={() => { if (effectiveIntegration && !selectedIntegration) { - setSelectedIntegration(effectiveIntegration); + onIntegrationChange(effectiveIntegration); } onComplete(); }} diff --git a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx index 8da847c4195d..4be31e599fee 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx @@ -14,14 +14,11 @@ import { } from 'sentry-test/reactTestingLibrary'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {Repository} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {ScmPlatformFeatures} from './scmPlatformFeatures'; @@ -57,13 +54,24 @@ jest.mock('sentry/data/platforms', () => { }; }); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface StateOverrides { + createdProjectSlug?: string; + selectedFeatures?: ProductSolution[]; + selectedPlatform?: OnboardingSelectedSDK; + selectedRepository?: Repository; +} + +function defaultProps(state: StateOverrides = {}) { + return { + selectedRepository: state.selectedRepository, + selectedPlatform: state.selectedPlatform, + selectedFeatures: state.selectedFeatures, + createdProjectSlug: state.createdProjectSlug, + onPlatformChange: jest.fn(), + onFeaturesChange: jest.fn(), + onClearProjectDetailsForm: jest.fn(), + onProjectCreated: jest.fn(), + onComplete: jest.fn(), }; } @@ -79,7 +87,6 @@ describe('ScmPlatformFeatures', () => { beforeEach(() => { jest.clearAllMocks(); - sessionStorageWrapper.clear(); ProjectsStore.loadInitialData([]); TeamStore.loadInitialData([]); }); @@ -104,17 +111,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const radioGroup = await screen.findByRole('radiogroup'); @@ -138,17 +136,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -164,17 +153,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -198,17 +178,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect( @@ -228,13 +199,7 @@ describe('ScmPlatformFeatures', () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedRepository: mockRepository, selectedPlatform: { key: 'nintendo-switch', @@ -244,8 +209,9 @@ describe('ScmPlatformFeatures', () => { link: null, category: 'all', }, - }), - } + })} + />, + {organization} ); await screen.findByRole('button', {name: 'Continue'}); @@ -276,17 +242,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const changeButton = await screen.findByRole('button', { @@ -305,17 +262,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); expect(await screen.findByText('Select a platform')).toBeInTheDocument(); @@ -325,17 +273,7 @@ describe('ScmPlatformFeatures', () => { }); it('renders manual picker when no repository in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); expect(await screen.findByText('Select a platform')).toBeInTheDocument(); expect( @@ -344,17 +282,7 @@ describe('ScmPlatformFeatures', () => { }); it('continue button is disabled when no platform selected', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); // Wait for the component to fully settle (CompactSelect triggers async popper updates) await screen.findByText('Select a platform'); @@ -371,17 +299,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); // Wait for auto-select of first detected platform @@ -401,47 +320,29 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); // Wait for feature cards to appear await screen.findByText('What do you want to instrument?'); - // Neither profiling nor tracing should be checked initially - expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Tracing/})).not.toBeChecked(); - - // Enable profiling — tracing should auto-enable + // Enable profiling — onFeaturesChange should be called with tracing also enabled await userEvent.click(screen.getByRole('checkbox', {name: /Profiling/})); - expect(screen.getByRole('checkbox', {name: /Profiling/})).toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Tracing/})).toBeChecked(); + expect(props.onFeaturesChange).toHaveBeenCalledWith( + expect.arrayContaining([ + ProductSolution.ERROR_MONITORING, + ProductSolution.PROFILING, + ProductSolution.PERFORMANCE_MONITORING, + ]) + ); }); it('shows framework suggestion modal when selecting a base language', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); renderGlobalModal(); await screen.findByText('Select a platform'); @@ -454,20 +355,12 @@ describe('ScmPlatformFeatures', () => { }); it('opens console modal when selecting a disabled gaming platform', async () => { - render( - null} - />, - { - // No enabledConsolePlatforms — all console platforms are blocked - organization: OrganizationFixture({ - features: ['performance-view', 'session-replay', 'profiling-view'], - }), - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, { + // No enabledConsolePlatforms — all console platforms are blocked + organization: OrganizationFixture({ + features: ['performance-view', 'session-replay', 'profiling-view'], + }), + }); renderGlobalModal(); await screen.findByText('Select a platform'); @@ -490,45 +383,36 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedPlatform: { - key: 'python', - name: 'Python', - language: 'python', - type: 'language', - link: 'https://docs.sentry.io/platforms/python/', - category: 'popular', - }, - selectedFeatures: [ - ProductSolution.ERROR_MONITORING, - ProductSolution.PERFORMANCE_MONITORING, - ProductSolution.PROFILING, - ], - }), - } - ); + const props = defaultProps({ + selectedRepository: mockRepository, + selectedPlatform: { + key: 'python', + name: 'Python', + language: 'python', + type: 'language', + link: 'https://docs.sentry.io/platforms/python/', + category: 'popular', + }, + selectedFeatures: [ + ProductSolution.ERROR_MONITORING, + ProductSolution.PERFORMANCE_MONITORING, + ProductSolution.PROFILING, + ], + }); + render(, {organization}); // Wait for feature cards to appear await screen.findByText('What do you want to instrument?'); - // Both should be checked initially - expect(screen.getByRole('checkbox', {name: /Tracing/})).toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Profiling/})).toBeChecked(); - - // Disable tracing — profiling should auto-disable + // Disable tracing — onFeaturesChange should drop both tracing and profiling await userEvent.click(screen.getByRole('checkbox', {name: /Tracing/})); - expect(screen.getByRole('checkbox', {name: /Tracing/})).not.toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); + expect(props.onFeaturesChange).toHaveBeenCalledWith( + expect.not.arrayContaining([ + ProductSolution.PERFORMANCE_MONITORING, + ProductSolution.PROFILING, + ]) + ); }); it('clears persisted project details form when detected platform changes', async () => { @@ -546,31 +430,15 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - projectDetailsForm: { - projectName: 'stale-name', - teamSlug: 'stale-team', - }, - }), - } - ); + // The component is stateless w.r.t. the form, so we just verify it calls + // the clear callback when the user changes the detected platform. + const props = defaultProps({selectedRepository: mockRepository}); + render(, {organization}); const djangoCard = await screen.findByRole('radio', {name: /Django/}); await userEvent.click(djangoCard); - await waitFor(() => { - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.projectDetailsForm).toBeUndefined(); - }); + expect(props.onClearProjectDetailsForm).toHaveBeenCalled(); }); describe('analytics', () => { @@ -581,17 +449,7 @@ describe('ScmPlatformFeatures', () => { }); it('fires step viewed event on mount', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + render(, {organization}); await screen.findByText('Select a platform'); @@ -617,17 +475,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); // Wait for detected platforms, then click the second one @@ -659,17 +508,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); await screen.findByRole('heading', {level: 3, name: 'Available with Next.js'}); @@ -693,17 +533,12 @@ describe('ScmPlatformFeatures', () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedRepository: mockRepository, selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } + })} + />, + {organization} ); await screen.findByText('What do you want to instrument?'); @@ -727,17 +562,8 @@ describe('ScmPlatformFeatures', () => { }); render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } + , + {organization} ); const changeButton = await screen.findByRole('button', { @@ -780,7 +606,6 @@ describe('ScmPlatformFeatures', () => { }); it('auto-creates the project on Continue and forwards selected features', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -791,20 +616,11 @@ describe('ScmPlatformFeatures', () => { body: createdProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -824,13 +640,13 @@ describe('ScmPlatformFeatures', () => { }) ); }); - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); + expect(props.onProjectCreated).toHaveBeenCalledWith(createdProject.slug); }); it('links selected repository to project after creation', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -857,21 +673,12 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -879,7 +686,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(repoLinkRequest).toHaveBeenCalledWith( @@ -892,7 +699,6 @@ describe('ScmPlatformFeatures', () => { }); it('reuses the existing project when the platform is unchanged', async () => { - const onComplete = jest.fn(); const existingProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -904,21 +710,12 @@ describe('ScmPlatformFeatures', () => { body: existingProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - createdProjectSlug: existingProject.slug, - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + createdProjectSlug: existingProject.slug, + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -926,7 +723,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); }); @@ -934,7 +731,6 @@ describe('ScmPlatformFeatures', () => { }); it('creates a new project when the platform changed from the existing one', async () => { - const onComplete = jest.fn(); const stalePythonProject = ProjectFixture({ slug: 'python', platform: 'python', @@ -950,21 +746,12 @@ describe('ScmPlatformFeatures', () => { body: newProject, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - createdProjectSlug: stalePythonProject.slug, - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + createdProjectSlug: stalePythonProject.slug, + }); + render(, {organization}); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -974,7 +761,7 @@ describe('ScmPlatformFeatures', () => { await waitFor(() => { expect(createRequest).toHaveBeenCalled(); }); - expect(onComplete).toHaveBeenCalledWith(nextJsPlatform, { + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { product: [ProductSolution.ERROR_MONITORING], }); }); @@ -985,7 +772,6 @@ describe('ScmPlatformFeatures', () => { // currentPlatformKey falls back to the detected key. Passing undefined // to onComplete here would trip goNextStep's SETUP_DOCS guard because // the captured closure still sees selectedPlatform as undefined. - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', platform: 'javascript-nextjs', @@ -1012,19 +798,8 @@ describe('ScmPlatformFeatures', () => { body: {}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + const props = defaultProps({selectedRepository: mockRepository}); + render(, {organization}); await screen.findByRole('radio', {name: /Next.js/}); await waitFor(() => { @@ -1033,7 +808,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith( + expect(props.onComplete).toHaveBeenCalledWith( expect.objectContaining({key: 'javascript-nextjs'}), {product: [ProductSolution.ERROR_MONITORING]} ); @@ -1060,27 +835,19 @@ describe('ScmPlatformFeatures', () => { }; it('advances without creating a project on Continue', async () => { - const onComplete = jest.fn(); const createRequest = MockApiClient.addMockResponse({ url: `/teams/${experimentOrganization.slug}/team-slug/projects/`, method: 'POST', body: ProjectFixture(), }); - render( - null} - />, - { - organization: experimentOrganization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: nextJsPlatform, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const props = defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); + render(, { + organization: experimentOrganization, + }); await waitFor(() => { expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled(); @@ -1088,7 +855,7 @@ describe('ScmPlatformFeatures', () => { await userEvent.click(screen.getByRole('button', {name: 'Continue'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith(); + expect(props.onComplete).toHaveBeenCalledWith(); }); expect(createRequest).not.toHaveBeenCalled(); }); diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index e88d935652cc..98f877bba80e 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -14,7 +14,6 @@ import {closeModal, openConsoleModal} from 'sentry/actionCreators/modal'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import { getDisabledProducts, platformProductAvailability, @@ -24,6 +23,7 @@ import {PLATFORM_PRODUCT_INFO} from 'sentry/data/platformProductInfo.generated'; import {platforms} from 'sentry/data/platforms'; import {IconBroadcast, IconBusiness, IconGeneric} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import type {PlatformIntegration, PlatformKey} from 'sentry/types/project'; @@ -100,20 +100,34 @@ function getPlatformName(platformKey: PlatformKey | undefined) { return getPlatformInfo(platformKey)?.name; } -export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { +interface ScmPlatformFeaturesProps { + createdProjectSlug: string | undefined; + onClearProjectDetailsForm: () => void; + onComplete: StepProps['onComplete']; + onFeaturesChange: (features: ProductSolution[] | undefined) => void; + onPlatformChange: (platform: OnboardingSelectedSDK | undefined) => void; + onProjectCreated: (slug: string | undefined) => void; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + +export function ScmPlatformFeatures({ + createdProjectSlug, + onClearProjectDetailsForm, + onComplete, + onFeaturesChange, + onPlatformChange, + onProjectCreated, + selectedFeatures, + selectedPlatform, + selectedRepository, + genBackButton, +}: ScmPlatformFeaturesProps) { const {openModal} = useModal(); const organization = useOrganization(); - const { - selectedRepository, - selectedPlatform, - setSelectedPlatform, - selectedFeatures, - setSelectedFeatures, - setProjectDetailsForm, - createdProjectSlug, - setCreatedProjectSlug, - } = useOnboardingContext(); const {teams, fetching: isLoadingTeams} = useTeams(); const {projects, initiallyLoaded: projectsLoaded} = useProjects(); @@ -138,10 +152,10 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { (platformKey: PlatformKey) => { const info = getPlatformInfo(platformKey); if (info) { - setSelectedPlatform(toSelectedSdk(info)); + onPlatformChange(toSelectedSdk(info)); } }, - [setSelectedPlatform] + [onPlatformChange] ); const hasScmConnected = !!selectedRepository; @@ -260,7 +274,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { } } - setSelectedFeatures(Array.from(newFeatures)); + onFeaturesChange(Array.from(newFeatures)); trackAnalytics('onboarding.scm_platform_feature_toggled', { organization, @@ -271,7 +285,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { }, [ currentFeatures, - setSelectedFeatures, + onFeaturesChange, disabledProducts, availableFeatures, organization, @@ -280,9 +294,9 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { ); const applyPlatformSelection = (sdk: OnboardingSelectedSDK) => { - setSelectedPlatform(sdk); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onPlatformChange(sdk); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); }; const handleManualPlatformSelect = async (option: {value: string}) => { @@ -340,8 +354,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -355,8 +369,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { return; } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -378,8 +392,8 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { setShowManualPicker(false); if (detectedPlatformKey) { setPlatform(detectedPlatformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); - setProjectDetailsForm(undefined); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); + onClearProjectDetailsForm(); } } @@ -393,12 +407,12 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { !hasProjectDetailsStep && (isLoadingTeams || !projectsLoaded); async function handleContinue() { - // Persist derived defaults to context if user accepted them + // Persist derived defaults if the user accepted them without an explicit click if (currentPlatformKey && !selectedPlatform?.key) { setPlatform(currentPlatformKey); } if (!selectedFeatures) { - setSelectedFeatures(currentFeatures); + onFeaturesChange(currentFeatures); } if (!hasProjectDetailsStep) { @@ -435,7 +449,7 @@ export function ScmPlatformFeatures({onComplete, genBackButton}: StepProps) { default_rules: true, firstTeamSlug: firstAdminTeam?.slug, }); - setCreatedProjectSlug(project.slug); + onProjectCreated(project.slug); if (selectedRepository?.id) { try { diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index ec53836fe4fc..dc06d6aade79 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -5,26 +5,35 @@ import {TeamFixture} from 'sentry-fixture/team'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {MetricValues, RuleAction} from 'sentry/views/projectInstall/issueAlertOptions'; import {ScmProjectDetails} from './scmProjectDetails'; -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); +interface StateOverrides { + createdProjectSlug?: string; + projectDetailsForm?: ProjectDetailsFormState; + selectedFeatures?: ProductSolution[]; + selectedPlatform?: OnboardingSelectedSDK; + selectedRepository?: Repository; +} + +function defaultProps(state: StateOverrides = {}) { + return { + selectedPlatform: state.selectedPlatform, + selectedFeatures: state.selectedFeatures, + selectedRepository: state.selectedRepository, + createdProjectSlug: state.createdProjectSlug, + projectDetailsForm: state.projectDetailsForm, + onProjectCreated: jest.fn(), + onProjectDetailsFormChange: jest.fn(), + onComplete: jest.fn(), }; } @@ -44,7 +53,6 @@ describe('ScmProjectDetails', () => { const teamWithAccess = TeamFixture({slug: 'my-team', access: ['team:admin']}); beforeEach(() => { - sessionStorageWrapper.clear(); TeamStore.loadInitialData([teamWithAccess]); ProjectsStore.loadInitialData([]); @@ -67,37 +75,17 @@ describe('ScmProjectDetails', () => { }); it('renders step header with heading', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('Project details')).toBeInTheDocument(); }); it('renders section headers with icons', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('Give your project a name')).toBeInTheDocument(); expect(screen.getByText('Assign a team')).toBeInTheDocument(); @@ -106,83 +94,31 @@ describe('ScmProjectDetails', () => { }); it('renders project name defaulted from platform key', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); - - const input = await screen.findByPlaceholderText('project-name'); - expect(input).toHaveValue('javascript-nextjs'); - }); - - it('uses platform key as default name even when repository is in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - selectedRepository: mockRepository, - }), - } - ); + render(, { + organization, + }); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('javascript-nextjs'); }); it('renders card-style alert frequency options', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); expect(await screen.findByText('High priority issues')).toBeInTheDocument(); expect(screen.getByText('Custom')).toBeInTheDocument(); expect(screen.getByText("I'll create my own alerts later")).toBeInTheDocument(); }); - it('create project button is disabled without platform in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + it('create project button is disabled without platform', async () => { + render(, {organization}); expect(await screen.findByRole('button', {name: 'Create project'})).toBeDisabled(); }); it('create project button calls API and completes on success', async () => { - const onComplete = jest.fn(); - const projectCreationRequest = MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -203,19 +139,8 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); @@ -225,12 +150,11 @@ describe('ScmProjectDetails', () => { }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); it('links selected repository to project after creation', async () => { - const onComplete = jest.fn(); const createdProject = ProjectFixture({ slug: 'javascript-nextjs', name: 'javascript-nextjs', @@ -266,25 +190,16 @@ describe('ScmProjectDetails', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - selectedRepository: mockRepository, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + selectedRepository: mockRepository, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(repoLinkRequest).toHaveBeenCalledWith( @@ -297,25 +212,15 @@ describe('ScmProjectDetails', () => { }); it('defaults team selector to first admin team', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); // TeamSelector renders the team slug as the selected value expect(await screen.findByText(`#${teamWithAccess.slug}`)).toBeInTheDocument(); }); - it('updates context with project slug after creation', async () => { + it('stores project slug via onProjectCreated after creation', async () => { const createdProject = ProjectFixture({ slug: 'my-custom-project', name: 'my-custom-project', @@ -339,60 +244,37 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); - // Verify the project slug was stored separately in context (not overwriting - // selectedPlatform.key) so onboarding.tsx can find the project via - // useRecentCreatedProject while preserving the original platform selection. - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.createdProjectSlug).toBe('my-custom-project'); - expect(stored.selectedPlatform?.key).toBe('javascript-nextjs'); + expect(props.onProjectCreated).toHaveBeenCalledWith('my-custom-project'); }); - it('restores form inputs from persisted projectDetailsForm', async () => { + it('restores form inputs from projectDetailsForm prop', async () => { render( null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ + {...defaultProps({ selectedPlatform: mockPlatform, projectDetailsForm: { projectName: 'my-saved-name', teamSlug: teamWithAccess.slug, }, - }), - } + })} + />, + {organization} ); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('my-saved-name'); }); - it('persists form state to context on successful creation', async () => { + it('persists form state on successful creation', async () => { MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -411,36 +293,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.projectDetailsForm).toEqual( + expect(props.onProjectDetailsFormChange).toHaveBeenCalledWith( expect.objectContaining({ projectName: 'javascript-nextjs', teamSlug: teamWithAccess.slug, }) ); - expect(stored.projectDetailsForm.alertRuleConfig).toBeDefined(); }); it('reuses existing project when nothing changed on back-nav', async () => { @@ -458,37 +325,26 @@ describe('ScmProjectDetails', () => { body: existingProject, }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: existingProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: existingProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); expect(createRequest).not.toHaveBeenCalled(); expect(trackAnalyticsSpy).toHaveBeenCalledWith( @@ -522,32 +378,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: existingProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: existingProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); const input = await screen.findByPlaceholderText('project-name'); await userEvent.clear(input); @@ -559,7 +404,7 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); @@ -593,32 +438,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: stalePythonProject.slug, - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: stalePythonProject.slug, + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); @@ -626,7 +460,7 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); @@ -651,32 +485,21 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - createdProjectSlug: 'javascript-nextjs', - projectDetailsForm: { - projectName: 'javascript-nextjs', - teamSlug: teamWithAccess.slug, - alertRuleConfig: { - alertSetting: RuleAction.DEFAULT_ALERT, - interval: '1m', - metric: MetricValues.ERRORS, - threshold: '10', - }, - }, - }), - } - ); + const props = defaultProps({ + selectedPlatform: mockPlatform, + createdProjectSlug: 'javascript-nextjs', + projectDetailsForm: { + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + alertRuleConfig: { + alertSetting: RuleAction.DEFAULT_ALERT, + interval: '1m', + metric: MetricValues.ERRORS, + threshold: '10', + }, + }, + }); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); @@ -684,13 +507,11 @@ describe('ScmProjectDetails', () => { expect(createRequest).toHaveBeenCalled(); }); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); }); it('shows error message on project creation failure', async () => { - const onComplete = jest.fn(); - MockApiClient.addMockResponse({ url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, method: 'POST', @@ -698,44 +519,23 @@ describe('ScmProjectDetails', () => { body: {detail: 'Internal Error'}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); await waitFor(() => { - expect(onComplete).not.toHaveBeenCalled(); + expect(props.onComplete).not.toHaveBeenCalled(); }); }); it('fires step viewed analytics on mount', async () => { const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + render(, { + organization, + }); await screen.findByText('Project details'); @@ -766,26 +566,13 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - const onComplete = jest.fn(); - - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + const props = defaultProps({selectedPlatform: mockPlatform}); + render(, {organization}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); await waitFor(() => { - expect(onComplete).toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalled(); }); const eventKeys = trackAnalyticsSpy.mock.calls.map(call => call[0]); diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index f5b72758285a..f500378c5eb6 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -7,11 +7,14 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules'; import {TeamSelector} from 'sentry/components/teamSelector'; import {IconGroup, IconProject, IconSiren} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import {fetchMutation} from 'sentry/utils/queryClient'; @@ -32,17 +35,30 @@ import {ScmAlertFrequency} from './components/scmAlertFrequency'; import {ScmStepHeader} from './components/scmStepHeader'; import type {StepProps} from './types'; -export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { +interface ScmProjectDetailsProps { + createdProjectSlug: string | undefined; + onComplete: StepProps['onComplete']; + onProjectCreated: (slug: string | undefined) => void; + onProjectDetailsFormChange: (form: ProjectDetailsFormState | undefined) => void; + projectDetailsForm: ProjectDetailsFormState | undefined; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; + selectedRepository: Repository | undefined; + genBackButton?: StepProps['genBackButton']; +} + +export function ScmProjectDetails({ + createdProjectSlug, + onComplete, + onProjectCreated, + onProjectDetailsFormChange, + projectDetailsForm, + selectedFeatures, + selectedPlatform, + selectedRepository, + genBackButton, +}: ScmProjectDetailsProps) { const organization = useOrganization(); - const { - selectedPlatform, - selectedFeatures, - selectedRepository, - createdProjectSlug, - setCreatedProjectSlug, - projectDetailsForm, - setProjectDetailsForm, - } = useOnboardingContext(); const {teams, fetching: isLoadingTeams} = useTeams(); const {projects, initiallyLoaded: projectsLoaded} = useProjects(); const createProjectAndRules = useCreateProjectAndRules(); @@ -160,7 +176,7 @@ export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { // Store the project slug separately so onboarding.tsx can find // the project via useRecentCreatedProject without corrupting // selectedPlatform.key (which the platform features step needs). - setCreatedProjectSlug(project.slug); + onProjectCreated(project.slug); if (selectedRepository?.id) { try { @@ -174,7 +190,7 @@ export function ScmProjectDetails({onComplete, genBackButton}: StepProps) { } } - setProjectDetailsForm({ + onProjectDetailsFormChange({ projectName: projectNameResolved, teamSlug: teamSlugResolved, alertRuleConfig, From 6b8802f306e7af0c698d62aff883ac0f8e34b48d Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 27 May 2026 13:45:29 -0400 Subject: [PATCH 15/37] feat(github-enterprise): Route install through API pipeline modal (#116316) Add `github_enterprise` to the list of providers that use the new API-driven pipeline modal instead of the legacy popup-based flow. --- static/app/utils/integrations/useAddIntegration.tsx | 1 + .../onboarding/components/scmProviderPills.spec.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/static/app/utils/integrations/useAddIntegration.tsx b/static/app/utils/integrations/useAddIntegration.tsx index c6a3912976cc..e45844b9cfaf 100644 --- a/static/app/utils/integrations/useAddIntegration.tsx +++ b/static/app/utils/integrations/useAddIntegration.tsx @@ -50,6 +50,7 @@ const UNCONDITIONAL_API_PIPELINE_PROVIDERS = [ 'cursor', 'discord', 'github', + 'github_enterprise', 'gitlab', 'opsgenie', 'pagerduty', diff --git a/static/app/views/onboarding/components/scmProviderPills.spec.tsx b/static/app/views/onboarding/components/scmProviderPills.spec.tsx index e823ea1d2fd3..d75d949ce532 100644 --- a/static/app/views/onboarding/components/scmProviderPills.spec.tsx +++ b/static/app/views/onboarding/components/scmProviderPills.spec.tsx @@ -3,6 +3,8 @@ import {GitLabIntegrationProviderFixture} from 'sentry-fixture/gitlabIntegration import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import * as pipelineModal from 'sentry/components/pipeline/modal'; + import {ScmProviderPills} from './scmProviderPills'; const bitbucketProvider = GitHubIntegrationProviderFixture({ @@ -92,10 +94,9 @@ describe('ScmProviderPills', () => { }); it('triggers install flow when clicking a dropdown item', async () => { - const open = jest.spyOn(window, 'open').mockReturnValue({ - focus: jest.fn(), - close: jest.fn(), - } as any); + const openPipelineModalSpy = jest + .spyOn(pipelineModal, 'openPipelineModal') + .mockImplementation(() => {}); const providers = [GitHubIntegrationProviderFixture(), gitHubEnterpriseProvider]; @@ -104,7 +105,7 @@ describe('ScmProviderPills', () => { await userEvent.click(screen.getByRole('button', {name: 'More'})); await userEvent.click(screen.getByRole('menuitemradio', {name: 'GitHub Enterprise'})); - expect(open).toHaveBeenCalledTimes(1); + expect(openPipelineModalSpy).toHaveBeenCalledTimes(1); }); it('does not render "More" dropdown when all providers are primary', () => { From ec776243e719e761f79068dd45427c8ac7d5b2bd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 27 May 2026 10:49:51 -0700 Subject: [PATCH 16/37] fix(traces): Downgrade Group.DoesNotExist log to info in trace serialization (#116322) logger.error() in _serialize_rpc_issue causes Sentry to capture these as error events (~14K/day), but a deleted/merged group during trace rendering is an expected race condition. The function already handles this gracefully by returning None. Downgrade to logger.info to stop generating noise. Fixes SENTRY-5KWJ --- src/sentry/snuba/trace.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index 16b16b6227b7..c55f2a6d7ada 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -140,8 +140,8 @@ def _qualify_short_id(project: str, short_id: int | None) -> str | None: else: try: issue = Group.objects.get(id=issue_id, project__id=occurrence.project_id) - except Group.DoesNotExist as e: - logger.error(e) + except Group.DoesNotExist: + logger.info("Group %s not found in _serialize_rpc_issue", issue_id) return None group_cache[issue_id] = issue return SerializedIssue( @@ -171,8 +171,8 @@ def _qualify_short_id(project: str, short_id: int | None) -> str | None: else: try: issue = Group.objects.get(id=issue_id, project__id=event["project.id"]) - except Group.DoesNotExist as e: - logger.error(e) + except Group.DoesNotExist: + logger.info("Group %s not found in _serialize_rpc_issue", issue_id) return None group_cache[issue_id] = issue From 0e2f01cad85435bc969ffeee963bd743a41ff8e8 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 27 May 2026 10:53:40 -0700 Subject: [PATCH 17/37] ref(flags): Remove organizations:insights-alerts (#116223) Limited-access flag registered Aug 2024, never GA'd (~22 months). Remove the flag and its MRI alert gating from charts.py and snuba_query_validator.py. The custom-metrics flag still gates MRI access where needed. --- src/sentry/features/temporary.py | 2 -- src/sentry/incidents/charts.py | 7 ------- src/sentry/snuba/snuba_query_validator.py | 8 -------- 3 files changed, 17 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index e6e016d31261..2dfa5042237b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -343,8 +343,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-query-date-range-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True) # Make Insights overview pages use EAP instead of transactions (because eap is not on AM1) manager.add("organizations:insights-modules-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable access to insights metrics alerts - manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development diff --git a/src/sentry/incidents/charts.py b/src/sentry/incidents/charts.py index 232cbf1004b6..cf7df6e442e2 100644 --- a/src/sentry/incidents/charts.py +++ b/src/sentry/incidents/charts.py @@ -5,7 +5,6 @@ from django.utils import timezone -from sentry import features from sentry.api import client from sentry.api.base import logger from sentry.api.utils import get_datetime_from_stats_period @@ -210,15 +209,9 @@ def build_metric_alert_chart( ), } - allow_mri = features.has( - "organizations:insights-alerts", - organization, - actor=user, - ) aggregate = translate_aggregate_field( snuba_query.aggregate, reverse=True, - allow_mri=allow_mri, allow_eap=dataset == Dataset.EventsAnalyticsPlatform, ) # If we allow alerts to be across multiple orgs this will break diff --git a/src/sentry/snuba/snuba_query_validator.py b/src/sentry/snuba/snuba_query_validator.py index 17ee38964fc9..72f78320797c 100644 --- a/src/sentry/snuba/snuba_query_validator.py +++ b/src/sentry/snuba/snuba_query_validator.py @@ -265,10 +265,6 @@ def _validate_aggregate(self, data: dict[str, Any]) -> None: "organizations:custom-metrics", self.context["organization"], actor=self.context.get("user", None), - ) or features.has( - "organizations:insights-alerts", - self.context["organization"], - actor=self.context.get("user", None), ) allow_eap = dataset == Dataset.EventsAnalyticsPlatform @@ -304,10 +300,6 @@ def _validate_query(self, data: dict[str, Any]) -> None: "organizations:custom-metrics", self.context["organization"], actor=self.context.get("user", None), - ) or features.has( - "organizations:insights-alerts", - self.context["organization"], - actor=self.context.get("user", None), ): try: column_is_mri = is_mri( From c302e7b6c9f8caa4c99407ed5b6ebd4b88088c3c Mon Sep 17 00:00:00 2001 From: William Mak Date: Wed, 27 May 2026 14:02:30 -0400 Subject: [PATCH 18/37] fix(trace-item-details): allow timestamp (#116321) - This endpoint was defaulting to all time so queries were commonly timing out, add our usual snuba params style timestamps along with a trace style timestamp parameter with a buffer --- .../endpoints/project_trace_item_details.py | 43 +++++- .../test_project_trace_item_details.py | 135 +++++++++++++++++- 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index dc62ea7520b7..6efbba3419db 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -1,5 +1,6 @@ import time import uuid +from datetime import timedelta from typing import Any, Literal import sentry_sdk @@ -17,8 +18,10 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint from sentry.api.exceptions import BadRequest +from sentry.api.utils import get_date_range_from_params from sentry.auth.staff import is_active_staff from sentry.auth.superuser import is_active_superuser +from sentry.exceptions import InvalidParams from sentry.models.project import Project from sentry.search.eap import constants from sentry.search.eap.types import ( @@ -36,8 +39,10 @@ translate_search_type_for_internal_column, translate_to_sentry_conventions, ) +from sentry.search.utils import InvalidQuery, parse_datetime_string from sentry.snuba.referrer import Referrer -from sentry.utils import json, snuba_rpc +from sentry.utils import json +from sentry.utils.snuba_rpc import trace_item_details_rpc _NUMERIC_COERCIONS: dict[str, type] = {"valFloat": float, "valDouble": float} _VAL_TYPE_TO_COLUMN_TYPE: dict[str, ColumnType] = { @@ -362,9 +367,31 @@ def get(request: Request, project: Project, item_id: str) -> Response: if not serializer.is_valid(): return Response(serializer.errors, status=400) + try: + start, end = get_date_range_from_params(request.GET, optional=True) + except InvalidParams: + return Response("date range parameters invalid", status=400) + if "timestamp" in request.GET: + try: + example_timestamp = parse_datetime_string(request.GET["timestamp"]) + except InvalidQuery: + return Response("timestamp parameter invalid", status=400) + time_buffer = 1.5 + example_start = example_timestamp - timedelta(days=time_buffer) + example_end = example_timestamp + timedelta(days=time_buffer) + if start is not None: + start = max(start, example_start) + else: + start = example_start + if end is not None: + end = min(end, example_end) + else: + end = example_end + serialized = serializer.validated_data trace_id = serialized.get("trace_id") item_type = serialized.get("item_type") + sentry_sdk.set_tag("trace_item_details.item_type", item_type) referrer = serialized.get("referrer", Referrer.API_ORGANIZATION_TRACE_ITEM_DETAILS.value) trace_item_type = None @@ -377,12 +404,14 @@ def get(request: Request, project: Project, item_id: str) -> Response: raise BadRequest(detail=f"Unknown trace item type: {item_type}") start_timestamp_proto = ProtoTimestamp() - start_timestamp_proto.FromSeconds(0) - end_timestamp_proto = ProtoTimestamp() - - # due to clock drift, the end time can be in the future - add a week to be safe - end_timestamp_proto.FromSeconds(int(time.time()) + 60 * 60 * 24 * 7) + if start is not None and end is not None: + start_timestamp_proto.FromDatetime(start) + end_timestamp_proto.FromDatetime(end) + else: + start_timestamp_proto.FromSeconds(0) + # due to clock drift, the end time can be in the future - add a week to be safe + end_timestamp_proto.FromSeconds(int(time.time()) + 60 * 60 * 24 * 7) trace_id = request.GET.get("trace_id") if not trace_id: @@ -403,7 +432,7 @@ def get(request: Request, project: Project, item_id: str) -> Response: trace_id=trace_id, ) - resp = MessageToDict(snuba_rpc.trace_item_details_rpc(req)) + resp = MessageToDict(trace_item_details_rpc(req)) use_sentry_conventions = features.has( "organizations:performance-sentry-conventions-fields", diff --git a/tests/snuba/api/endpoints/test_project_trace_item_details.py b/tests/snuba/api/endpoints/test_project_trace_item_details.py index 7694cabfbad3..dc29c5c6f079 100644 --- a/tests/snuba/api/endpoints/test_project_trace_item_details.py +++ b/tests/snuba/api/endpoints/test_project_trace_item_details.py @@ -1,4 +1,5 @@ import uuid +from datetime import timedelta from unittest import mock import pytest @@ -32,7 +33,7 @@ def setUp(self) -> None: self.one_min_ago = before_now(minutes=1) self.trace_uuid = str(uuid.uuid4()).replace("-", "") - def do_request(self, event_type: str, item_id: str, features=None): + def do_request(self, event_type: str, item_id: str, extra_data=None, features=None): item_details_url = reverse( "sentry-api-0-project-trace-item-details", kwargs={ @@ -43,13 +44,16 @@ def do_request(self, event_type: str, item_id: str, features=None): ) if features is None: features = self.features + data = { + "item_type": event_type, + "trace_id": self.trace_uuid, + } + if extra_data is not None: + data.update(extra_data) with self.feature(features): return self.client.get( item_details_url, - { - "item_type": event_type, - "trace_id": self.trace_uuid, - }, + data, ) def test_simple(self) -> None: @@ -639,3 +643,124 @@ def test_attachment(self) -> None: "meta": {}, "timestamp": mock.ANY, } + + def test_with_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": self.one_min_ago.isoformat()}, + {"statsPeriod": "24h"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 200, trace_details_response.content + + timestamp_nanos = int(self.one_min_ago.timestamp() * 1_000_000_000) + assert trace_details_response.data["attributes"] == [ + {"name": "tags[bool_attr,boolean]", "type": "bool", "value": True}, + {"name": "tags[float_attr,number]", "type": "float", "value": 3.0}, + { + "name": "observed_timestamp", + "type": "int", + "value": str(timestamp_nanos), + }, + {"name": "project_id", "type": "int", "value": str(self.project.id)}, + {"name": "severity_number", "type": "int", "value": "0"}, + {"name": "tags[int_attr,number]", "type": "int", "value": "2"}, + { + "name": "timestamp_precise", + "type": "int", + "value": str(timestamp_nanos), + }, + {"name": "message", "type": "str", "value": "foo"}, + {"name": "severity", "type": "str", "value": "INFO"}, + {"name": "str_attr", "type": "str", "value": "1"}, + {"name": "trace", "type": "str", "value": self.trace_uuid}, + ] + assert trace_details_response.data["itemId"] == item_id + assert ( + trace_details_response.data["timestamp"] + == self.one_min_ago.replace(microsecond=0, tzinfo=None).isoformat() + "Z" + ) + + def test_with_incorrect_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": (self.one_min_ago - timedelta(days=30)).isoformat()}, + {"statsPeriodEnd": "24h", "statsPeriodStart": "48h"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 404, trace_details_response.content + + def test_with_invalid_timestamp(self) -> None: + log = self.create_ourlog( + { + "body": "foo", + "trace_id": self.trace_uuid, + }, + attributes={ + "str_attr": { + "string_value": "1", + }, + "int_attr": {"int_value": 2}, + "float_attr": { + "double_value": 3.0, + }, + "bool_attr": { + "bool_value": True, + }, + }, + timestamp=self.one_min_ago, + ) + self.store_eap_items([log]) + item_id = log.item_id.hex() + + for extra_data in [ + {"timestamp": "beepboop"}, + {"statsPeriod": "hello"}, + ]: + trace_details_response = self.do_request("logs", item_id, extra_data=extra_data) + + assert trace_details_response.status_code == 400, trace_details_response.content From 05073c187a00beab8e5767941633e75962f909be Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 27 May 2026 11:04:15 -0700 Subject: [PATCH 19/37] fix(preprod): Pre-filter latest base snapshot query by project access (#116319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move project access control in the latest-base-snapshot endpoint from a post-fetch check to a queryset-level filter. The old approach picked the globally latest artifact, then returned 404 if the user lacked access to its project — even when older, accessible artifacts existed. The new approach filters the queryset upfront using `request.access.accessible_project_ids`, so the query returns the latest artifact the user can actually see. Also adds an explicit `project__status=ObjectStatus.ACTIVE` filter to the base queryset so inactive/deleted projects are excluded for all users, including staff and global-access roles. This preserves the status check that the old `has_project_access()` call performed. Staff and `has_global_access` users (superusers, org owners) bypass the project-id filter entirely, keeping their existing behavior intact. Co-authored-by: Claude --- .../snapshots/preprod_artifact_snapshot_latest_base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py index 980f6ee27112..3e629dee0824 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py @@ -23,6 +23,7 @@ from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff +from sentry.constants import ObjectStatus from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import ( @@ -148,6 +149,7 @@ def get( qs = ( PreprodArtifact.objects.filter( project__organization_id=organization.id, + project__status=ObjectStatus.ACTIVE, app_id=app_id, preprodsnapshotmetrics__isnull=False, ) @@ -159,6 +161,9 @@ def get( .select_related("commit_comparison", "project", "preprodsnapshotmetrics") ) + if not is_active_staff(request) and not request.access.has_global_access: + qs = qs.filter(project_id__in=request.access.accessible_project_ids) + if project_id is not None: qs = qs.filter(project_id=project_id) if branch: @@ -169,9 +174,6 @@ def get( if artifact is None: return Response({"detail": "No snapshot found"}, status=404) - if not is_active_staff(request) and not request.access.has_project_access(artifact.project): - return Response({"detail": "No snapshot found"}, status=404) - snapshot_metrics = artifact.preprodsnapshotmetrics manifest_key = (snapshot_metrics.extras or {}).get("manifest_key") if not manifest_key: From c8284380407b6148e3306fd33d2c513dcf37d2cb Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 27 May 2026 11:19:13 -0700 Subject: [PATCH 20/37] feat(issues): Consolidate user feedback activity styles (#116318) Legacy user feedback is the last place using ActivityItem. This moves the markup and styles into the component so it owns the avatar + speech bubble layout locally. Types event.userReport and hides email when it is the same as the display name. Redash query where you can find issues that use legacy user feedback https://redash.getsentry.net/queries/11330/source New story for component image --- static/app/components/activity/author.tsx | 8 - .../app/components/activity/item/bubble.tsx | 52 ----- static/app/components/activity/item/index.tsx | 159 ---------------- .../components/events/userFeedback.spec.tsx | 89 +++++++++ .../events/userFeedback.stories.tsx | 33 ++++ static/app/components/events/userFeedback.tsx | 177 +++++++++++------- static/app/types/event.tsx | 4 +- static/app/types/group.tsx | 11 +- static/app/utils.tsx | 4 - .../groupEventDetailsContent.tsx | 7 +- .../views/issueDetails/groupUserFeedback.tsx | 19 +- .../sharedGroupDetails/sharedEventContent.tsx | 8 +- 12 files changed, 256 insertions(+), 315 deletions(-) delete mode 100644 static/app/components/activity/author.tsx delete mode 100644 static/app/components/activity/item/bubble.tsx delete mode 100644 static/app/components/activity/item/index.tsx create mode 100644 static/app/components/events/userFeedback.spec.tsx create mode 100644 static/app/components/events/userFeedback.stories.tsx diff --git a/static/app/components/activity/author.tsx b/static/app/components/activity/author.tsx deleted file mode 100644 index 3bee67baaa05..000000000000 --- a/static/app/components/activity/author.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import styled from '@emotion/styled'; - -const ActivityAuthor = styled('span')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - font-size: ${p => p.theme.font.size.md}; -`; - -export {ActivityAuthor}; diff --git a/static/app/components/activity/item/bubble.tsx b/static/app/components/activity/item/bubble.tsx deleted file mode 100644 index e6e39a768b19..000000000000 --- a/static/app/components/activity/item/bubble.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import styled from '@emotion/styled'; - -export interface ActivityBubbleProps extends React.HTMLAttributes { - backgroundColor?: string; - borderColor?: string; -} - -/** - * This creates a bordered box that has a left pointing arrow - * on the left-side at the top. - */ -const ActivityBubble = styled('div')` - display: flex; - justify-content: center; - flex-direction: column; - align-items: stretch; - flex: 1; - background-color: ${p => p.backgroundColor || p.theme.tokens.background.primary}; - border: 1px solid ${p => p.borderColor || p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - position: relative; - width: 100%; /* this is used in Incidents Details - a chart can cause overflow and won't resize properly */ - - &:before { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid ${p => p.borderColor || p.theme.tokens.border.primary}; - position: absolute; - left: -7px; - top: 12px; - } - - &:after { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ - border-right: 6px solid ${p => p.backgroundColor || p.theme.tokens.background.primary}; - position: absolute; - left: -6px; - top: 13px; - } -`; - -export {ActivityBubble}; diff --git a/static/app/components/activity/item/index.tsx b/static/app/components/activity/item/index.tsx deleted file mode 100644 index fc2705df6d12..000000000000 --- a/static/app/components/activity/item/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {Flex} from '@sentry/scraps/layout'; - -import {DateTime} from 'sentry/components/dateTime'; -import {TimeSince} from 'sentry/components/timeSince'; -import {textStyles} from 'sentry/styles/text'; -import type {AvatarUser} from 'sentry/types/user'; - -import {ActivityAvatar} from './avatar'; -import type {ActivityBubbleProps} from './bubble'; -import {ActivityBubble} from './bubble'; - -type ActivityAuthorType = 'user' | 'system'; - -interface ActivityItemProps { - /** - * Used to render an avatar for the author. Currently can be a user, otherwise - * defaults as a "system" avatar (i.e. sentry) - * - * `user` is required if `type` is "user" - */ - author?: { - type: ActivityAuthorType; - user?: AvatarUser; - }; - avatarSize?: number; - bubbleProps?: ActivityBubbleProps; - children?: React.ReactNode; - - className?: string; - /** - * If supplied, will show the time that the activity started - */ - date?: string | Date; - /** - * Can be a react node or a render function. render function will not include default wrapper - */ - header?: React.ReactNode; - /** - * Do not show the date in the header - */ - hideDate?: boolean; - /** - * This is used to uniquely identify the activity item for use as an anchor - */ - id?: string; - /** - * If supplied, will show the interval that the activity occurred in - */ - interval?: number; - /** - * Removes padding on the activtiy body - */ - noPadding?: boolean; - /** - * Show exact time instead of relative date/time. - */ - showTime?: boolean; -} - -function ActivityItem({ - author, - avatarSize, - bubbleProps, - className, - children, - date, - interval, - noPadding, - id, - header, - hideDate = false, - showTime = false, -}: ActivityItemProps) { - const showDate = !hideDate && date && !interval; - const showRange = !hideDate && date && interval; - const dateEnded = showRange - ? moment(date).add(interval, 'minutes').utc().format() - : undefined; - const timeOnly = Boolean( - date && dateEnded && moment(date).date() === moment(dateEnded).date() - ); - - return ( - - {id && } - - {author && ( - - )} - - - {header && ( - - {header} - {date && showDate && !showTime && } - {date && showDate && showTime && } - - {showRange && ( - - - {' — '} - - - )} - - )} - - {children && (noPadding ? children : {children})} - - - ); -} - -const ActivityHeader = styled('div')` - display: flex; - align-items: center; - padding: 6px ${p => p.theme.space.xl}; - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; - font-size: ${p => p.theme.font.size.md}; - - &:last-child { - border-bottom: none; - } -`; - -const ActivityHeaderContent = styled('div')` - flex: 1; -`; - -const ActivityBody = styled('div')` - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl}; - ${textStyles} -`; - -const StyledActivityAvatar = styled(ActivityAvatar)` - margin-right: ${p => p.theme.space.md}; -`; - -const StyledTimeSince = styled(TimeSince)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledDateTime = styled(DateTime)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledDateTimeWindow = styled('div')` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const StyledActivityBubble = styled(ActivityBubble)` - width: 75%; - overflow-wrap: break-word; -`; - -export {ActivityItem}; diff --git a/static/app/components/events/userFeedback.spec.tsx b/static/app/components/events/userFeedback.spec.tsx new file mode 100644 index 000000000000..67680cc7055a --- /dev/null +++ b/static/app/components/events/userFeedback.spec.tsx @@ -0,0 +1,89 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import type {UserReport} from 'sentry/types/group'; +import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; + +import {EventUserFeedback} from './userFeedback'; + +jest.mock('sentry/utils/useCopyToClipboard'); +const mockCopy = jest.fn(); +jest.mocked(useCopyToClipboard).mockReturnValue({copy: mockCopy}); + +function makeReport(overrides: Partial = {}): UserReport { + return { + comments: 'Line one\n', + dateCreated: '2024-01-01T00:00:00.000Z', + email: 'jane@example.com', + event: {eventID: 'abc123', id: '1'}, + eventID: 'abc123', + id: '1', + issue: {} as UserReport['issue'], + name: 'Jane Reporter', + user: { + avatarUrl: null, + email: 'jane@example.com', + id: '1', + ipAddress: null, + name: 'Jane Reporter', + username: 'jane', + }, + ...overrides, + }; +} + +describe('EventUserFeedback', () => { + beforeEach(() => { + mockCopy.mockClear(); + }); + + it('renders feedback details and copies the reporter email', async () => { + render( + + ); + + expect(screen.getByText('Jane Reporter')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'View event'})).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/123/events/abc123/?referrer=user-feedback' + ); + + const emailButton = screen.getByRole('button', {name: 'jane@example.com'}); + await userEvent.click(emailButton); + + expect(mockCopy).toHaveBeenCalledWith('jane@example.com', { + successMessage: 'Copied email to clipboard', + }); + }); + + it('does not repeat the email when it matches the reporter name', async () => { + render( + + ); + + expect(screen.getAllByText('Jane@Example.com')).toHaveLength(1); + + await userEvent.click(screen.getByRole('button', {name: 'Copy email address'})); + + expect(mockCopy).toHaveBeenCalledWith('jane@example.com', { + successMessage: 'Copied email to clipboard', + }); + }); + + it('preserves comment text without rendering html and hides the event link', () => { + render(); + + expect(screen.queryByRole('link', {name: 'View event'})).not.toBeInTheDocument(); + expect(screen.getByTestId('letter_avatar-avatar')).toHaveTextContent('JR'); + expect(document.querySelector('script')).not.toBeInTheDocument(); + const comment = document.querySelector('p'); + expect(comment?.textContent).toBe('Line one\n'); + }); +}); diff --git a/static/app/components/events/userFeedback.stories.tsx b/static/app/components/events/userFeedback.stories.tsx new file mode 100644 index 000000000000..b99db848e4df --- /dev/null +++ b/static/app/components/events/userFeedback.stories.tsx @@ -0,0 +1,33 @@ +import * as Storybook from 'sentry/stories'; +import type {UserReport} from 'sentry/types/group'; + +import {EventUserFeedback} from './userFeedback'; + +const report: UserReport = { + comments: 'The checkout button did nothing after I submitted payment.\nI tried twice.', + dateCreated: '2024-01-01T00:00:00.000Z', + email: 'jane@example.com', + event: {eventID: 'abc123', id: '1'}, + eventID: 'abc123', + id: '1', + name: 'Jane Reporter', + user: { + avatarUrl: null, + email: 'jane@example.com', + id: '1', + ipAddress: null, + name: 'Jane Reporter', + username: 'jane', + }, +}; + +export default Storybook.story('EventUserFeedback', story => { + story('Default', () => ( +
+ +
+ )); +}); diff --git a/static/app/components/events/userFeedback.tsx b/static/app/components/events/userFeedback.tsx index ddc9478a1063..c3f2cd05da35 100644 --- a/static/app/components/events/userFeedback.tsx +++ b/static/app/components/events/userFeedback.tsx @@ -1,95 +1,140 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; -import {ActivityAuthor} from 'sentry/components/activity/author'; -import {ActivityItem} from 'sentry/components/activity/item'; +import {ActivityAvatar} from 'sentry/components/activity/item/avatar'; +import {TimeSince} from 'sentry/components/timeSince'; import {IconCopy} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {UserReport} from 'sentry/types/group'; -import {escape, nl2br} from 'sentry/utils'; +import type {AvatarUser} from 'sentry/types/user'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; type Props = { - issueId: string; - orgSlug: string; report: UserReport; - className?: string; - showEventLink?: boolean; + eventLink?: string; }; -export function EventUserFeedback({ - className, - report, - orgSlug, - issueId, - showEventLink = true, -}: Props) { - const user = report.user || { - name: report.name, - email: report.email, - id: '', - username: '', - ip_address: '', - }; - +export function EventUserFeedback({eventLink, report}: Props) { const {copy} = useCopyToClipboard(); + const showEmailLabel = !isSameIdentity(report.name, report.email); + const copyEmail = () => + copy(report.email, {successMessage: t('Copied email to clipboard')}); return ( -
- - {report.name} - + + + + + + + {report.name} + + + + {eventLink && ( + + {t('View event')} + )} - } - > -

- -

+ + + + +
+ + + + {report.comments} + + + + ); } -const StyledActivityItem = styled(ActivityItem)` - margin-bottom: 0; -`; +function isSameIdentity(name: string, email: string) { + return name.trim().toLowerCase() === email.trim().toLowerCase(); +} -const CopyButton = styled(Button)` - color: ${p => p.theme.tokens.content.secondary}; - font-size: ${p => p.theme.font.size.sm}; - font-weight: ${p => p.theme.font.weight.sans.regular}; -`; +function getAvatarUser(report: UserReport): AvatarUser | undefined { + const user = report.user; + + if (!user) { + return { + id: '', + email: report.email, + name: report.name, + username: '', + ip_address: '', + }; + } + + return { + id: user.id, + email: user.email ?? '', + name: user.name ?? report.name, + username: user.username ?? '', + ip_address: user.ipAddress ?? '', + avatarUrl: user.avatarUrl ?? undefined, + }; +} + +const FeedbackBubble = styled('div')` + display: flex; + justify-content: center; + flex-direction: column; + align-items: stretch; + flex: 1; + width: 75%; + overflow-wrap: break-word; + background-color: ${p => p.theme.tokens.background.primary}; + border: 1px solid ${p => p.theme.tokens.border.primary}; + border-radius: ${p => p.theme.radius.md}; + position: relative; -const StyledIconCopy = styled(IconCopy)``; + &:before { + display: block; + content: ''; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid ${p => p.theme.tokens.border.primary}; + position: absolute; + left: -7px; + top: 12px; + } -const ViewEventLink = styled(Link)` - font-weight: ${p => p.theme.font.weight.sans.regular}; - font-size: 0.9em; + &:after { + display: block; + content: ''; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + border-right: 6px solid ${p => p.theme.tokens.background.primary}; + position: absolute; + left: -6px; + top: 13px; + } `; diff --git a/static/app/types/event.tsx b/static/app/types/event.tsx index e159a6de9b1b..18acdce37cd2 100644 --- a/static/app/types/event.tsx +++ b/static/app/types/event.tsx @@ -12,7 +12,7 @@ import type {SymbolicatorStatus} from 'sentry/components/events/interfaces/types import type {RawCrumb} from './breadcrumbs'; import type {Image} from './debugImage'; -import type {IssueAttachment, IssueCategory, IssueType} from './group'; +import type {IssueAttachment, IssueCategory, IssueType, UserReport} from './group'; import type {PlatformKey} from './project'; import type {Release} from './release'; import type {StackTraceMechanism, StacktraceType} from './stacktrace'; @@ -789,7 +789,7 @@ interface EventBase { version: string | null; } | null; sdkUpdates?: SDKUpdatesSuggestion[]; - userReport?: any; + userReport?: UserReport | null; } interface TraceEventContexts extends EventContexts { diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index d2c2c27444e2..9cf74b4a918d 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -1266,9 +1266,16 @@ export type UserReport = { event: {eventID: string; id: string}; eventID: string; id: string; - issue: Group; name: string; - user: User; + user: { + avatarUrl: string | null; + email: string | null; + id: string; + ipAddress: string | null; + name: string | null; + username: string | null; + } | null; + issue?: Group | null; }; export type KeyValueListDataItem = { diff --git a/static/app/utils.tsx b/static/app/utils.tsx index cb24e64fdda0..7ef5fb24d8b3 100644 --- a/static/app/utils.tsx +++ b/static/app/utils.tsx @@ -18,10 +18,6 @@ export function defined(item: T): item is Exclude { return item !== undefined && item !== null; } -export function nl2br(str: string): string { - return str.replace(/\r\n|\r|\n/g, '
'); -} - export function escape(str: string): string { return str .replace(/&/g, '&') diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index 1299bd6b392b..d62f01539bb2 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -150,12 +150,7 @@ export function EventDetailsContent({ )} {event.userReport && ( - + )} {(event.contexts?.metric_alert?.alert_rule_id || diff --git a/static/app/views/issueDetails/groupUserFeedback.tsx b/static/app/views/issueDetails/groupUserFeedback.tsx index d245aea0a572..a84d02fae4de 100644 --- a/static/app/views/issueDetails/groupUserFeedback.tsx +++ b/static/app/views/issueDetails/groupUserFeedback.tsx @@ -1,7 +1,7 @@ -import {Fragment} from 'react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; +import {Stack} from '@sentry/scraps/layout'; import {Pagination} from '@sentry/scraps/pagination'; import {EventUserFeedback} from 'sentry/components/events/userFeedback'; @@ -78,27 +78,22 @@ function GroupUserFeedback() { {reportList.length === 0 ? ( ) : ( - - {reportList.map((item, idx) => ( - + {reportList.map(item => ( + ))} - + )} ); } -const StyledEventUserFeedback = styled(EventUserFeedback)` - margin-bottom: ${p => p.theme.space.xl}; -`; - const StyledLayoutBody = styled(Layout.Body)` border: 1px solid ${p => p.theme.tokens.border.primary}; border-radius: ${p => p.theme.radius.md}; diff --git a/static/app/views/sharedGroupDetails/sharedEventContent.tsx b/static/app/views/sharedGroupDetails/sharedEventContent.tsx index f324c49c1052..ff09f89ed2f8 100644 --- a/static/app/views/sharedGroupDetails/sharedEventContent.tsx +++ b/static/app/views/sharedGroupDetails/sharedEventContent.tsx @@ -37,16 +37,16 @@ export function SharedEventContent({organization, project, event, group}: Props) } const projectSlug = project.slug; + const userReport = event.userReport; return (
- {event.userReport && ( + {userReport && ( From 4142da1de033fc1701268bda9bbe09abbf2507a1 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 27 May 2026 11:19:41 -0700 Subject: [PATCH 21/37] feat(issues): Use shared markdown component for activity notes (#116300) Activity notes and the note preview were still using MarkedText with local markdown styles layered on top. This swaps both to the core Markdown component and lets it own the rendered markdown styling. Only real functional change is internal links (missing https://) are no longer allowed, which i think is fine. before image after image --- static/app/components/activity/note/body.tsx | 47 +---- .../app/components/activity/note/compact.tsx | 47 +---- .../components/activity/note/input.spec.tsx | 14 +- static/app/components/activity/note/input.tsx | 178 +++++++----------- .../components/activity/note/mentionStyle.tsx | 2 + .../activitySection/index.spec.tsx | 26 +++ .../issueDetails/activitySection/index.tsx | 10 +- 7 files changed, 115 insertions(+), 209 deletions(-) diff --git a/static/app/components/activity/note/body.tsx b/static/app/components/activity/note/body.tsx index 1f9476918270..0db775c65d94 100644 --- a/static/app/components/activity/note/body.tsx +++ b/static/app/components/activity/note/body.tsx @@ -1,50 +1,15 @@ -import styled from '@emotion/styled'; - -import {MarkedText} from 'sentry/utils/marked/markedText'; +import {Markdown} from '@sentry/scraps/markdown'; type Props = { text: string; }; function NoteBody({text}: Props) { - return ; + return ( +
+ +
+ ); } -const StyledNoteBody = styled(MarkedText)` - ul { - list-style: disc; - } - - h1, - h2, - h3, - h4, - p, - ul:not(.nav), - ol, - pre, - hr, - blockquote { - margin-bottom: ${p => p.theme.space.xl}; - } - - ul, - ol { - padding-left: 20px; - } - - p { - a { - word-wrap: break-word; - } - } - - blockquote { - font-size: 15px; - border-left: 5px solid ${p => p.theme.tokens.border.secondary}; - padding-left: ${p => p.theme.space.md}; - margin-left: 0; - } -`; - export {NoteBody}; diff --git a/static/app/components/activity/note/compact.tsx b/static/app/components/activity/note/compact.tsx index 54078f116e17..d7e122a1c0ce 100644 --- a/static/app/components/activity/note/compact.tsx +++ b/static/app/components/activity/note/compact.tsx @@ -1,8 +1,7 @@ import {useCallback, useId, useState} from 'react'; import type {MentionsInputProps} from 'react-mentions'; import {Mention, MentionsInput} from 'react-mentions'; -import type {Theme} from '@emotion/react'; -import {css, useTheme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -152,7 +151,6 @@ export function CompactNoteInput({ borderRadius: theme.radius.md, }, }), - width: '100%', }} placeholder={placeholder} onChange={handleChange} @@ -200,46 +198,7 @@ export function CompactNoteInput({ ); } -const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => { - if (!p.error) { - return ''; - } - - return css` - color: ${p.theme.tokens.content.danger}; - margin: -1px; - border: 1px solid ${p.theme.tokens.border.danger}; - border-radius: ${p.theme.radius.md}; - - &:before { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid ${p.theme.colors.red400}; - position: absolute; - left: -7px; - top: 12px; - } - - &:after { - display: block; - content: ''; - width: 0; - height: 0; - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-right: 6px solid #fff; - position: absolute; - left: -5px; - top: 12px; - } - `; -}; - -const NoteInputForm = styled('form')<{error?: string}>` +const NoteInputForm = styled('form')` display: flex; flex-direction: column; gap: ${p => p.theme.space.sm}; @@ -247,6 +206,4 @@ const NoteInputForm = styled('form')<{error?: string}>` width: 100%; min-width: 0; transition: padding 0.2s ease-in-out; - - ${getNoteInputErrorStyles}; `; diff --git a/static/app/components/activity/note/input.spec.tsx b/static/app/components/activity/note/input.spec.tsx index 9d75873f853f..8245aceecdfb 100644 --- a/static/app/components/activity/note/input.spec.tsx +++ b/static/app/components/activity/note/input.spec.tsx @@ -104,7 +104,7 @@ describe('NoteInput', () => { describe('Existing Item', () => { const props = { noteId: 'item-id', - text: 'an existing item', + text: 'an **existing** [item](https://docs.sentry.io/)', }; it('edits existing message', async () => { @@ -114,18 +114,24 @@ describe('NoteInput', () => { // Switch to preview await userEvent.click(screen.getByRole('radio', {name: 'Preview'})); - expect(screen.getByText('an existing item')).toBeInTheDocument(); + expect(screen.getByText('existing').closest('strong')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'item'})).toHaveAttribute( + 'href', + 'https://docs.sentry.io/' + ); // Switch to edit await userEvent.click(screen.getByRole('radio', {name: 'Edit'})); - expect(screen.getByRole('textbox')).toHaveTextContent('an existing item'); + expect(screen.getByRole('textbox')).toHaveTextContent( + 'an **existing** [item](https://docs.sentry.io/)' + ); // Can edit text await userEvent.type(screen.getByRole('textbox'), ' new content{Control>}{Enter}'); expect(onUpdate).toHaveBeenCalledWith({ - text: 'an existing item new content', + text: 'an **existing** [item](https://docs.sentry.io/) new content', mentions: [], }); }); diff --git a/static/app/components/activity/note/input.tsx b/static/app/components/activity/note/input.tsx index c2fca1e9f9a7..2baed82c40ba 100644 --- a/static/app/components/activity/note/input.tsx +++ b/static/app/components/activity/note/input.tsx @@ -1,7 +1,6 @@ import {useCallback, useId, useState} from 'react'; import {Mention, MentionsInput} from 'react-mentions'; -import type {Theme} from '@emotion/react'; -import {css, useTheme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion, useReducedMotion} from 'framer-motion'; import {z} from 'zod'; @@ -9,13 +8,13 @@ import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; import {Flex} from '@sentry/scraps/layout'; +import {Markdown} from '@sentry/scraps/markdown'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; import {IconMarkdown} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {textStyles} from 'sentry/styles/text'; import type {NoteType} from 'sentry/types/alerts'; -import {MarkedText} from 'sentry/utils/marked/markedText'; import {useMemberMentionData} from 'sentry/utils/members/useMemberMentionData'; import {useTeams} from 'sentry/utils/useTeams'; @@ -156,65 +155,62 @@ export function NoteInput({ {field => ( - +
{editorMode === 'write' ? ( > {({ref, ...fieldProps}) => ( - - { - setAreControlsVisible(true); - field.handleChange(e.target.value); - onChange?.(e, {updating: existingItem}); - }} - onFocus={() => setAreControlsVisible(true)} - onKeyDown={e => { - if ( - e.key === 'Enter' && - (e.metaKey || e.ctrlKey) && - field.state.value.trim() !== '' - ) { - e.preventDefault(); - form.handleSubmit(); - } - }} - value={field.state.value} - required - autoFocus={existingItem} - > - `@${display}`} - markup="**[sentry.strip:member]__display__**" - appendSpaceOnAdd - /> - display} - appendSpaceOnAdd - /> - - + { + setAreControlsVisible(true); + field.handleChange(e.target.value); + onChange?.(e, {updating: existingItem}); + }} + onFocus={() => setAreControlsVisible(true)} + onKeyDown={e => { + if ( + e.key === 'Enter' && + (e.metaKey || e.ctrlKey) && + field.state.value.trim() !== '' + ) { + e.preventDefault(); + form.handleSubmit(); + } + }} + value={field.state.value} + required + autoFocus={existingItem} + > + `@${display}`} + markup="**[sentry.strip:member]__display__**" + appendSpaceOnAdd + /> + display} + appendSpaceOnAdd + /> + )} ) : ( - + + + )} - +
)}
@@ -236,16 +232,20 @@ export function NoteInput({ {t('Preview')} - - - {t('Markdown supported')} - + + + + {t('Markdown supported')} + + {errorMessage && ( -
- {errorMessage} -
+ + + {errorMessage} + + )} {existingItem && ( @@ -270,62 +270,20 @@ export function NoteInput({ ); } -type NotePreviewProps = { - minHeight: Props['minHeight']; - theme: Theme; -}; - -const getNotePreviewCss = (p: NotePreviewProps) => css` - max-height: 1000px; - max-width: 100%; - ${p.minHeight - ? css` - min-height: ${p.minHeight}px; - ` - : ''}; - padding: ${p.theme.space.lg} ${p.theme.space.lg}; - overflow: auto; - border: 0; -`; - const EditorSurface = styled('div')` background: ${p => p.theme.tokens.background.primary}; border: 1px solid ${p => p.theme.tokens.border.primary}; border-radius: ${p => p.theme.radius.md}; `; -const NoteInputPanel = styled('div')` - ${textStyles} -`; - -const MentionsEditor = styled('div')` - flex: 1; - min-width: 0; -`; - const MotionControls = styled(motion.div)` overflow: hidden; isolation: isolate; `; -const ErrorMessage = styled('span')` - display: flex; - align-items: center; - height: 100%; - color: ${p => p.theme.tokens.content.danger}; - font-size: 0.9em; -`; - -const MarkdownIndicator = styled('span')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - color: ${p => p.theme.tokens.content.secondary}; - font-size: ${p => p.theme.font.size.sm}; -`; - -const NotePreview = styled(MarkedText, { - shouldForwardProp: prop => prop !== 'minHeight', -})<{minHeight: Props['minHeight']}>` - ${p => getNotePreviewCss(p)}; +const NotePreview = styled('div')` + max-height: 1000px; + max-width: 100%; + padding: ${p => p.theme.space.lg}; + overflow: auto; `; diff --git a/static/app/components/activity/note/mentionStyle.tsx b/static/app/components/activity/note/mentionStyle.tsx index 303ebf6b8d02..d5fd82d61714 100644 --- a/static/app/components/activity/note/mentionStyle.tsx +++ b/static/app/components/activity/note/mentionStyle.tsx @@ -23,6 +23,8 @@ export function mentionStyle({theme, minHeight, inputStyle}: Options) { }; return { + width: '100%', + control: { backgroundColor: 'transparent', fontSize: theme.font.size.md, diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 88111caf156c..2fabb552aea5 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -174,6 +174,32 @@ describe('ActivitySection', () => { expect(screen.queryByText('Test Note')).not.toBeInTheDocument(); }); + it('renders note markdown', async () => { + const activityGroup = GroupFixture({ + id: '1338', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: '**Bold Note** and [docs](https://docs.sentry.io/)'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + project, + }); + + render(); + + expect(await screen.findByTestId('activity-note-body')).toContainElement( + screen.getByText('Bold Note').closest('strong') + ); + expect(screen.getByRole('link', {name: 'docs'})).toHaveAttribute( + 'href', + 'https://docs.sentry.io/' + ); + }); + it('renders activity actor markers', async () => { const activityGroup = GroupFixture({ id: '1338', diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 630947ce9361..a2a4bb301f1a 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -17,7 +17,6 @@ import {TimeSince} from 'sentry/components/timeSince'; import {IconEllipsis} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {GroupStore} from 'sentry/stores/groupStore'; -import {textStyles} from 'sentry/styles/text'; import type {NoteType} from 'sentry/types/alerts'; import type {Group, GroupActivity, GroupActivityNote} from 'sentry/types/group'; import {GroupActivityType, SEER_ACTIVITY_TYPES} from 'sentry/types/group'; @@ -205,9 +204,7 @@ function TimelineItem({ onCancel={() => setEditing(false)} /> ) : typeof message === 'string' ? ( - - - + ) : ( {message} @@ -521,11 +518,6 @@ const MoreActivityIcon = styled('div')` background: ${p => p.theme.tokens.background.primary}; `; -const NoteWrapper = styled('div')<{size: 'sm' | 'md'}>` - ${textStyles} - font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; -`; - const ActivityInputFrame = styled('div')` color: ${p => p.theme.tokens.content.primary}; min-width: 0; From ca6aa27adc2e83b84a1a8ea1fc03e251b3da8056 Mon Sep 17 00:00:00 2001 From: michelletran-sentry <167130096+michelletran-sentry@users.noreply.github.com> Date: Wed, 27 May 2026 14:28:12 -0400 Subject: [PATCH 22/37] fix(oauth): Use hashed token lookup and reject tokens for inactive users (#116323) Fixed 2 vulns: - Use hashed token lookup in `/oauth/userinfo` endpoint. The endpoint was looking up tokens by plaintext column only, diverging from the standard hash-first path used everywhere else. Switch to `hashed_token` lookup with plaintext fallback and migration, matching `UserAuthTokenAuthentication._find_or_update_token_by_hash`. - Reject tokens for inactive/suspended users and disabled apps in '/oauth/userinfo`. The endpoint was missing is_active checks that the standard `UserAuthTokenAuthentication` path enforces. Tokens belonging to deactivated users or disabled OAuth applications now return 401. --------- Co-authored-by: Claude --- src/sentry/api/endpoints/oauth_userinfo.py | 26 +++++++++- .../api/endpoints/test_oauth_userinfo.py | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/oauth_userinfo.py b/src/sentry/api/endpoints/oauth_userinfo.py index 7078881e3681..9210eda69141 100644 --- a/src/sentry/api/endpoints/oauth_userinfo.py +++ b/src/sentry/api/endpoints/oauth_userinfo.py @@ -1,3 +1,5 @@ +import hashlib + from rest_framework import status from rest_framework.authentication import get_authorization_header from rest_framework.exceptions import APIException @@ -80,14 +82,34 @@ def get(self, request: Request) -> Response: raise BearerTokenMissing() access_token = auth_header[1].decode("utf-8") + hashed_token = hashlib.sha256(access_token.encode()).hexdigest() try: - token_details = ApiToken.objects.get(token=access_token) + token_details = ApiToken.objects.select_related("user", "application").get( + hashed_token=hashed_token + ) except ApiToken.DoesNotExist: - raise BearerTokenInvalid() + try: + token_details = ApiToken.objects.select_related("user", "application").get( + token=access_token + ) + except ApiToken.DoesNotExist: + raise BearerTokenInvalid() + else: + token_details.hashed_token = hashed_token + token_details.save(update_fields=["hashed_token"]) if token_details.is_expired(): raise BearerTokenInvalid() + if not token_details.user.is_active: + raise BearerTokenInvalid() + + if getattr(token_details.user, "is_suspended", False): + raise BearerTokenInvalid() + + if token_details.application is not None and not token_details.application.is_active: + raise BearerTokenInvalid() + scopes = token_details.get_scopes() if "openid" not in scopes: raise BearerTokenInsufficientScope() diff --git a/tests/sentry/api/endpoints/test_oauth_userinfo.py b/tests/sentry/api/endpoints/test_oauth_userinfo.py index 253eefeac240..66ebe46a0768 100644 --- a/tests/sentry/api/endpoints/test_oauth_userinfo.py +++ b/tests/sentry/api/endpoints/test_oauth_userinfo.py @@ -1,9 +1,12 @@ import datetime +import hashlib from django.urls import reverse from django.utils import timezone from rest_framework.test import APIClient +from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus +from sentry.models.apitoken import ApiToken from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test @@ -147,3 +150,52 @@ def test_gets_multiple_scopes(self) -> None: # openid information assert response.data["sub"] == str(self.user.id) + + def test_resolves_token_by_hash_when_plaintext_cleared(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + plaintext = token.token + expected_hash = hashlib.sha256(plaintext.encode()).hexdigest() + assert token.hashed_token == expected_hash + + # Scramble the plaintext column so only the hashed path can match + ApiToken.objects.filter(id=token.id).update(token=f"scrambled-{plaintext[:50]}") + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {plaintext}") + response = self.client.get(self.path) + + assert response.status_code == 200 + assert response.data["sub"] == str(self.user.id) + + def test_rejects_token_for_inactive_user(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + self.user.update(is_active=False) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" + + def test_rejects_token_for_suspended_user(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["openid"]) + self.user.update(is_suspended=True) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" + + def test_rejects_token_for_inactive_application(self) -> None: + app = ApiApplication.objects.create( + name="test-app", + redirect_uris="https://example.com/callback", + ) + token = self.create_user_auth_token(user=self.user, scope_list=["openid"], application=app) + app.update(status=ApiApplicationStatus.inactive) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") + response = self.client.get(self.path) + + assert response.status_code == 401 + assert response.data["error"] == "invalid_token" From d0225787959b175bc0f39b1c91ab17624a105248 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 27 May 2026 12:08:55 -0700 Subject: [PATCH 23/37] fix(metrics): Skip tag validation when deleting Snuba subscriptions (#116325) The skip_field_validation_for_entity_subscription_deletion flag was added to bypass query validation during subscription deletion, but it only covered default_filter_converter. The resolve_tag_key method was still raising IncompatibleMetricsQuery for tags not in default_metric_tags (e.g. http.url), causing deletion to fail. Fixes SENTRY-5HAG --- src/sentry/search/events/builder/metrics.py | 5 +++- .../search/events/builder/test_metrics.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/sentry/search/events/builder/metrics.py b/src/sentry/search/events/builder/metrics.py index cf755ca300b1..3fd9f147ecd7 100644 --- a/src/sentry/search/events/builder/metrics.py +++ b/src/sentry/search/events/builder/metrics.py @@ -697,7 +697,10 @@ def resolve_tag_key(self, value: str) -> int | str | None: value = self.column_remapping.get(value, value) if self.use_default_tags: - if value in self.default_metric_tags: + if ( + value in self.default_metric_tags + or self.builder_config.skip_field_validation_for_entity_subscription_deletion + ): return self.resolve_metric_index(value) else: raise IncompatibleMetricsQuery(f"{value} is not a tag in the metrics dataset") diff --git a/tests/sentry/search/events/builder/test_metrics.py b/tests/sentry/search/events/builder/test_metrics.py index 94e4837e2207..1236d0655da4 100644 --- a/tests/sentry/search/events/builder/test_metrics.py +++ b/tests/sentry/search/events/builder/test_metrics.py @@ -442,6 +442,31 @@ def test_incorrect_parameter_for_metrics(self) -> None: selected_columns=["transaction", "count_unique(test)"], ) + def test_non_default_tag_in_query_raises(self) -> None: + with pytest.raises(IncompatibleMetricsQuery): + MetricsQueryBuilder( + self.params, + query="http.url:https://example.com", + dataset=Dataset.PerformanceMetrics, + selected_columns=["count()"], + ) + + def test_non_default_tag_in_query_skipped_for_subscription_deletion(self) -> None: + indexer.record( + use_case_id=UseCaseID.TRANSACTIONS, + org_id=self.organization.id, + string="http.url", + ) + MetricsQueryBuilder( + self.params, + query="http.url:https://example.com", + dataset=Dataset.PerformanceMetrics, + selected_columns=["count()"], + config=QueryBuilderConfig( + skip_field_validation_for_entity_subscription_deletion=True, + ), + ) + def test_project_filter(self) -> None: query = MetricsQueryBuilder( self.params, From 60f40a79dd1195d42163a3148325c8dbdd6e7335 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 27 May 2026 15:09:37 -0400 Subject: [PATCH 24/37] feat(bitbucket-server): Add API-driven pipeline backend for Bitbucket Server integration setup (#116295) Implement `get_pipeline_api_steps()` on `BitbucketServerIntegrationProvider` with two steps: installation config (validates URL, RSA private key, and consumer key length, then fetches an OAuth 1.0a request token from the Bitbucket Server instance), and an OAuth callback step that builds the authorize URL from the request token and exchanges the callback's `oauth_token` (used by Bitbucket Server as the verifier) for an access token. Legacy `InstallationConfigView`, `OAuthLoginView`, and `OAuthCallbackView` remain in place so in-flight installs can complete via the existing flow; they will be removed in a follow-up ([VDY-103](https://linear.app/getsentry/issue/VDY-103/remove-legacy-bitbucket-server-integration-setup-views)) once the API flow has been validated in production. Ref [VDY-99](https://linear.app/getsentry/issue/VDY-99/bitbucket-server-api-driven-integration-setup) --- .../bitbucket_server/integration.py | 158 ++++++++++- .../bitbucket_server/test_integration.py | 259 +++++++++++++++++- 2 files changed, 414 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 57c9c3c6b28f..807c0be881c0 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend @@ -14,7 +14,10 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt +from rest_framework import serializers +from rest_framework.fields import BooleanField, CharField, URLField +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -40,7 +43,8 @@ ) from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity from sentry.web.helpers import render_to_response @@ -257,6 +261,153 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR ) +class InstallationConfigData(TypedDict): + url: str + consumer_key: str + private_key: str + verify_ssl: bool + + +class InstallationConfigSerializer(CamelSnakeSerializer[InstallationConfigData]): + url = URLField(required=True) + consumer_key = CharField(required=True, max_length=200) + private_key = CharField(required=True) + verify_ssl = BooleanField(required=False, default=True) + + def validate_private_key(self, value: str) -> str: + try: + load_pem_private_key(value.encode("utf-8"), None, default_backend()) + except Exception: + raise serializers.ValidationError( + "Private key must be a valid SSH private key encoded in a PEM format." + ) + return value + + +class InstallationConfigApiStep: + """ + Collect Bitbucket Server consumer credentials and verify them by fetching an + OAuth 1.0a request token. The token is stored on pipeline state so the next + step can build an authorize URL and exchange it for an access token. + """ + + step_name = "installation_config" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return {} + + def get_serializer_cls(self) -> type: + return InstallationConfigSerializer + + def handle_post( + self, + validated_data: InstallationConfigData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + validated_data["url"] = validated_data["url"].rstrip("/") + + client = BitbucketServerSetupClient( + validated_data["url"], + validated_data["consumer_key"], + validated_data["private_key"], + validated_data["verify_ssl"], + ) + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_LOGIN, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + request_token = client.get_request_token() + except ApiError as error: + lifecycle.record_failure(str(error), extra={"url": validated_data["url"]}) + return PipelineStepResult.error( + f"Could not fetch a request token from Bitbucket. {error}" + ) + + if not request_token.get("oauth_token") or not request_token.get("oauth_token_secret"): + lifecycle.record_failure( + "missing oauth_token", extra={"url": validated_data["url"]} + ) + return PipelineStepResult.error("Missing oauth_token") + + pipeline.bind_state("installation_data", validated_data) + pipeline.bind_state("request_token", request_token) + return PipelineStepResult.advance() + + +class OAuthCallbackData(TypedDict): + oauth_token: str + + +class OAuthCallbackSerializer(CamelSnakeSerializer[OAuthCallbackData]): + oauth_token = CharField(required=True) + + +class OAuthStepData(TypedDict): + oauthUrl: str + + +class OAuthApiStep: + """ + Build the Bitbucket Server authorize URL from the previously-fetched request + token, then exchange the callback's oauth_token (which Bitbucket Server uses + as the verifier) for an access token. + """ + + step_name = "oauth_callback" + + def _client(self, pipeline: IntegrationPipeline) -> BitbucketServerSetupClient: + installation = pipeline.fetch_state("installation_data") + if installation is None: + raise AssertionError("pipeline called out of order") + return BitbucketServerSetupClient( + installation["url"], + installation["consumer_key"], + installation["private_key"], + installation["verify_ssl"], + ) + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> OAuthStepData: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + return {"oauthUrl": self._client(pipeline).get_authorize_url(request_token)} + + def get_serializer_cls(self) -> type: + return OAuthCallbackSerializer + + def handle_post( + self, + validated_data: OAuthCallbackData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_CALLBACK, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + access_token = self._client(pipeline).get_access_token( + request_token, validated_data["oauth_token"] + ) + except ApiError as error: + lifecycle.record_failure(str(error)) + return PipelineStepResult.error( + f"Could not fetch an access token from Bitbucket. {error}" + ) + + pipeline.bind_state("access_token", access_token) + return PipelineStepResult.advance() + + class BitbucketServerIntegration(RepositoryIntegration[BitbucketServerClient]): """ IntegrationInstallation implementation for Bitbucket Server @@ -395,6 +546,9 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [InstallationConfigApiStep(), OAuthApiStep()] + def post_install( self, integration: Integration, diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 726bdd044bff..8f998c61f7e6 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,17 +1,20 @@ from functools import cached_property +from typing import Any from unittest.mock import MagicMock, patch import responses +from django.urls import reverse from requests.exceptions import ReadTimeout from fixtures.bitbucket_server import EXAMPLE_PRIVATE_KEY from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.pipeline import IntegrationPipeline from sentry.models.repository import Repository from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric -from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity, IdentityProvider @@ -476,3 +479,257 @@ def test_extract_source_path_from_source_url(self) -> None: ] for source_url, expected in test_cases: assert installation.extract_source_path_from_source_url(repo, source_url) == expected + + +REQUEST_TOKEN_BODY = "oauth_token=req-token&oauth_token_secret=req-token-secret" +ACCESS_TOKEN_BODY = "oauth_token=valid-token&oauth_token_secret=valid-secret" + + +@control_silo_test +class BitbucketServerApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + + bbs_url = "https://bitbucket.example.com" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def tearDown(self) -> None: + responses.reset() + super().tearDown() + + def _pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize(self) -> Any: + return self.client.post( + self._pipeline_url(), + data={"action": "initialize", "provider": "bitbucket_server"}, + format="json", + ) + + def _advance(self, data: dict[str, Any]) -> Any: + return self.client.post(self._pipeline_url(), data=data, format="json") + + def _submit_config(self, **overrides: Any) -> Any: + data = { + "url": self.bbs_url, + "consumerKey": "sentry-bot", + "privateKey": EXAMPLE_PRIVATE_KEY, + "verifySsl": False, + } + data.update(overrides) + return self._advance(data) + + def _stub_request_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", REQUEST_TOKEN_BODY), + **kwargs, + ) + + def _stub_access_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/access-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", ACCESS_TOKEN_BODY), + **kwargs, + ) + + @responses.activate + def test_initialize_pipeline(self) -> None: + resp = self._initialize() + assert resp.status_code == 200 + assert resp.data["provider"] == "bitbucket_server" + assert resp.data["step"] == "installation_config" + assert resp.data["stepIndex"] == 0 + assert resp.data["totalSteps"] == 2 + assert resp.data["data"] == {} + + @responses.activate + def test_config_step_validation_missing_required_fields(self) -> None: + self._initialize() + resp = self._advance({"url": self.bbs_url}) + assert resp.status_code == 400 + for field in ("consumerKey", "privateKey"): + assert resp.data[field] == ["This field is required."] + + @responses.activate + def test_config_step_validation_invalid_url(self) -> None: + self._initialize() + resp = self._submit_config(url="bitbucket.example.com") + assert resp.status_code == 400 + assert resp.data["url"] == ["Enter a valid URL."] + + @responses.activate + def test_config_step_validation_invalid_private_key(self) -> None: + self._initialize() + resp = self._submit_config(privateKey="hot-garbage") + assert resp.status_code == 400 + assert "PEM format" in resp.data["privateKey"][0] + + @responses.activate + def test_config_step_validation_consumer_key_too_long(self) -> None: + self._initialize() + resp = self._submit_config(consumerKey="x" * 201) + assert resp.status_code == 400 + assert "200 characters" in resp.data["consumerKey"][0] + + @responses.activate + def test_config_step_advance(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config() + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["step"] == "oauth_callback" + assert resp.data["stepIndex"] == 1 + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + def test_config_step_strips_trailing_slash(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config(url=f"{self.bbs_url}//") + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_timeout(self, mock_record: MagicMock) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + body=ReadTimeout("Read timed out. (read timeout=30)"), + ) + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric( + mock_record, "Timed out attempting to reach host: bitbucket.example.com" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_fails(self, mock_record: MagicMock) -> None: + self._stub_request_token(status=503, body="") + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, "") + + @responses.activate + def test_oauth_step_validation_missing_token(self) -> None: + self._stub_request_token() + self._initialize() + self._submit_config() + resp = self._advance({}) + assert resp.status_code == 400 + assert resp.data["oauthToken"] == ["This field is required."] + + @responses.activate + def test_oauth_step_passes_callback_token_as_verifier(self) -> None: + # Bitbucket Server uses the callback's oauth_token as the OAuth 1.0a + # verifier when exchanging for an access token. Confirm the access-token + # request signature contains the value from the callback. + self._stub_request_token() + self._stub_access_token() + self._initialize() + self._submit_config() + self._advance({"oauthToken": "callback-token"}) + + access_token_calls = [ + call + for call in responses.calls + if call.request.url == f"{self.bbs_url}/plugins/servlet/oauth/access-token" + ] + assert len(access_token_calls) == 1 + assert ( + 'oauth_verifier="callback-token"' + in access_token_calls[0].request.headers["Authorization"] + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_oauth_step_access_token_failure(self, mock_record: MagicMock) -> None: + self._stub_request_token() + error_msg = "it broke" + self._stub_access_token(status=500, body=error_msg) + self._initialize() + self._submit_config() + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "access token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, error_msg) + + @responses.activate + def test_full_pipeline_flow(self) -> None: + self._stub_request_token() + self._stub_access_token() + + resp = self._initialize() + assert resp.data["step"] == "installation_config" + + resp = self._submit_config() + assert resp.data["step"] == "oauth_callback" + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + integration = Integration.objects.get(provider="bitbucket_server") + assert integration.name == "sentry-bot" + assert integration.external_id == "bitbucket.example.com:sentry-bot" + assert integration.metadata["base_url"] == self.bbs_url + assert integration.metadata["domain_name"] == "bitbucket.example.com" + assert integration.metadata["verify_ssl"] is False + + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + + idp = IdentityProvider.objects.get(type="bitbucket_server") + identity = Identity.objects.get( + idp=idp, user=self.user, external_id="bitbucket.example.com:sentry-bot" + ) + assert identity.data["consumer_key"] == "sentry-bot" + assert identity.data["access_token"] == "valid-token" + assert identity.data["access_token_secret"] == "valid-secret" + assert identity.data["private_key"] == EXAMPLE_PRIVATE_KEY + + @responses.activate + def test_full_pipeline_truncates_external_id(self) -> None: + self._stub_request_token() + self._stub_access_token() + + self._initialize() + long_key = "a-very-long-consumer-key-that-when-combined-with-host-would-overflow" + self._submit_config(consumerKey=long_key) + self._advance({"oauthToken": "callback-token"}) + + integration = Integration.objects.get(provider="bitbucket_server") + assert ( + integration.external_id + == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" + ) From a61282cb131d4ea925792543518339d7557e526b Mon Sep 17 00:00:00 2001 From: Kush Dubey Date: Wed, 27 May 2026 12:15:10 -0700 Subject: [PATCH 25/37] ref(seer-grouping): rm backfill url (#116253) Backfill was removed in https://github.com/getsentry/sentry/pull/102364 1. https://github.com/getsentry/getsentry/pull/20423 2. [this PR] https://github.com/getsentry/sentry/pull/116253 3. https://github.com/getsentry/ops/pull/20829 4. https://github.com/getsentry/ops/pull/20828 --- src/sentry/conf/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 40e4eb58fcd8..277c34037c62 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2910,8 +2910,6 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SEER_GROUPING_URL = SEER_DEFAULT_URL # for local development, these share a URL -SEER_GROUPING_BACKFILL_URL = SEER_DEFAULT_URL - SEER_SCORING_URL = SEER_DEFAULT_URL # for local development, these share a URL SEER_ANOMALY_DETECTION_MODEL_VERSION = "v1" From c326bf7daa49735d61b29b0a3602665fd64abe8e Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Wed, 27 May 2026 12:16:16 -0700 Subject: [PATCH 26/37] fix(integrations): Use paginated jira projects endpoint, behind flag (#116327) Resolves ISWF-2751 It's simple enough to use the paginated endpoint, but let's make sure it works before turning it on for all customers. --- src/sentry/features/temporary.py | 2 + src/sentry/integrations/jira/integration.py | 31 +++-- .../integrations/jira/test_integration.py | 106 ++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 2dfa5042237b..25c6aa355cbe 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -138,6 +138,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Temporary: log full Jira Cloud `issue.updated` webhook payloads so we can design project-change link rewriting. manager.add("organizations:jira-issue-updated-payload-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use the paginated project endpoint in Jira org config to avoid timeouts on large instances. + manager.add("organizations:jira-paginated-project-config", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable inviting billing members to organizations at the member limit. manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) # Enable inviting members to organizations. diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 7ff28cf8592f..b4c73d81c57c 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -164,13 +164,30 @@ def use_email_scope(cls): def get_organization_config(self) -> list[dict[str, Any]]: configuration: list[dict[str, Any]] = self._get_organization_config_default_values() + context = organization_service.get_organization_by_id( + id=self.organization_id, include_projects=False, include_teams=False + ) + assert context, "organizationcontext must exist to get org" + organization = context.organization + client = self.get_client() try: - projects: list[JiraProjectMapping] = [ - JiraProjectMapping(value=p["id"], label=p["name"]) - for p in client.get_projects_list() - ] + if features.has("organizations:jira-paginated-project-config", organization): + # Use the paginated endpoint to avoid fetching all projects at once, + # which can time out for large Jira instances. The settings page + # dropdown search (typeahead) handles finding projects beyond this + # initial page via JiraSearchEndpoint. + projects_response = client.get_projects_paginated(params={"maxResults": 50}) + projects: list[JiraProjectMapping] = [ + JiraProjectMapping(value=p["id"], label=p["name"]) + for p in projects_response.get("values", []) + ] + else: + projects = [ + JiraProjectMapping(value=p["id"], label=p["name"]) + for p in client.get_projects_list() + ] self._set_status_choices_in_organization_config(configuration, projects) configuration[0]["addDropdown"]["items"] = projects except ApiError: @@ -179,12 +196,6 @@ def get_organization_config(self) -> list[dict[str, Any]]: "Unable to communicate with the Jira instance. You may need to reinstall the addon." ) - context = organization_service.get_organization_by_id( - id=self.organization_id, include_projects=False, include_teams=False - ) - assert context, "organizationcontext must exist to get org" - organization = context.organization - has_issue_sync = features.has("organizations:integrations-issue-sync", organization) if not has_issue_sync: for field in configuration: diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py index 6ed2a9e2dc9c..19829ea17710 100644 --- a/tests/sentry/integrations/jira/test_integration.py +++ b/tests/sentry/integrations/jira/test_integration.py @@ -1297,6 +1297,112 @@ def test_get_config_data_issue_keys(self) -> None: == "hello world, goodnight, moon" ) + @responses.activate + def test_get_organization_config_uses_projects_list_without_flag(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project", + json=[{"id": "10000", "name": "Project A"}], + ) + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/statuses/search", + json={"values": []}, + ) + + installation = integration.get_installation(self.organization.id) + config = installation.get_organization_config() + + assert config[0]["addDropdown"]["items"] == [ + {"value": "10000", "label": "Project A"}, + ] + assert any( + "rest/api/2/project" in call.request.url and "search" not in call.request.url + for call in responses.calls + ) + + @responses.activate + def test_get_organization_config_uses_paginated_endpoint_with_flag(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={ + "values": [ + {"id": "10000", "name": "Project A"}, + {"id": "10001", "name": "Project B"}, + ], + "maxResults": 50, + "total": 2, + }, + ) + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/statuses/search", + json={"values": []}, + ) + + installation = integration.get_installation(self.organization.id) + with self.feature("organizations:jira-paginated-project-config"): + config = installation.get_organization_config() + + assert config[0]["addDropdown"]["items"] == [ + {"value": "10000", "label": "Project A"}, + {"value": "10001", "label": "Project B"}, + ] + assert any("rest/api/2/project/search" in call.request.url for call in responses.calls) + + @responses.activate + def test_get_organization_config_paginated_api_error_disables_config(self) -> None: + integration = self.create_provider_integration( + provider="jira", + name="Example Jira", + metadata={ + "oauth_client_id": "oauth-client-id", + "shared_secret": "a-super-secret-key-from-atlassian", + "base_url": "https://example.atlassian.net", + "domain_name": "example.atlassian.net", + }, + ) + integration.add_organization(self.organization, self.user) + + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={"errorMessages": ["Something went wrong"]}, + status=500, + ) + + installation = integration.get_installation(self.organization.id) + with self.feature("organizations:jira-paginated-project-config"): + config = installation.get_organization_config() + + assert config[0]["disabled"] is True + assert "Unable to communicate" in config[0]["disabledReason"] + def test_error_fields_from_json_issue_not_found(self) -> None: integration = self.create_provider_integration(provider="jira", name="Example Jira") integration.add_organization(self.organization, self.user) From 2e78c41fb8861be5afd10fbbcdc48904ac9c987c Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 27 May 2026 15:52:24 -0400 Subject: [PATCH 27/37] feat(bitbucket-server): Add frontend pipeline steps for Bitbucket Server integration setup (#116294) Register Bitbucket Server in the pipeline registry with two steps: an installation config form (URL, consumer key, RSA private key, SSL toggle) and a popup-based OAuth 1.0a authorization step using `useRedirectPopupStep`. Unlike OAuth 2.0 providers, the Bitbucket Server callback returns an `oauth_token` (used as the verifier), which the frontend relays to the backend as `oauthToken`. Ref [VDY-99](https://linear.app/getsentry/issue/VDY-99/bitbucket-server-api-driven-integration-setup) --- ...ipelineIntegrationBitbucketServer.spec.tsx | 168 +++++++++++++ .../pipelineIntegrationBitbucketServer.tsx | 235 ++++++++++++++++++ static/app/components/pipeline/registry.tsx | 2 + 3 files changed, 405 insertions(+) create mode 100644 static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx create mode 100644 static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx new file mode 100644 index 000000000000..2d42a87eedcb --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.spec.tsx @@ -0,0 +1,168 @@ +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; +import {createMakeStepProps, dispatchPipelineMessage, setupMockPopup} from './testUtils'; + +const InstallationConfigStep = bitbucketServerIntegrationPipeline.steps[0].component; +const OAuthCallbackStep = bitbucketServerIntegrationPipeline.steps[1].component; + +const makeStepProps = createMakeStepProps({totalSteps: 2}); + +let mockPopup: Window; + +beforeEach(() => { + mockPopup = setupMockPopup(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +async function fillRequiredConfigFields() { + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}), + 'sentry-bot' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}), + '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----' + ); +} + +describe('Bitbucket Server InstallationConfigStep', () => { + it('renders the config form fields', () => { + render(); + + expect(screen.getByRole('textbox', {name: 'Bitbucket URL'})).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Key'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Bitbucket Consumer Private Key'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument(); + }); + + it('calls advance with form data on submit', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith({ + url: 'https://bitbucket.example.com', + consumerKey: 'sentry-bot', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----', + verifySsl: true, + }); + }); + }); + + it('strips trailing slashes from the URL', async () => { + const advance = jest.fn(); + render(); + + await fillRequiredConfigFields(); + await userEvent.clear(screen.getByRole('textbox', {name: 'Bitbucket URL'})); + await userEvent.type( + screen.getByRole('textbox', {name: 'Bitbucket URL'}), + 'https://bitbucket.example.com///' + ); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => { + expect(advance).toHaveBeenCalledWith( + expect.objectContaining({url: 'https://bitbucket.example.com'}) + ); + }); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); +}); + +describe('Bitbucket Server OAuthCallbackStep', () => { + const oauthUrl = + 'https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=req-token'; + + it('renders the authorize button', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeInTheDocument(); + }); + + it('opens the popup and advances with oauthToken on callback', async () => { + const advance = jest.fn(); + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect(window.open).toHaveBeenCalledWith( + oauthUrl, + 'pipeline_popup', + expect.any(String) + ); + + dispatchPipelineMessage({ + source: mockPopup, + data: { + _pipeline_source: 'sentry-pipeline', + oauth_token: 'callback-token', + }, + }); + + expect(advance).toHaveBeenCalledWith({oauthToken: 'callback-token'}); + }); + + it('disables the authorize button when oauthUrl is missing', () => { + render(); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toBeDisabled(); + }); + + it('shows popup-blocked notice when window.open returns null', async () => { + jest.spyOn(window, 'open').mockReturnValue(null); + + render(); + + await userEvent.click( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ); + + expect( + screen.getByText( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + ) + ).toBeInTheDocument(); + }); + + it('shows busy state when isAdvancing', () => { + render( + + ); + + expect( + screen.getByRole('button', {name: 'Authorize Bitbucket Server'}) + ).toHaveAttribute('aria-busy', 'true'); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx new file mode 100644 index 000000000000..d06435206457 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationBitbucketServer.tsx @@ -0,0 +1,235 @@ +import {useCallback, useEffect} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import {t, tct} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import {useRedirectPopupStep} from './shared/useRedirectPopupStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +const installationConfigSchema = z.object({ + url: z + .string() + .min(1, t('Bitbucket URL is required')) + .url(t('Enter a valid URL')) + .transform(v => v.replace(/\/+$/, '')), + consumerKey: z + .string() + .min(1, t('Consumer Key is required')) + .max(200, t('Consumer Key is limited to 200 characters')), + privateKey: z.string().min(1, t('Private Key is required')), + verifySsl: z.boolean(), +}); + +interface InstallationConfigAdvanceData { + consumerKey: string; + privateKey: string; + url: string; + verifySsl: boolean; +} + +function InstallationConfigStep({ + advance, + advanceError, + isAdvancing, + isInitializing, +}: PipelineStepProps, InstallationConfigAdvanceData>) { + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + url: '', + consumerKey: '', + privateKey: '', + verifySsl: true, + }, + validators: {onDynamic: installationConfigSchema}, + onSubmit: ({value}) => { + advance(installationConfigSchema.parse(value)); + }, + }); + + useEffect(() => { + if (advanceError) { + setFieldErrors(form, advanceError); + } + }, [advanceError, form]); + + return ( + + + + {tct( + 'Create an Application Link on your Bitbucket Server instance for Sentry, then enter the consumer credentials below. Refer to the [link:documentation] for setup instructions.', + { + link: ( + + ), + } + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + + {t('Continue')} + + + + + ); +} + +interface OAuthStepData { + oauthUrl?: string; +} + +function OAuthCallbackStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const handleCallback = useCallback( + (data: Record) => { + if (data.oauth_token) { + advance({oauthToken: data.oauth_token}); + } + }, + [advance] + ); + + const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData?.oauthUrl, + onCallback: handleCallback, + }); + + return ( + + + + {t( + 'Authorize Sentry on your Bitbucket Server instance to complete the integration setup.' + )} + + {isWaitingForCallback && ( + + {t('A popup should have opened to authorize with Bitbucket Server.')} + + )} + {popupStatus === 'failed-to-open' && ( + + {t( + 'The authorization popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + )} + + {isWaitingForCallback && !isAdvancing ? ( + + ) : ( + + )} + + ); +} + +export const bitbucketServerIntegrationPipeline = { + type: 'integration', + provider: 'bitbucket_server', + actionTitle: t('Installing Bitbucket Server Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'installation_config', + shortDescription: t('Configuring Bitbucket Server connection'), + component: InstallationConfigStep, + }, + { + stepId: 'oauth_callback', + shortDescription: t('Authorizing via OAuth'), + component: OAuthCallbackStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index d4e49141317e..71f9281ddb62 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,6 +1,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {awsLambdaIntegrationPipeline} from './pipelineIntegrationAwsLambda'; import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; +import {bitbucketServerIntegrationPipeline} from './pipelineIntegrationBitbucketServer'; import {claudeCodeIntegrationPipeline} from './pipelineIntegrationClaudeCode'; import {cursorIntegrationPipeline} from './pipelineIntegrationCursor'; import {discordIntegrationPipeline} from './pipelineIntegrationDiscord'; @@ -23,6 +24,7 @@ import {vstsIntegrationPipeline} from './pipelineIntegrationVsts'; export const PIPELINE_REGISTRY = [ awsLambdaIntegrationPipeline, bitbucketIntegrationPipeline, + bitbucketServerIntegrationPipeline, claudeCodeIntegrationPipeline, cursorIntegrationPipeline, discordIntegrationPipeline, From eb6bcc933327c6c9d905bca727f526ffa4c23410 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Wed, 27 May 2026 13:06:10 -0700 Subject: [PATCH 28/37] ref: Delete plan migration frontend (#116331) There are no in progress plan migrations and any new migrations would need to be done on the billing platform, so we are deleting the legacy plan migration API and this frontend that used it --- static/app/utils/api/knownGetsentryApiUrls.ts | 1 - .../customers/pendingChanges.spec.tsx | 76 +- .../components/customers/pendingChanges.tsx | 19 +- static/gsApp/hooks/usePlanMigrations.tsx | 39 - static/gsApp/types/index.tsx | 48 - .../views/amCheckout/components/cart.spec.tsx | 5 - static/gsApp/views/amCheckout/index.spec.tsx | 10 - .../amCheckout/steps/addBillingInfo.spec.tsx | 5 - .../amCheckout/steps/buildYourPlan.spec.tsx | 5 - .../steps/chooseYourBillingCycle.spec.tsx | 5 - .../amCheckout/steps/productSelect.spec.tsx | 5 - .../steps/reserveAdditionalVolume.spec.tsx | 10 - .../billingInformation.spec.tsx | 5 - .../subscriptionPage/decidePendingChanges.tsx | 17 +- .../subscriptionPage/notifications.spec.tsx | 5 - .../views/subscriptionPage/overview.spec.tsx | 68 +- .../subscriptionPage/paymentHistory.spec.tsx | 6 - .../planMigrationActive/index.spec.tsx | 908 ------------------ .../planMigrationActive/index.tsx | 189 ---- .../planMigrationRow.spec.tsx | 64 -- .../planMigrationActive/planMigrationRow.tsx | 166 ---- .../planMigrationTable.tsx | 299 ------ .../subscriptionHeader.spec.tsx | 6 - .../subscriptionUpsellBanner.spec.tsx | 11 - .../subscriptionUpsellBanner.tsx | 12 +- .../subscriptionPage/usageHistory.spec.tsx | 6 - .../views/subscriptionPage/usageLog.spec.tsx | 6 - .../getsentry-test/fixtures/planMigration.ts | 234 ----- 28 files changed, 11 insertions(+), 2219 deletions(-) delete mode 100644 static/gsApp/hooks/usePlanMigrations.tsx delete mode 100644 static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx delete mode 100644 static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx delete mode 100644 static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx delete mode 100644 static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx delete mode 100644 static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx delete mode 100644 tests/js/getsentry-test/fixtures/planMigration.ts diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index cedea81749de..a00abfdb2733 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -36,7 +36,6 @@ export type KnownGetsentryApiUrls = | '/customers/$organizationIdOrSlug/members/' | '/customers/$organizationIdOrSlug/migrate-google-domain/' | '/customers/$organizationIdOrSlug/ondemand-budgets/' - | '/customers/$organizationIdOrSlug/plan-migrations/' | '/customers/$organizationIdOrSlug/policies/' | '/customers/$organizationIdOrSlug/product-trial/' | '/customers/$organizationIdOrSlug/projects/$projectIdOrSlug/stats/' diff --git a/static/gsAdmin/components/customers/pendingChanges.spec.tsx b/static/gsAdmin/components/customers/pendingChanges.spec.tsx index 066cc69d780f..592c55b66b46 100644 --- a/static/gsAdmin/components/customers/pendingChanges.spec.tsx +++ b/static/gsAdmin/components/customers/pendingChanges.spec.tsx @@ -2,7 +2,6 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; import {SeerReservedBudgetFixture} from 'getsentry-test/fixtures/reservedBudget'; import { Am3DsEnterpriseSubscriptionFixture, @@ -15,9 +14,8 @@ import {DataCategory} from 'sentry/types/core'; import {PendingChanges} from 'admin/components/customers/pendingChanges'; import {PendingChangesFixture} from 'getsentry/__fixtures__/pendingChanges'; import {PlanFixture} from 'getsentry/__fixtures__/plan'; -import {ANNUAL, RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; -import * as usePlanMigrations from 'getsentry/hooks/usePlanMigrations'; -import {CohortId, OnDemandBudgetMode} from 'getsentry/types'; +import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; +import {OnDemandBudgetMode} from 'getsentry/types'; describe('PendingChanges', () => { it('renders null pendingChanges)', () => { @@ -244,76 +242,6 @@ describe('PendingChanges', () => { ); }); - it('renders pending changes for plan migration', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - onDemandPeriodEnd: '2018-10-24', - contractPeriodEnd: '2019-09-24', - pendingChanges: PendingChangesFixture({ - planDetails: PlanFixture({ - id: 'am3_business_auf', - name: 'Business', - contractInterval: 'annual', - billingInterval: 'annual', - onDemandCategories: [ - DataCategory.ERRORS, - DataCategory.ATTACHMENTS, - DataCategory.SPANS, - DataCategory.REPLAYS, - DataCategory.MONITOR_SEATS, - ], - }), - reserved: { - errors: 50_000, - spans: 10_000_000, - replays: 50, - monitorSeats: 1, - attachments: 1, - }, - effectiveDate: '2019-09-25', - onDemandEffectiveDate: '2018-10-25', - }), - }); - const migrationDate = '2018-10-25'; - const migration = PlanMigrationFixture({ - cohortId: CohortId.TENTH, - effectiveAt: migrationDate, - }); - jest - .spyOn(usePlanMigrations, 'usePlanMigrations') - .mockReturnValue({planMigrations: [migration], isLoading: false}); - - const {container} = render(); - - // expected copy for plan changes - expect(container).toHaveTextContent( - 'This account has pending changes to the subscription' - ); - expect(container).toHaveTextContent( - 'The following changes will take effect on Oct 25, 2018' - ); - expect(container).toHaveTextContent('Plan changes — Business → Business'); - expect(container).toHaveTextContent( - 'Reserved performance units — 100,000 → 0 transactions' - ); - expect(container).toHaveTextContent('Reserved replays — 500 → 50 session replays'); - expect(container).toHaveTextContent('Reserved spans — 0 → 10,000,000 spans'); - - // no actual changes - expect(container).not.toHaveTextContent('Reserved errors — 50,000 → 50,000 errors'); - expect(container).not.toHaveTextContent( - 'Reserved attachments — 1 GB → 1 GB attachments' - ); - expect(container).not.toHaveTextContent( - 'Reserved cron monitors — 1 → 1 cron monitor' - ); - }); - it('renders reserved budgets with existing budgets', () => { const subscription = Am3DsEnterpriseSubscriptionFixture({ organization: OrganizationFixture(), diff --git a/static/gsAdmin/components/customers/pendingChanges.tsx b/static/gsAdmin/components/customers/pendingChanges.tsx index a415d3dbaff9..59cdb5cf9989 100644 --- a/static/gsAdmin/components/customers/pendingChanges.tsx +++ b/static/gsAdmin/components/customers/pendingChanges.tsx @@ -9,8 +9,7 @@ import {IconArrow} from 'sentry/icons'; import {DataCategory} from 'sentry/types/core'; import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; -import type {Plan, PlanMigration, Subscription} from 'getsentry/types'; +import type {Plan, Subscription} from 'getsentry/types'; import {displayBudgetName, formatReservedWithUnits} from 'getsentry/utils/billing'; import { getPlanCategoryName, @@ -332,7 +331,7 @@ type Change = { items: React.ReactNode[]; }; -function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) { +function getChanges(subscription: Subscription) { const {pendingChanges} = subscription; const changeSet: Change[] = []; @@ -340,13 +339,7 @@ function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) return changeSet; } - const activeMigration = planMigrations.find( - ({dateApplied, cohort}) => dateApplied === null && cohort?.nextPlan - ); - - const {onDemandEffectiveDate} = pendingChanges; - - const effectiveDate = activeMigration?.effectiveAt ?? pendingChanges.effectiveDate; + const {onDemandEffectiveDate, effectiveDate} = pendingChanges; const regularChanges = getRegularChanges(subscription); const onDemandChanges = getOnDemandChanges(subscription); @@ -371,16 +364,12 @@ function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) export function PendingChanges({subscription}: any) { const {pendingChanges} = subscription; - const {planMigrations, isLoading} = usePlanMigrations(); - if (isLoading) { - return null; - } if (typeof pendingChanges !== 'object' || pendingChanges === null) { return null; } - const changes = getChanges(subscription, planMigrations); + const changes = getChanges(subscription); if (!changes.length) { return null; } diff --git a/static/gsApp/hooks/usePlanMigrations.tsx b/static/gsApp/hooks/usePlanMigrations.tsx deleted file mode 100644 index 2f79d539dfcb..000000000000 --- a/static/gsApp/hooks/usePlanMigrations.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type {Organization} from 'sentry/types/organization'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useUser} from 'sentry/utils/useUser'; - -import type {PlanMigration} from 'getsentry/types'; - -const hasBillingAccess = ({access}: Organization) => access?.includes('org:billing'); - -interface PlanMigrationsHook { - isLoading: boolean; - planMigrations: PlanMigration[]; -} - -export function usePlanMigrations(): PlanMigrationsHook { - const organization = useOrganization(); - const user = useUser(); - const enabled = hasBillingAccess(organization) || user.isStaff; - const {data: planMigrations, isPending} = useApiQuery( - [ - getApiUrl('/customers/$organizationIdOrSlug/plan-migrations/', { - path: {organizationIdOrSlug: organization.slug}, - }), - {query: {scheduled: 1, applied: 0}}, - ], - { - staleTime: Infinity, - enabled, - retry: false, - notifyOnChangeProps: ['isLoading', 'data'], - } - ); - - return { - planMigrations: planMigrations ?? [], - isLoading: enabled ? isPending : false, - }; -} diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 44942b3e0f95..c40857e09419 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -950,54 +950,6 @@ export type RecurringCredit = | RecurringPercentDiscount | RecurringEventCredit; -export enum CohortId { - SECOND = 2, - THIRD = 3, - FOURTH = 4, - FIFTH = 5, - SIXTH = 6, - SEVENTH = 7, - EIGHTH = 8, - NINTH = 9, - TENTH = 10, - TEST_ONE = 111, -} - -export type Cohort = { - cohortId: CohortId; - nextPlan: NextPlanInfo | null; - secondDiscount: number; -}; - -export type NextPlanInfo = { - contractPeriod: string; - discountAmount: number; - discountMonths: number; - id: string; - name: string; - reserved: Partial>; - totalPrice: number; - categoryCredits?: Partial< - Record< - DataCategory, - { - credits: number; - months: number; - } - > - >; -}; - -export type PlanMigration = { - cohort: Cohort | null; - dateApplied: string | null; - effectiveAt: string | null; - id: number | string; - planTier: string; - recurringCredits: RecurringCredit[]; - scheduled: boolean; -}; - export enum PlanTier { /** * Performance plans with continuous profiling diff --git a/static/gsApp/views/amCheckout/components/cart.spec.tsx b/static/gsApp/views/amCheckout/components/cart.spec.tsx index 0957c25842bb..763e28515ea9 100644 --- a/static/gsApp/views/amCheckout/components/cart.spec.tsx +++ b/static/gsApp/views/amCheckout/components/cart.spec.tsx @@ -62,11 +62,6 @@ describe('Cart', () => { setMockDate(MOCK_TODAY); MockApiClient.clearMockResponses(); SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/index.spec.tsx b/static/gsApp/views/amCheckout/index.spec.tsx index 5011aa363d60..e2a66f2019d2 100644 --- a/static/gsApp/views/amCheckout/index.spec.tsx +++ b/static/gsApp/views/amCheckout/index.spec.tsx @@ -66,11 +66,6 @@ describe('Legacy Tier Checkout', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', @@ -195,11 +190,6 @@ describe('Default Tier Checkout', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx b/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx index 71a6a8956485..d13d4aa58f3e 100644 --- a/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/addBillingInfo.spec.tsx @@ -41,11 +41,6 @@ describe('AddBillingInformation', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx index 5c336c6f2f51..ab15b46ac13b 100644 --- a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx @@ -40,11 +40,6 @@ describe('BuildYourPlan', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx index 3aed1b4b6598..3a568eea8869 100644 --- a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx @@ -47,11 +47,6 @@ describe('ChooseYourBillingCycle', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx index fb31cf539772..06f05b9e1ece 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx @@ -52,11 +52,6 @@ describe('ProductSelect', () => { method: 'POST', body: {}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', diff --git a/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx b/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx index 879c64c33337..9fb6f3f37b1b 100644 --- a/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.spec.tsx @@ -99,11 +99,6 @@ describe('ReserveAdditionalVolume', () => { beforeEach(() => { SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', @@ -234,11 +229,6 @@ describe('ReserveAdditionalVolume', () => { subscription = SubscriptionFixture({organization}); stepProps.subscription = subscription; SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/?applied=0`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'GET', diff --git a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx index b2385a211baa..523e52964302 100644 --- a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx +++ b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx @@ -52,11 +52,6 @@ describe('Subscription > BillingInformation', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, diff --git a/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx b/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx index 8ab976a935c6..a0f60b38a493 100644 --- a/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx +++ b/static/gsApp/views/subscriptionPage/decidePendingChanges.tsx @@ -1,10 +1,8 @@ import type {Organization} from 'sentry/types/organization'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; import type {Subscription} from 'getsentry/types'; import {PendingChanges} from './pendingChanges'; -import {PlanMigrationActive} from './planMigrationActive'; type Props = { organization: Organization; @@ -12,18 +10,5 @@ type Props = { }; export function DecidePendingChanges({subscription, organization}: Props) { - const {planMigrations, isLoading} = usePlanMigrations(); - if (isLoading) { - return null; - } - - const activeMigration = planMigrations.find( - ({dateApplied, cohort}) => dateApplied === null && cohort?.nextPlan - ); - - return activeMigration ? ( - - ) : ( - - ); + return ; } diff --git a/static/gsApp/views/subscriptionPage/notifications.spec.tsx b/static/gsApp/views/subscriptionPage/notifications.spec.tsx index 534d13b2f52b..0a1dffa939ec 100644 --- a/static/gsApp/views/subscriptionPage/notifications.spec.tsx +++ b/static/gsApp/views/subscriptionPage/notifications.spec.tsx @@ -20,11 +20,6 @@ describe('Subscription > Notifications', () => { method: 'GET', body: {reservedPercent: [90], perProductOndemandPercent: [80, 50]}, }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', diff --git a/static/gsApp/views/subscriptionPage/overview.spec.tsx b/static/gsApp/views/subscriptionPage/overview.spec.tsx index b03f95c9f79c..f617a6533e0a 100644 --- a/static/gsApp/views/subscriptionPage/overview.spec.tsx +++ b/static/gsApp/views/subscriptionPage/overview.spec.tsx @@ -2,17 +2,15 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; import {RecurringCreditFixture} from 'getsentry-test/fixtures/recurringCredit'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -import {textWithMarkupMatcher} from 'sentry-test/utils'; import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext'; import {PendingChangesFixture} from 'getsentry/__fixtures__/pendingChanges'; import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; -import {CohortId, PlanTier} from 'getsentry/types'; +import {PlanTier} from 'getsentry/types'; import Overview from 'getsentry/views/subscriptionPage/overview'; describe('Subscription > Overview', () => { @@ -26,12 +24,6 @@ describe('Subscription > Overview', () => { method: 'GET', body: CustomerUsageFixture(), }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/recurring-credits/`, method: 'GET', @@ -67,7 +59,7 @@ describe('Subscription > Overview', () => { SubscriptionStore.set(organization.slug, {}); }); - describe('Plan Migrations', () => { + describe('Pending Changes', () => { const subscription = SubscriptionFixture({ organization, plan: 'mm2_b_100k', @@ -90,62 +82,6 @@ describe('Subscription > Overview', () => { expect( await screen.findByText(/The following changes will take effect on/) ).toBeInTheDocument(); - - expect(screen.queryByText("We're updating our")).not.toBeInTheDocument(); - }); - - it('renders plan migration', async () => { - SubscriptionStore.set(organization.slug, subscription); - const planMigrations = [PlanMigrationFixture({cohortId: CohortId.SECOND})]; - const mockApi = MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: planMigrations, - }); - - render(, { - organization, - additionalWrapper: SecondaryNavigationContextProvider, - }); - - expect( - await screen.findByText(textWithMarkupMatcher("We're updating our Team Plan")) - ).toBeInTheDocument(); - expect( - screen.queryByText('The following changes will take effect on') - ).not.toBeInTheDocument(); - - expect(mockApi).toHaveBeenCalledTimes(1); - }); - - it('does not render already applied plan migration', async () => { - SubscriptionStore.set(organization.slug, subscription); - const planMigrations = [ - PlanMigrationFixture({ - cohortId: CohortId.SECOND, - dateApplied: '2021-08-01', - }), - ]; - const mockApi = MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: planMigrations, - }); - - render(, { - organization, - additionalWrapper: SecondaryNavigationContextProvider, - }); - - expect( - await screen.findByText(/The following changes will take effect on/) - ).toBeInTheDocument(); - - expect(screen.queryByText("We're updating our")).not.toBeInTheDocument(); - - expect(mockApi).toHaveBeenCalledTimes(1); }); }); diff --git a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx index a3cbcda390b0..26251a7ce547 100644 --- a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx @@ -21,12 +21,6 @@ describe('Subscription > PaymentHistory', () => { url: '/organizations/dogz-rule/promotions/trigger-check/', method: 'POST', }); - MockApiClient.addMockResponse({ - url: '/customers/dogz-rule/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: '/customers/dogz-rule/recurring-credits/', method: 'GET', diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx deleted file mode 100644 index e3ae1adac0c2..000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/index.spec.tsx +++ /dev/null @@ -1,908 +0,0 @@ -import moment from 'moment-timezone'; -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {PlanMigrationFixture} from 'getsentry-test/fixtures/planMigration'; -import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {ANNUAL} from 'getsentry/constants'; -import {CohortId} from 'getsentry/types'; -import {PlanMigrationActive} from 'getsentry/views/subscriptionPage/planMigrationActive/index'; - -describe('PlanMigrationActive cohort 2', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SECOND, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect( - screen.getByText('These plan changes will take place on Oct 25, 2018.') - ).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders null', () => { - render(); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders null with missing next plan', () => { - render( - - ); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(6); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$29/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$29\*/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders discount note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$29 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 3', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k_ac', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.THIRD, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders null', () => { - render(); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders null with missing next plan', () => { - render( - - ); - expect(screen.queryByTestId('plan-migration-panel')).not.toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('renders contract row', () => { - renderSimple(); - expect(screen.getByTestId('current-contract')).toHaveTextContent(/annual/); - expect(screen.getByTestId('new-contract')).toHaveTextContent(/monthly/); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$26/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$26/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$26 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 4', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'mm2_b_100k_auf', - billingInterval: 'annual', - organization, - }); - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.FOURTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Team Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getAllByText(migrationDate, {exact: false})).toHaveLength(2); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Team/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$312/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$480/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$312/); - }); - - it('renders renewal price row', () => { - renderSimple(); - expect(screen.getByTestId('current-renewal')).toHaveTextContent(/\$312/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$480/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$452/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders annual dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('annual-dollar-credits')).toHaveTextContent( - /\*Discount of \$168 for plan changes on Oct 18, 2017. An additional one-time \$28 discount applies at contract renewal on Oct 25, 2018\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 5', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'm1', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.FIFTH, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Medium Plan")).toBeInTheDocument(); - expect( - screen.getByText('These plan changes will take place on Oct 25, 2018.') - ).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(6); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Medium/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$199/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$484/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$199\*/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/1M/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/1M/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders discount note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$199 for 5 months, then changes to \$484 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 6', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 's1_ac', - organization, - }); - const renewalDate = moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SIXTH, - effectiveAt: renewalDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Small Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(renewalDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Small/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Team/); - }); - - it('renders contract row', () => { - renderSimple(); - expect(screen.getByTestId('current-contract')).toHaveTextContent(/annual/); - expect(screen.getByTestId('new-contract')).toHaveTextContent(/monthly/); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$26/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$44/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$26/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('dollar-credits')).toHaveTextContent( - /\*\$26 for 5 months, then changes to \$44 per month on Mar 25, 2019\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 7', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'm1_auf', - billingInterval: 'annual', - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.SEVENTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Medium Plan")).toBeInTheDocument(); - expect(screen.getAllByText('Performance Monitoring')).toHaveLength(2); - expect(screen.getByText('Event Attachments')).toBeInTheDocument(); - expect(screen.getByText('Stack Trace Linking')).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getAllByText(migrationDate, {exact: false})).toHaveLength(2); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(7); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Medium/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$2,148/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$5,232/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$2,148/); - }); - - it('renders renewal price row', () => { - renderSimple(); - expect(screen.getByTestId('current-renewal')).toHaveTextContent(/\$2,148/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$5,232/); - expect(screen.getByTestId('new-renewal')).toHaveTextContent(/\$4,718/); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/1M/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/1M/); - }); - - it('renders transactions row', () => { - renderSimple(); - expect(screen.getByTestId('current-transactions')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-transactions')).toHaveTextContent(/100K/); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/0/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders annual dollar credits note', () => { - renderSimple(); - expect(screen.getByTestId('annual-dollar-credits')).toHaveTextContent( - /\*Discount of \$3,084 for plan changes on Oct 18, 2017. An additional one-time \$514 discount applies at contract renewal on Oct 25, 2018\./ - ); - }); -}); - -describe('PlanMigrationActive cohort 8', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business', - organization, - }); - subscription.categories.errors!.reserved = 100_000; // test that it renders the correct next reserved values even if it's not the base volume - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.EIGHTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$89/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$89/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - // TODO(isabella): condense category-specific assertions into a single test - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/100K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/100K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('does not render profile duration row', () => { - renderSimple(); - expect(screen.queryByTestId('current-profileDuration')).not.toBeInTheDocument(); - expect(screen.queryByTestId('new-profileDuration')).not.toBeInTheDocument(); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 450 replays for the next 2 monthly usage cycles after your plan is upgraded, at no charge./ - ); - }); -}); - -describe('PlanMigrationActive cohort 9', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.NINTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$960/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$960/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/50K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/50K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 450 replays for the next 2 months following the end of your current annual contract, at no charge./ - ); - }); -}); - -describe('PlanMigrationActive cohort 10', () => { - const organization = OrganizationFixture(); - const am2BusinessPlan = PlanDetailsLookupFixture('am2_business_auf'); - const subscription = SubscriptionFixture({ - planDetails: am2BusinessPlan, - plan: 'am2_business_auf', - contractInterval: ANNUAL, - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.TENTH, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByText("We're updating our Business Plan")).toBeInTheDocument(); - expect( - screen.getByText('10M spans for easier debugging and performance monitoring') - ).toBeInTheDocument(); - expect( - screen.getByText('Simplified, cheaper pay-as-you-go pricing') - ).toBeInTheDocument(); - expect(screen.getByText('And more...')).toBeInTheDocument(); - expect(screen.getByText(migrationDate, {exact: false})).toBeInTheDocument(); - }); - - it('renders plan migration table', () => { - renderSimple(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders plan row', () => { - renderSimple(); - expect(screen.getByTestId('current-plan')).toHaveTextContent(/Legacy Business/); - expect(screen.getByTestId('new-plan')).toHaveTextContent(/Business/); - }); - - it('does not render contract row', () => { - renderSimple(); - expect(screen.queryByTestId('current-contract')).not.toBeInTheDocument(); - }); - - it('renders price row', () => { - renderSimple(); - expect(screen.getByTestId('current-price')).toHaveTextContent(/\$960/); - expect(screen.getByTestId('new-price')).toHaveTextContent(/\$960/); - }); - - it('does not renders renewal price row', () => { - renderSimple(); - expect(screen.queryByTestId('current-renewal')).not.toBeInTheDocument(); - }); - - it('renders errors row', () => { - renderSimple(); - expect(screen.getByTestId('current-errors')).toHaveTextContent(/50K errors/); - expect(screen.getByTestId('new-errors')).toHaveTextContent(/50K errors/); - }); - - it('renders spans row', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent( - /100K performance units/ - ); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByText(/Tracing and Performance Monitoring/)).toBeInTheDocument(); - }); - - it('renders attachments row', () => { - renderSimple(); - expect(screen.getByTestId('current-attachments')).toHaveTextContent(/1 GB/); - expect(screen.getByTestId('new-attachments')).toHaveTextContent(/1 GB/); - }); - - it('renders replays row', () => { - renderSimple(); - expect(screen.getByTestId('current-replays')).toHaveTextContent(/500 replays/); - expect(screen.getByTestId('new-replays')).toHaveTextContent(/50 replays/); - }); - - it('renders monitor seats row', () => { - renderSimple(); - expect(screen.getByTestId('current-monitorSeats')).toHaveTextContent( - /1 cron monitor/ - ); - expect(screen.getByTestId('new-monitorSeats')).toHaveTextContent(/1 cron monitor/); - }); - - it('renders replays credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*You'll retain the same monthly replay quota throughout the remainder of your annual subscription./ - ); - }); -}); - -describe('PlanMigrationActive cohort 111 -- TEST ONLY', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - plan: 'am3_business_auf', - organization, - }); - - const migrationDate = moment().add(1, 'days').format('ll'); - const migration = PlanMigrationFixture({ - cohortId: CohortId.TEST_ONE, - effectiveAt: migrationDate, - }); - - function renderSimple() { - render(); - } - - it('renders with active migration', () => { - renderSimple(); - expect(screen.getByTestId('plan-migration-panel')).toBeInTheDocument(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(8); - }); - - it('renders combined credit message', () => { - renderSimple(); - expect(screen.getByTestId('recurring-credits')).toHaveTextContent( - /\*We'll provide an additional 100000 errors for the next 1 months, 100000 replays for the next 1 months, and 100000 spans for the next 1 months following the end of your current annual contract, at no charge./ - ); - }); - - it('renders spans row with existing spans', () => { - renderSimple(); - expect(screen.getByTestId('current-spans')).toHaveTextContent(/10M spans/); - expect(screen.getByTestId('new-spans')).toHaveTextContent(/10M spans/); - expect( - screen.queryByText(/Tracing and Performance Monitoring/) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx deleted file mode 100644 index 3c154c49e922..000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/index.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import {useEffect} from 'react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {Button} from '@sentry/scraps/button'; -import {ExternalLink} from '@sentry/scraps/link'; - -import {Panel} from 'sentry/components/panels/panel'; -import {IconBusiness} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {ConfigStore} from 'sentry/stores/configStore'; -import {showIntercom} from 'sentry/utils/intercom'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {ZendeskLink} from 'getsentry/components/zendeskLink'; -import {ANNUAL} from 'getsentry/constants'; -import {CohortId, type PlanMigration, type Subscription} from 'getsentry/types'; -import {trackGetsentryAnalytics} from 'getsentry/utils/trackGetsentryAnalytics'; -import {PanelBodyWithTable} from 'getsentry/views/subscriptionPage/styles'; - -import {PlanMigrationTable} from './planMigrationTable'; - -type Props = { - migration: undefined | PlanMigration; - subscription: Subscription; -}; - -function NewFeature({title}: {title: string}) { - return ( - - - {title} - - ); -} - -function getMigrationDate(migration: PlanMigration, subscription: Subscription) { - if (migration.effectiveAt) { - return moment(migration.effectiveAt).format('ll'); - } - if (subscription.billingInterval === ANNUAL) { - return moment(subscription.onDemandPeriodEnd).add(1, 'days').format('ll'); - } - return moment(subscription.contractPeriodEnd).add(1, 'days').format('ll'); -} - -export function PlanMigrationActive({subscription, migration}: Props) { - const organization = useOrganization(); - const hasIntercom = organization.features.includes('intercom-support'); - const shouldRender = Boolean(migration?.cohort?.nextPlan); - - useEffect(() => { - if (shouldRender && hasIntercom) { - trackGetsentryAnalytics('intercom_link.viewed', { - organization, - source: 'billing', - }); - } - }, [shouldRender, hasIntercom, organization]); - - if (!migration?.cohort?.nextPlan) { - return null; - } - - async function handleIntercomClick() { - trackGetsentryAnalytics('intercom_link.clicked', { - organization, - source: 'billing', - }); - try { - await showIntercom(organization.slug); - } catch { - const supportEmail = ConfigStore.get('supportEmail'); - if (supportEmail) { - window.location.href = `mailto:${supportEmail}?subject=${window.encodeURIComponent('Legacy Plan Migration Question')}`; - } - } - } - - const supportLink = hasIntercom ? ( - - ) : ( - - ); - - const isAM3Migration = migration.cohort.cohortId >= CohortId.EIGHTH; - - return ( - - - - -

- {tct("We're updating our [planName] Plan", { - planName: subscription.planDetails.name, - })} -

-

- {tct('These plan changes will take place on [date].', { - date: getMigrationDate(migration, subscription), - })} -

-
-
{t('New Features:')}
- {isAM3Migration ? ( -

- - - -

- ) : ( -

- - - - -

- )} -
-
- - - {tct( - 'For more details please see our [faqLink:FAQ] or contact [supportLink:Support].', - { - faqLink: ( - - ), - supportLink, - } - )} - -
- -
-
- ); -} - -const StyledPanelBody = styled(PanelBodyWithTable)` - h6 { - font-weight: 400; - font-size: ${p => p.theme.font.size.lg}; - margin-bottom: ${p => p.theme.space.sm}; - } - - table { - margin-bottom: ${p => p.theme.space.md}; - } - - p, - h4 { - margin: 0; - } -`; - -const MigrationDetailsWithFooter = styled('div')` - display: grid; - grid-auto-flow: row; - align-content: space-between; -`; - -const MigrationDetails = styled('div')` - display: grid; - gap: ${p => p.theme.space['2xl']}; -`; - -const Feature = styled('span')` - display: grid; - grid-template-columns: max-content auto; - gap: ${p => p.theme.space.md}; - align-items: center; - align-content: center; -`; - -const MoreInfo = styled('p')` - font-size: ${p => p.theme.font.size.sm}; -`; diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx deleted file mode 100644 index ad498ad7c250..000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {DataCategoryExact} from 'sentry/types/core'; - -import {PlanMigrationRow} from 'getsentry/views/subscriptionPage/planMigrationActive/planMigrationRow'; - -function renderRow(props: React.ComponentProps) { - return render( - - - - -
- ); -} - -describe('PlanMigrationRow', () => { - it.each([ - ['attachment', DataCategoryExact.ATTACHMENT, 'attachments'], - ['log_byte', DataCategoryExact.LOG_BYTE, 'logBytes'], - ['trace_metric_byte', DataCategoryExact.TRACE_METRIC_BYTE, 'traceMetricBytes'], - ])( - 'renders byte category %s with GB suffix and no appended display name', - (_label, category, testIdSuffix) => { - renderRow({type: category, currentValue: 10, nextValue: 20}); - - const currentCell = screen.getByTestId(`current-${testIdSuffix}`); - const newCell = screen.getByTestId(`new-${testIdSuffix}`); - - expect(currentCell).toHaveTextContent(/GB$/); - expect(newCell).toHaveTextContent(/GB$/); - } - ); - - it('renders PROFILE_DURATION with hours suffix', () => { - renderRow({ - type: DataCategoryExact.PROFILE_DURATION, - currentValue: 1, - nextValue: 5, - }); - - const currentCell = screen.getByTestId('current-profileDuration'); - const newCell = screen.getByTestId('new-profileDuration'); - - expect(currentCell).toHaveTextContent(/hour$/); - expect(newCell).toHaveTextContent(/hours$/); - }); - - it('renders count category with display name', () => { - renderRow({ - type: DataCategoryExact.ERROR, - currentValue: 50000, - nextValue: 100000, - }); - - const currentCell = screen.getByTestId('current-errors'); - const newCell = screen.getByTestId('new-errors'); - - expect(currentCell).not.toHaveTextContent(/GB/); - expect(newCell).not.toHaveTextContent(/GB/); - expect(currentCell).toHaveTextContent(/errors?$/i); - expect(newCell).toHaveTextContent(/errors?$/i); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx deleted file mode 100644 index 3972d596d155..000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import styled from '@emotion/styled'; - -import {DATA_CATEGORY_INFO} from 'sentry/constants'; -import {IconArrow} from 'sentry/icons'; -import {tct} from 'sentry/locale'; -import {DataCategoryExact} from 'sentry/types/core'; - -import {formatReservedWithUnits} from 'getsentry/utils/billing'; -import {displayPrice} from 'getsentry/views/amCheckout/utils'; - -type Props = DataRow | PriceRow | RenewalPriceRow | PlanRow | ContractRow; - -type DataRow = { - currentValue: number | null; - nextValue: number | null; - type: DataCategoryExact; - hasCredits?: boolean; - previousType?: DataCategoryExact; - titleOverride?: string; -}; - -type PriceRow = { - currentValue: number; - discountPrice: number; - nextValue: number; - type: 'price'; - hasCredits?: boolean; -}; - -// RenewalPriceRow shown for AUF plans only to differentiate between the first discount at the migration and second discounts at annual contract renewal -type RenewalPriceRow = { - currentValue: number; - discountPrice: number; - nextValue: number; - type: 'renewal'; - hasCredits?: boolean; -}; - -type PlanRow = { - currentValue: string; - nextValue: string; - type: 'plan'; -}; - -type ContractRow = { - currentValue: string; - nextValue: string; - type: 'contract'; -}; - -function formatCategoryRowString( - category: DataCategoryExact, - quantity: number | null, - options: {isAbbreviated: boolean} -): string { - const reservedWithUnits = formatReservedWithUnits( - quantity, - DATA_CATEGORY_INFO[category].plural, - options - ); - if (DATA_CATEGORY_INFO[category].formatting.unitType === 'bytes') { - return reservedWithUnits; - } - - if (category === DataCategoryExact.PROFILE_DURATION) { - const postfix = reservedWithUnits === '1' ? 'hour' : 'hours'; - return `${reservedWithUnits} ${postfix}`; - } - - if (category === DataCategoryExact.TRANSACTION) { - return `${reservedWithUnits} performance units`; - } - - const displayName = DATA_CATEGORY_INFO[category].displayName; - const plural = `${displayName}s`; - return `${reservedWithUnits} ${quantity === 1 ? displayName : plural}`; -} - -export function PlanMigrationRow(props: Props) { - let currentValue: React.ReactNode; - let nextValue: React.ReactNode; - let discountPrice: string | undefined; - let currentTitle: React.ReactNode = - DATA_CATEGORY_INFO[props.type as DataCategoryExact]?.productName ?? props.type; - const dataTestIdSuffix: string = - DATA_CATEGORY_INFO[props.type as DataCategoryExact]?.plural ?? props.type; - - const options = {isAbbreviated: true}; - - // TODO(data categories): BIL-955 - switch (props.type) { - case 'plan': - currentValue = tct('Legacy [currentValue]', {currentValue: props.currentValue}); - nextValue = props.nextValue; - break; - case 'contract': - currentValue = props.currentValue; - nextValue = props.nextValue; - break; - case 'price': - currentValue = displayPrice({cents: props.currentValue}); - discountPrice = displayPrice({cents: props.discountPrice}); - nextValue = displayPrice({cents: props.nextValue}); - break; - case 'renewal': - currentValue = displayPrice({cents: props.currentValue}); - discountPrice = displayPrice({cents: props.discountPrice}); - nextValue = displayPrice({cents: props.nextValue}); - currentTitle = 'renewal price'; - break; - default: { - // assume DataCategoryExact - currentValue = formatCategoryRowString( - props.previousType ?? props.type, - props.currentValue, - options - ); - const formattedNextValue = formatCategoryRowString( - props.type, - props.nextValue, - options - ); - nextValue = props.hasCredits ? `${formattedNextValue}*` : formattedNextValue; - if (props.titleOverride) { - currentTitle = props.titleOverride; - } - break; - } - } - - const hasDiscount = - (props.type === 'price' || props.type === 'renewal') && props.hasCredits; - - return ( - - {currentTitle} - {currentValue} - - - - {hasDiscount ? ( - - {nextValue} - {`${discountPrice}*`} - - ) : ( - {nextValue} - )} - - ); -} - -const Title = styled('td')` - text-transform: capitalize; -`; - -const DiscountCell = styled('td')` - display: flex; - gap: ${p => p.theme.space.md}; - justify-content: flex-end; -`; - -const DiscountedPrice = styled('span')` - text-decoration: line-through; - font-weight: 400; -`; diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx deleted file mode 100644 index d368e9971d94..000000000000 --- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationTable.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {DATA_CATEGORY_INFO} from 'sentry/constants'; -import {t, tct} from 'sentry/locale'; -import {DataCategory, DataCategoryExact} from 'sentry/types/core'; -import {oxfordizeArray} from 'sentry/utils/oxfordizeArray'; - -import {ANNUAL, MONTHLY} from 'getsentry/constants'; -import { - CohortId, - type NextPlanInfo, - type PlanMigration, - type Subscription, -} from 'getsentry/types'; -import {getCategoryInfoFromPlural} from 'getsentry/utils/dataCategory'; -import {displayPrice} from 'getsentry/views/amCheckout/utils'; -import {AlertStripedTable} from 'getsentry/views/subscriptionPage/styles'; - -import {PlanMigrationRow} from './planMigrationRow'; - -type Props = { - migration: PlanMigration; - subscription: Subscription; -}; - -export function PlanMigrationTable({subscription, migration}: Props) { - if (!migration?.cohort?.nextPlan) { - return null; - } - - // migrations from AM1/AM2 to AM3 - const isAM3Migration = - migration.cohort.cohortId >= CohortId.EIGHTH && - migration.cohort.cohortId <= CohortId.TENTH; - - const planName = subscription.planDetails.name; - const planPrice = subscription.planDetails.price; - - const planTerm = subscription.planDetails.contractInterval; - const cohort = migration.cohort; - const nextPlan = cohort.nextPlan!; - const secondDiscount = cohort.secondDiscount; - // Setting default to monthly to handle nextPlan if the endpoint update is not updated yet - // Prior plan migrations are all monthly contracts - const nextPlanTerm = nextPlan.contractPeriod ?? MONTHLY; - // The nextPlan.discountAmount is handled differently for monthly & annual billing intervals. Using these checks to display correct info - const hasMonthlyDiscount = !!( - nextPlan.discountAmount && - nextPlan.discountMonths && - subscription.billingInterval === MONTHLY - ); - const hasAnnualDiscount = !!( - nextPlan.discountAmount && - nextPlan.discountMonths && - subscription.billingInterval === ANNUAL - ); - const hasSecondDiscount = !!(secondDiscount && hasAnnualDiscount); - const annualMigrationDate = migration.effectiveAt - ? moment(migration.effectiveAt).format('ll') - : moment(subscription.onDemandPeriodEnd).add(1, 'days').format('ll'); - - const getRowParamsForCategory = (category: DataCategory) => { - // for AM1/AM2 to AM3 migrations, we move from transactions-based billing to spans-based billing - // so we render the row as a transition from reserved transactions volume to reserved spans volume - const isSpans = category === DataCategory.SPANS; - const shouldShowCurrentSpans = - isSpans && !!subscription.categories[category]?.reserved; - const isTransactionsToSpansMigration = isSpans && !shouldShowCurrentSpans; - - const currentValue = isTransactionsToSpansMigration - ? (subscription.categories.transactions?.reserved ?? null) - : (subscription.categories[category]?.reserved ?? null); - const titleOverride = isTransactionsToSpansMigration - ? t('Tracing and Performance Monitoring') - : undefined; - const previousType = isTransactionsToSpansMigration - ? DataCategoryExact.TRANSACTION - : undefined; - - const categoryInfo = getCategoryInfoFromPlural(category); - if (!categoryInfo) { - return null; - } - - const type = categoryInfo.name; - - const nextValue = getNextDataCategoryValue( - nextPlan, - isAM3Migration, // update this if shouldUseExistingVolume should be true for future migrations - type, - subscription - ); - - return { - type, - previousType, - currentValue, - nextValue, - hasCredits: !!nextPlan.categoryCredits?.[category]?.credits, - titleOverride, - }; - }; - - const sortRowParamMappings = ( - rowParamsMapping: Array> - ) => { - return rowParamsMapping - .filter(rowParams => !!rowParams) - .sort((a, b) => { - // sort based on order of the categories in the subscription's current plan - // if previousType exists, we need to use that since it means we're migrating - // from a category on the subscription's current plan that won't be available - // in the new plan - const aCategoryExact = a?.previousType ?? a?.type; - const bCategoryExact = b?.previousType ?? b?.type; - const aCategory = aCategoryExact - ? DATA_CATEGORY_INFO[aCategoryExact]?.plural - : null; - const bCategory = bCategoryExact - ? DATA_CATEGORY_INFO[bCategoryExact]?.plural - : null; - const aOrder = aCategory - ? (subscription.categories[aCategory]?.order ?? Infinity) - : Infinity; - const bOrder = bCategory - ? (subscription.categories[bCategory]?.order ?? Infinity) - : Infinity; - return aOrder - bOrder; - }); - }; - - const getCategoryRows = () => { - const rowParamsMapping = Object.entries(nextPlan.reserved) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([category, _]) => getRowParamsForCategory(category as DataCategory)); - - return sortRowParamMappings(rowParamsMapping).map(rowParams => ( - - )); - }; - - return ( - - - - - - {t('Current')} - - {t('New')} - - - - - {planTerm !== nextPlanTerm && ( - - )} - - {hasAnnualDiscount && ( - - )} - {getCategoryRows()} - - - {hasMonthlyDiscount && ( - - * - {tct( - '[currentPrice] for [discountMonths] months, then changes to [nextPrice] per month on [discountEndDate].', - { - currentPrice: displayPrice({cents: subscription.planDetails.price}), - discountMonths: nextPlan.discountMonths, - nextPrice: displayPrice({cents: nextPlan.totalPrice}), - discountEndDate: moment(subscription.contractPeriodEnd) - .add(nextPlan.discountMonths, 'months') - .add(1, 'days') - .format('ll'), - } - )} - - )} - {hasAnnualDiscount && ( - - * - {tct('Discount of [firstDiscount] for plan changes on [migrationDate].', { - migrationDate: annualMigrationDate, - firstDiscount: displayPrice({ - cents: nextPlan.totalPrice - subscription.planDetails.price, - }), - })} - {hasSecondDiscount && - tct( - ' An additional one-time [secondDiscount] discount applies at contract renewal on [contractRenewalDate].', - { - secondDiscount: displayPrice({cents: secondDiscount}), - contractRenewalDate: moment(subscription.contractPeriodEnd) - .add(1, 'days') - .format('ll'), - } - )} - - )} - {getCategoryCredits(migration.cohort.cohortId, nextPlan)} - - ); -} - -function getNextDataCategoryValue( - nextPlan: NextPlanInfo, - shouldUseExistingVolume: boolean, - category: DataCategoryExact, - subscription: Subscription -) { - const key = DATA_CATEGORY_INFO[category].plural as DataCategory; - if ( - shouldUseExistingVolume && - subscription.planDetails.categories.includes(key) && - subscription.categories[key]?.reserved !== - subscription.planDetails.planCategories[key]![0]!.events - ) { - return subscription.categories[key]!.reserved; - } - return nextPlan.reserved[key] ?? null; -} - -function getCategoryCredits(cohortId: CohortId, nextPlan: NextPlanInfo) { - if (!nextPlan.categoryCredits) { - return null; - } - - let message: string; - if (cohortId === CohortId.TENTH) { - message = - "You'll retain the same monthly replay quota throughout the remainder of your annual subscription."; - } else { - const categoryCredits = nextPlan.categoryCredits; - - message = "We'll provide an additional "; - const isAnnualNextPlan = nextPlan.contractPeriod === 'annual'; - - const creditsToDisplay: string[] = []; - - Object.entries(categoryCredits) - .filter(([_, creditInfo]) => creditInfo.credits !== null) - .forEach(([category, creditInfo]) => { - const {credits, months} = creditInfo; - if (credits !== 0 && months !== 0) { - creditsToDisplay.push( - `${credits} ${category} for the next ${months} ${isAnnualNextPlan ? 'months' : 'monthly usage cycles'}` - ); - } - }); - - message += oxfordizeArray(creditsToDisplay); - - if (nextPlan.contractPeriod === 'annual') { - message += ' following the end of your current annual contract'; - } else { - message += ' after your plan is upgraded'; - } - message += ', at no charge.'; - } - - return ( - *{tct('[message]', {message})} - ); -} - -const TableContainer = styled('div')` - display: grid; - grid-auto-flow: row; - align-content: space-between; -`; - -const Credits = styled('p')` - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; -`; diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx index 5e81c3e1ed60..e439dc16aede 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx @@ -35,12 +35,6 @@ describe('SubscriptionHeader', () => { url: '/organizations/org-slug/promotions/trigger-check/', method: 'POST', }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/prompts-activity/', body: {}, diff --git a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx index 45e83f37754a..0892c3aeb215 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.spec.tsx @@ -15,21 +15,10 @@ describe('SubscriptionUpsellBanner', () => { url: '/organizations/org-slug/prompts-activity/', body: promptResponse, }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/?applied=0', - method: 'GET', - body: {}, - }); MockApiClient.addMockResponse({ url: '/customers/org-slug/', body: {}, }); - MockApiClient.addMockResponse({ - url: '/customers/org-slug/plan-migrations/', - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); }); it('should render banner for users on free plan with billing access', async () => { diff --git a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx index 8c40ac8b3fc2..0997f509582f 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionUpsellBanner.tsx @@ -11,7 +11,6 @@ import type {Organization} from 'sentry/types/organization'; import {openUpsellModal} from 'getsentry/actionCreators/modal'; import UpgradeOrTrialButton from 'getsentry/components/upgradeOrTrialButton'; -import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations'; import type {Subscription} from 'getsentry/types'; import { hasPartnerMigrationFeature, @@ -78,16 +77,9 @@ function useIsSubscriptionUpsellHidden( subscription: Subscription, organization: Organization ): boolean { - const {planMigrations, isLoading} = usePlanMigrations(); - // Hide while loading - if (isLoading) { - return true; - } - - // hide upsell for mmx plans and forced plan migrations + // hide upsell for mmx plans const isLegacyUpsell = - (!hasPerformance(subscription.planDetails) || planMigrations.length > 0) && - !subscription.canTrial; + !hasPerformance(subscription.planDetails) && !subscription.canTrial; // hide upsell for customers on partner plans with flag const hasEndingPartnerPlan = hasPartnerMigrationFeature(organization); diff --git a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx index 0875883f8417..d6c1a7f93347 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx @@ -49,12 +49,6 @@ describe('Subscription > UsageHistory', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, diff --git a/static/gsApp/views/subscriptionPage/usageLog.spec.tsx b/static/gsApp/views/subscriptionPage/usageLog.spec.tsx index 302bc2e173f5..add29c11be71 100644 --- a/static/gsApp/views/subscriptionPage/usageLog.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageLog.spec.tsx @@ -39,12 +39,6 @@ describe('Subscription Usage Log', () => { url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/recurring-credits/`, method: 'GET', diff --git a/tests/js/getsentry-test/fixtures/planMigration.ts b/tests/js/getsentry-test/fixtures/planMigration.ts deleted file mode 100644 index de037a5cc5cb..000000000000 --- a/tests/js/getsentry-test/fixtures/planMigration.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type {Cohort, PlanMigration as PlanMigrationType} from 'getsentry/types'; -import {CohortId} from 'getsentry/types'; - -const SecondCohort: Cohort = { - cohortId: CohortId.SECOND, - nextPlan: { - id: 'am1_team', - name: 'Team', - totalPrice: 4400, - - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1500, - discountMonths: 5, - contractPeriod: 'monthly', - categoryCredits: { - errors: { - credits: 0, - months: 0, - }, - }, - }, - secondDiscount: 0, -}; - -const ThirdCohort: Cohort = { - cohortId: CohortId.THIRD, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 4400, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1800, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const FourthCohort: Cohort = { - cohortId: CohortId.FOURTH, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 48000, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 16800, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 2800, -}; - -const FifthCohort: Cohort = { - cohortId: CohortId.FIFTH, - nextPlan: { - id: 'am2_business', - name: 'Business', - totalPrice: 48400, - reserved: {errors: 1_000_000, transactions: 100000, attachments: 1}, - discountAmount: 28500, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const SixthCohort: Cohort = { - cohortId: CohortId.SIXTH, - nextPlan: { - id: 'am2_team', - name: 'Team', - totalPrice: 4400, - reserved: {errors: 100000, transactions: 100000, attachments: 1}, - discountAmount: 1800, - discountMonths: 5, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const SeventhCohort: Cohort = { - cohortId: CohortId.SEVENTH, - nextPlan: { - id: 'am2_business', - name: 'Business', - totalPrice: 523200, - reserved: {errors: 1_000_000, transactions: 100000, attachments: 1}, - discountAmount: 308400, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 51400, -}; - -const EighthCohort: Cohort = { - cohortId: CohortId.EIGHTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 89_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'monthly', - }, - secondDiscount: 0, -}; - -const NinthCohort: Cohort = { - cohortId: CohortId.NINTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const TenthCohort: Cohort = { - cohortId: CohortId.TENTH, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - replays: { - credits: 450, - months: 2, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const testOneCohort: Cohort = { - cohortId: CohortId.TEST_ONE, - nextPlan: { - id: 'am3_business', - name: 'Business', - totalPrice: 960_00, - reserved: { - errors: 50_000, - replays: 50, - spans: 10_000_000, - attachments: 1, - monitorSeats: 1, - }, - categoryCredits: { - errors: { - credits: 100_000, - months: 1, - }, - replays: { - credits: 100_000, - months: 1, - }, - spans: { - credits: 100_000, - months: 1, - }, - }, - discountAmount: 0, - discountMonths: 1, - contractPeriod: 'annual', - }, - secondDiscount: 0, -}; - -const CohortLookup: Record = { - [CohortId.SECOND]: SecondCohort, - [CohortId.THIRD]: ThirdCohort, - [CohortId.FOURTH]: FourthCohort, - [CohortId.FIFTH]: FifthCohort, - [CohortId.SIXTH]: SixthCohort, - [CohortId.SEVENTH]: SeventhCohort, - [CohortId.EIGHTH]: EighthCohort, - [CohortId.NINTH]: NinthCohort, - [CohortId.TENTH]: TenthCohort, - [CohortId.TEST_ONE]: testOneCohort, -}; - -export function PlanMigrationFixture({ - cohortId, - ...params -}: {cohortId: CohortId} & Partial): PlanMigrationType { - return { - id: 1, - cohort: CohortLookup[cohortId] ?? null, - dateApplied: null, - planTier: 'am2', - scheduled: false, - effectiveAt: '', - recurringCredits: [], - ...params, - }; -} From ceb6ae89997aab6d002fef1cac849121560e5c5c Mon Sep 17 00:00:00 2001 From: Lyn <1779792+lynnagara@users.noreply.github.com> Date: Wed, 27 May 2026 13:19:07 -0700 Subject: [PATCH 29/37] feat(cells): Remove cross-org feature gating from quota notifications (#115937) The org listing endpoint returns the smaller OrganizationSummary type not the full Organization. OrganizationSummary is a strict subset of organization and does not include a number of fields, such as `features` (as these can no longer be returned from the control silo). Since `features` is not present on OrganizationSummary, this change unconditionally shows every category on the notifications list page, and doesn't hide any. This seems the safer direction to go, as the notificationSettingsByType component is primarily just a list of links -- the actual orgs that the change would be applied to is configured on the linked view. The exception to this pattern is self-hosted. This follows the pattern in https://github.com/getsentry/sentry/pull/115829 which hid the `quota` entry from the notification settings index on self-hosted. This PR adds the same `isSelfHosted` gate so a direct link to the quota page returns null on self-hosted instead of rendering categories that don't apply. --- static/app/components/contextPickerModal.tsx | 8 +- static/app/components/idBadge/baseBadge.tsx | 4 +- .../components/idBadge/organizationBadge.tsx | 4 +- static/app/stores/organizationsStore.tsx | 20 +- .../primary/organizationDropdown.tsx | 6 +- .../settings/account/notifications/fields.tsx | 26 +- .../notificationSettingsByEntity.tsx | 10 +- .../notificationSettingsByType.spec.tsx | 276 +----------------- .../notificationSettingsByType.tsx | 115 +------- .../organizationSelectHeader.tsx | 4 +- 10 files changed, 45 insertions(+), 428 deletions(-) diff --git a/static/app/components/contextPickerModal.tsx b/static/app/components/contextPickerModal.tsx index dd5008c55e09..ea107161cdfa 100644 --- a/static/app/components/contextPickerModal.tsx +++ b/static/app/components/contextPickerModal.tsx @@ -27,7 +27,7 @@ import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Integration} from 'sentry/types/integrations'; -import type {Organization, Team} from 'sentry/types/organization'; +import type {OrganizationSummary, Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; @@ -67,7 +67,7 @@ type SharedProps = ModalRenderProps & { }; type ContentProps = SharedProps & { - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: (slug: string) => void; projectSlugs?: string[]; @@ -464,7 +464,7 @@ function TeamSelector({ function ConfigUrlContainer( props: SharedProps & { configQueryKey: ApiQueryKey; - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: Dispatch>; } @@ -514,7 +514,7 @@ function ConfigPickerContent({ Body, }: SharedProps & { integrationConfigs: Integration[]; - organizations: Organization[]; + organizations: OrganizationSummary[]; selectedOrgSlug: string | undefined; setSelectedOrgSlug: Dispatch>; }) { diff --git a/static/app/components/idBadge/baseBadge.tsx b/static/app/components/idBadge/baseBadge.tsx index 827a613732e2..7cb6f5252fbc 100644 --- a/static/app/components/idBadge/baseBadge.tsx +++ b/static/app/components/idBadge/baseBadge.tsx @@ -11,7 +11,7 @@ import { import {Flex} from '@sentry/scraps/layout'; import type {Actor} from 'sentry/types/core'; -import type {Organization, Team} from 'sentry/types/organization'; +import type {OrganizationSummary, Team} from 'sentry/types/organization'; import type {AvatarProject} from 'sentry/types/project'; import type {AvatarUser} from 'sentry/types/user'; import type {SpaceSize} from 'sentry/utils/theme'; @@ -30,7 +30,7 @@ export interface BaseBadgeProps { interface AllBaseBadgeProps extends BaseBadgeProps { displayName: React.ReactNode; actor?: Actor; - organization?: Organization; + organization?: OrganizationSummary; project?: AvatarProject; team?: Team; user?: AvatarUser; diff --git a/static/app/components/idBadge/organizationBadge.tsx b/static/app/components/idBadge/organizationBadge.tsx index 8ec36c5b51b6..8c1af86ba5d7 100644 --- a/static/app/components/idBadge/organizationBadge.tsx +++ b/static/app/components/idBadge/organizationBadge.tsx @@ -1,10 +1,10 @@ -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import {BadgeDisplayName} from './badgeDisplayName'; import {BaseBadge, type BaseBadgeProps} from './baseBadge'; export interface OrganizationBadgeProps extends BaseBadgeProps { - organization: Organization; + organization: OrganizationSummary; /** * When true will default max-width, or specify one as a string */ diff --git a/static/app/stores/organizationsStore.tsx b/static/app/stores/organizationsStore.tsx index 23b7c4d07b35..376142669bc0 100644 --- a/static/app/stores/organizationsStore.tsx +++ b/static/app/stores/organizationsStore.tsx @@ -1,22 +1,22 @@ import {createStore} from 'reflux'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import type {StrictStoreDefinition} from './types'; interface State { loaded: boolean; - organizations: Organization[]; + organizations: OrganizationSummary[]; } interface OrganizationsStoreDefinition extends StrictStoreDefinition { - addOrReplace(item: Organization): void; - get(slug: string): Organization | undefined; - getAll(): Organization[]; - load(items: Organization[]): void; - onChangeSlug(prev: Organization, next: Partial): void; + addOrReplace(item: OrganizationSummary): void; + get(slug: string): OrganizationSummary | undefined; + getAll(): OrganizationSummary[]; + load(items: OrganizationSummary[]): void; + onChangeSlug(prev: OrganizationSummary, next: Partial): void; onRemoveSuccess(slug: string): void; - onUpdate(org: Partial): void; + onUpdate(org: Partial): void; remove(slug: string): void; } @@ -62,7 +62,7 @@ const storeConfig: OrganizationsStoreDefinition = { }, get(slug) { - return this.state.organizations.find((item: Organization) => item.slug === slug); + return this.state.organizations.find(item => item.slug === slug); }, getAll() { @@ -97,7 +97,7 @@ const storeConfig: OrganizationsStoreDefinition = { this.trigger(newOrgs); }, - load(items: Organization[]) { + load(items: OrganizationSummary[]) { const newOrgs = [...items]; this.state = {organizations: newOrgs, loaded: true}; this.trigger(newOrgs); diff --git a/static/app/views/navigation/primary/organizationDropdown.tsx b/static/app/views/navigation/primary/organizationDropdown.tsx index 8b3b54757435..0ea87d4363ae 100644 --- a/static/app/views/navigation/primary/organizationDropdown.tsx +++ b/static/app/views/navigation/primary/organizationDropdown.tsx @@ -18,7 +18,7 @@ import {t, tn} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import {isDemoModeActive} from 'sentry/utils/demoMode'; import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -184,7 +184,7 @@ export function OrganizationDropdown(props: OrganizationDropdownProps) { ); } -function makeOrganizationMenuItem(org: Organization): MenuItemProps { +function makeOrganizationMenuItem(org: OrganizationSummary): MenuItemProps { return { key: org.id, label: , @@ -193,7 +193,7 @@ function makeOrganizationMenuItem(org: Organization): MenuItemProps { }; } -function makeInactiveOrganizationMenuItem(org: Organization): MenuItemProps { +function makeInactiveOrganizationMenuItem(org: OrganizationSummary): MenuItemProps { return { ...makeOrganizationMenuItem(org), trailingItems: , diff --git a/static/app/views/settings/account/notifications/fields.tsx b/static/app/views/settings/account/notifications/fields.tsx index 1d5056430ceb..a83836e840d0 100644 --- a/static/app/views/settings/account/notifications/fields.tsx +++ b/static/app/views/settings/account/notifications/fields.tsx @@ -318,7 +318,11 @@ export const QUOTA_FIELDS = [ label: ( {t('Spend Allocations')}{' '} - + ), help: t('Receive notifications about your spend allocations.'), @@ -328,23 +332,3 @@ export const QUOTA_FIELDS = [ ] as const, }, ]; - -export const SPEND_FIELDS = [ - { - name: 'quota', - label: t('Spend Notifications'), - help: tct( - 'Receive notifications when your spend crosses predefined or custom thresholds. [learnMore:Learn more]', - { - learnMore: ( - - ), - } - ), - choices: [ - ['always', t('On')], - ['never', t('Off')], - ] as const, - }, - ...QUOTA_FIELDS.slice(1), -]; diff --git a/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx b/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx index 56b22a331664..c2e20127ef2b 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx @@ -17,7 +17,7 @@ import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {IconAdd, IconDelete} from 'sentry/icons'; import {t} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; -import type {Organization} from 'sentry/types/organization'; +import type {OrganizationSummary} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useLocation} from 'sentry/utils/useLocation'; @@ -38,7 +38,7 @@ interface NotificationSettingsByEntityProps { handleRemoveNotificationOption: (id: string) => void; notificationOptions: NotificationOptionsObject[]; notificationType: string; - organizations: Organization[]; + organizations: OrganizationSummary[]; } export function NotificationSettingsByEntity({ @@ -92,7 +92,7 @@ export function NotificationSettingsByEntity({ // always loading all projects even though we only need it sometimes const entities = entityType === 'project' ? projects || [] : organizations; // create maps by the project id for constant time lookups - const entityById = keyBy(entities, 'id'); + const entityById = keyBy(entities, 'id'); const handleOrgChange = (organizationId: string) => { navigate( @@ -136,7 +136,7 @@ export function NotificationSettingsByEntity({ const idBadgeProps = entityType === 'project' ? {project: entity as Project} - : {organization: entity as Organization}; + : {organization: entity as OrganizationSummary}; return ( @@ -190,7 +190,7 @@ export function NotificationSettingsByEntity({ const idBadgeProps = entityType === 'project' ? {project: entity as Project} - : {organization: entity as Organization}; + : {organization: entity as OrganizationSummary}; return { label: entityType === 'project' ? obj.slug : obj.name, diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx index a7cc61b90e6a..40f5758dccd0 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx @@ -157,15 +157,13 @@ describe('NotificationSettingsByType', () => { 'Receive notifications when your organization exceeds the following limits.' ) ).toBeInTheDocument(); - expect( - await screen.findByText('Receive notifications about your error quotas.') - ).toBeInTheDocument(); expect(screen.getByText('Errors')).toBeInTheDocument(); expect(screen.getByText('Transactions')).toBeInTheDocument(); + expect(screen.getByText('Spans')).toBeInTheDocument(); expect(screen.getByText('Session Replays')).toBeInTheDocument(); expect(screen.getByText('Attachments')).toBeInTheDocument(); + expect(screen.getByText('Seer Budget')).toBeInTheDocument(); expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.queryByText('Spans')).not.toBeInTheDocument(); }); it('adds a project override and removes it', async () => { renderComponent({}); @@ -263,272 +261,10 @@ describe('NotificationSettingsByType', () => { expect(changeProvidersMock).toHaveBeenCalledTimes(1); }); - it('renders spend notifications page instead of quota notifications with flag', async () => { - const organizationWithFlag = OrganizationFixture(); - organizationWithFlag.features.push('spend-visibility-notifications'); - const organizationNoFlag = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organizationWithFlag, organizationNoFlag], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - expect(screen.queryByText('Quota Notifications')).not.toBeInTheDocument(); - expect( - screen.getByText('Control the notifications you receive for organization spend.') - ).toBeInTheDocument(); - }); - - it('toggle user spend notifications', async () => { - const organizationWithFlag = OrganizationFixture(); - organizationWithFlag.features.push('spend-visibility-notifications'); - const organizationNoFlag = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organizationWithFlag, organizationNoFlag], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quota', - value: 'never', - }, - }); - - // toggle spend notifications off - await selectEvent.select(screen.getAllByText('On')[0]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quota', - value: 'never', - }, - }) - ); - }); + it('hides quota notifications on self-hosted', () => { + ConfigStore.set('isSelfHosted', true); + const {container} = renderComponent({notificationType: 'quota'}); - it('spend notifications on org with am3 with spend visibility notifications', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - 'continuous-profiling-billing', - 'seer-billing', - 'logs-billing', - 'expose-category-trace-metric-byte', - 'seer-user-billing-launch', - ], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - expect(screen.getByText('Logs')).toBeInTheDocument(); - expect(screen.getByText('Active Contributors')).toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }); - - // toggle spans quota notifications off - await selectEvent.select(screen.getAllByText('On')[4]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }) - ); - }); - - it('spend notifications on org with am3 and org without am3', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - 'continuous-profiling-billing', - 'seer-billing', - ], - }); - const otherOrganization = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organization, otherOrganization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.getByText('Transactions')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - }); - - it('spend notifications on org with am1 org only', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am1-tier', - 'continuous-profiling-billing', - 'seer-billing', - ], - }); - const otherOrganization = OrganizationFixture(); - renderComponent({ - notificationType: 'quota', - organizations: [organization, otherOrganization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect(screen.getByText('Transactions')).toBeInTheDocument(); - expect( - screen.queryByText('Continuous Profile Hours', {exact: true}) - ).not.toBeInTheDocument(); - expect(screen.queryByText('UI Profile Hours', {exact: true})).not.toBeInTheDocument(); - expect(screen.queryByText('Spans')).not.toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - expect(screen.getByText('Size Analysis Builds')).toBeInTheDocument(); - }); - - it('spend notifications on org with am3 without spend visibility notifications', async () => { - const organization = OrganizationFixture({ - features: ['am3-tier', 'continuous-profiling-billing', 'seer-billing'], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Errors')).toBeInTheDocument(); - expect(screen.queryByText('Spend Notifications')).not.toBeInTheDocument(); - - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - expect( - screen.getByText('Continuous Profile Hours', {exact: true}) - ).toBeInTheDocument(); - expect(screen.getByText('UI Profile Hours', {exact: true})).toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - expect(screen.getByText('Seer Budget')).toBeInTheDocument(); - - const editSettingMock = MockApiClient.addMockResponse({ - url: '/users/me/notification-options/', - method: 'PUT', - body: { - id: '7', - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }); - - // toggle spans quota notifications off - await selectEvent.select(screen.getAllByText('On')[3]!, 'Off'); - - expect(editSettingMock).toHaveBeenCalledTimes(1); - expect(editSettingMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: { - scopeIdentifier: '1', - scopeType: 'user', - type: 'quotaSpans', - value: 'never', - }, - }) - ); - }); - - it('should not show categories without related features', async () => { - const organization = OrganizationFixture({ - features: [ - 'spend-visibility-notifications', - 'am3-tier', - // No continuous-profiling-billing feature - // No seer-billing feature - // No logs-billing feature - // No expose-category-trace-metric-byte feature - ], - }); - renderComponent({ - notificationType: 'quota', - organizations: [organization], - }); - - expect(await screen.findByText('Spend Notifications')).toBeInTheDocument(); - - // These should be present - expect(screen.getByText('Errors')).toBeInTheDocument(); - expect(screen.getByText('Spans')).toBeInTheDocument(); - expect(screen.getByText('Session Replays')).toBeInTheDocument(); - expect(screen.getByText('Attachments')).toBeInTheDocument(); - expect(screen.getByText('Spend Allocations')).toBeInTheDocument(); - - // These should NOT be present - expect( - screen.queryByText('Continuous Profile Hours', {exact: true}) - ).not.toBeInTheDocument(); - expect(screen.queryByText('UI Profile Hours', {exact: true})).not.toBeInTheDocument(); - expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer Budget')).not.toBeInTheDocument(); - expect(screen.queryByText('Logs')).not.toBeInTheDocument(); - expect(screen.queryByText('Application Metrics')).not.toBeInTheDocument(); - expect(screen.queryByText('Active Contributors')).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); }); }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 825939526a53..ce0c541a1277 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -31,7 +31,6 @@ import { ACCOUNT_NOTIFICATION_FIELDS, NOTIFICATION_SETTING_FIELDS, QUOTA_FIELDS, - SPEND_FIELDS, } from './fields'; import {NotificationSettingsByEntity} from './notificationSettingsByEntity'; import type {Identity} from './types'; @@ -214,95 +213,6 @@ export function NotificationSettingsByType({notificationType}: Props) { }); }; - const filterCategoryFields = ( - fields: Array<{ - choices: ReadonlyArray; - name: string; - help?: React.ReactNode; - label?: React.ReactNode; - }> - ) => { - // at least one org exists with am3 tiered plan - const hasOrgWithAm3 = organizations.some(organization => - organization.features?.includes('am3-tier') - ); - - // at least one org exists without am3 tier plan - const hasOrgWithoutAm3 = organizations.some( - organization => !organization.features?.includes('am3-tier') - ); - - // at least one org exists with am2 tier plan - const hasOrgWithAm2 = organizations.some(organization => - organization.features?.includes('am2-tier') - ); - - // at least one org exists with am1 tier plan - const hasOrgWithAm1 = organizations.some(organization => - organization.features?.includes('am1-tier') - ); - - // Check if any organization has the continuous-profiling-billing feature flag - const hasOrgWithContinuousProfilingBilling = organizations.some(organization => - organization.features?.includes('continuous-profiling-billing') - ); - - const hasSeerBilling = organizations.some(organization => - organization.features?.includes('seer-billing') - ); - - const hasLogsBilling = organizations.some(organization => - 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') - ); - - const excludeTransactions = hasOrgWithAm3 && !hasOrgWithoutAm3; - const includeSpans = hasOrgWithAm3; - const includeProfileDuration = - (hasOrgWithAm2 || hasOrgWithAm3) && hasOrgWithContinuousProfilingBilling; - const includeSeer = hasSeerBilling; - const includeLogs = hasLogsBilling; - const includeSizeAnalysis = hasOrgWithAm3 || hasOrgWithAm2 || hasOrgWithAm1; - - return fields.filter(field => { - if (field.name === 'quotaSpans' && !includeSpans) { - return false; - } - if (field.name === 'quotaTransactions' && excludeTransactions) { - return false; - } - if ( - ['quotaProfileDuration', 'quotaProfileDurationUI'].includes(field.name) && - !includeProfileDuration - ) { - return false; - } - if (field.name.startsWith('quotaSeerBudget') && !includeSeer) { - return false; - } - if (field.name.startsWith('quotaLogBytes') && !includeLogs) { - return false; - } - if (field.name.startsWith('quotaTraceMetricBytes') && !hasTraceMetricsBilling) { - return false; - } - if (field.name.startsWith('quotaSeerUsers') && !hasSeerUserBilling) { - return false; - } - if (field.name.startsWith('quotaSize') && !includeSizeAnalysis) { - return false; - } - return true; - }); - }; - const removeNotificationMutation = useMutation({ mutationFn: (id: string) => fetchMutation({method: 'DELETE', url: `/users/me/notification-options/${id}/`}), @@ -369,21 +279,14 @@ export function NotificationSettingsByType({notificationType}: Props) { const unlinkedSlackOrgs = getUnlinkedOrgs('slack'); const unlinkedSlackStagingOrgs = getUnlinkedOrgs('slack_staging'); - let notificationDetails = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; - if ( - notificationType === 'quota' && - organizations.some(org => org.features?.includes('spend-visibility-notifications')) - ) { - notificationDetails = { - ...notificationDetails, - title: t('Spend Notifications'), - description: t('Control the notifications you receive for organization spend.'), - }; - } - const {title, description} = notificationDetails; + const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; const entityType = isGroupedByProject(notificationType) ? 'project' : 'organization'; + if (notificationType === 'quota' && ConfigStore.get('isSelfHosted')) { + return null; + } + if ( notificationOptionStatus === 'pending' || notificationProviderStatus === 'pending' || @@ -460,13 +363,7 @@ export function NotificationSettingsByType({notificationType}: Props) { }); const renderQuotaFields = () => { - const hasSpendVisibility = organizations.some(organization => - organization.features?.includes('spend-visibility-notifications') - ); - const sourceFields = hasSpendVisibility ? SPEND_FIELDS : QUOTA_FIELDS; - const filteredFields = filterCategoryFields(sourceFields); - - return filteredFields.map(field => { + return QUOTA_FIELDS.map(field => { const schema = z.object({[field.name]: z.string()}); return ( void; organizationId: string | undefined; - organizations: Organization[]; + organizations: OrganizationSummary[]; }; export function OrganizationSelectHeader({ From 47c9a60519ffa2e1326b0692df91e38bcb2e8a66 Mon Sep 17 00:00:00 2001 From: tnt-sentry Date: Wed, 27 May 2026 16:23:46 -0400 Subject: [PATCH 30/37] feat(scm): add github_enterprise support to SCM Platform RPC dispatch (#116193) Closes [SCM-112](https://linear.app/getsentry/issue/SCM-112). Adds GitHub Enterprise support to Sentry's SCM Platform RPC server so seer's \`ScmRepoClient\` proxy path can construct a working \`GitHubProvider\` for \`github_enterprise\` integrations. --- src/sentry/scm/private/helpers.py | 17 +- .../integration/test_helpers_integration.py | 148 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/sentry/scm/private/helpers.py b/src/sentry/scm/private/helpers.py index fc9eef14ce95..71ac7ed5617d 100644 --- a/src/sentry/scm/private/helpers.py +++ b/src/sentry/scm/private/helpers.py @@ -77,6 +77,19 @@ def fetch_repository(organization_id: int, repository_id: RepositoryId) -> Repos if not isinstance(repo.provider, str): return None + provider_name = repo.provider.removeprefix("integrations:") + web_base_url: str | None = None + if provider_name == "github_enterprise": + integration = integration_service.get_integration( + integration_id=repo.integration_id, + organization_id=organization_id, + ) + if integration: + domain_name = integration.metadata.get("domain_name") + if domain_name: + base_host = domain_name.split("/", 1)[0] + web_base_url = f"https://{base_host}" + return cast( Repository, { @@ -86,8 +99,8 @@ def fetch_repository(organization_id: int, repository_id: RepositoryId) -> Repos "is_active": repo.status == ObjectStatus.ACTIVE, "name": repo.name, "organization_id": repo.organization_id, - "provider_name": repo.provider.removeprefix("integrations:"), - "web_base_url": None, + "provider_name": provider_name, + "web_base_url": web_base_url, }, ) diff --git a/tests/sentry/scm/integration/test_helpers_integration.py b/tests/sentry/scm/integration/test_helpers_integration.py index 3056f8dfb40b..fc4997ce001c 100644 --- a/tests/sentry/scm/integration/test_helpers_integration.py +++ b/tests/sentry/scm/integration/test_helpers_integration.py @@ -73,6 +73,74 @@ def test_fetch_by_provider_and_name_returns_repository(self) -> None: def test_fetch_by_provider_and_external_id_returns_none_for_nonexistent(self) -> None: assert fetch_repository(self.organization.id, ("github", "nonexistent")) is None + def test_fetch_ghe_repo_populates_web_base_url(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-acme-1", + metadata={ + "domain_name": "github.acme.com/installations/1", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + RepositoryModel.objects.create( + organization_id=self.organization.id, + name="acme/widget", + provider="integrations:github_enterprise", + external_id="9001", + status=ObjectStatus.ACTIVE, + integration_id=integration.id, + ) + + result = fetch_repository(self.organization.id, ("github_enterprise", "9001")) + + assert result is not None + assert result["provider_name"] == "github_enterprise" + assert result["web_base_url"] == "https://github.acme.com" + + def test_fetch_ghe_cloud_repo_populates_web_base_url(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Cloud Acme", + external_id="ghe-cloud-acme-1", + metadata={ + "domain_name": "acme-corp.ghe.com", + "installation_id": "2", + "installation": {"id": "2", "private_key": "x", "verify_ssl": True}, + }, + ) + RepositoryModel.objects.create( + organization_id=self.organization.id, + name="acme/widget", + provider="integrations:github_enterprise", + external_id="9002", + status=ObjectStatus.ACTIVE, + integration_id=integration.id, + ) + + result = fetch_repository(self.organization.id, ("github_enterprise", "9002")) + + assert result is not None + assert result["web_base_url"] == "https://acme-corp.ghe.com" + + def test_fetch_non_ghe_repo_web_base_url_is_none(self) -> None: + repo = RepositoryModel.objects.create( + organization_id=self.organization.id, + name="test-org/test-repo", + provider="integrations:github", + external_id="11111", + status=ObjectStatus.ACTIVE, + integration_id=1, + ) + + result = fetch_repository(self.organization.id, repo.id) + + assert result is not None + assert result["web_base_url"] is None + class TestFetchServiceProvider(TestCase): def test_returns_provider_from_map_to_provider(self) -> None: @@ -113,3 +181,83 @@ def test_returns_none_for_nonexistent_integration(self) -> None: } result = fetch_service_provider(self.organization.id, repository) assert result is None + + def test_github_enterprise_returns_github_provider(self) -> None: + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-dispatch-1", + metadata={ + "domain_name": "github.acme.com", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + + repository: Repository = { + "id": 1, + "integration_id": integration.id, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + provider = fetch_service_provider(self.organization.id, repository) + + assert isinstance(provider, GitHubProvider) + + def test_github_enterprise_without_integration_returns_none(self) -> None: + repository: Repository = { + "id": 1, + "integration_id": 99999, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + assert fetch_service_provider(self.organization.id, repository) is None + + def test_github_enterprise_client_error_returns_none(self) -> None: + from unittest.mock import patch + + from sentry.shared_integrations.exceptions import IntegrationError + + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="GHE Acme", + external_id="ghe-clienterror-1", + metadata={ + "domain_name": "github.acme.com", + "installation_id": "1", + "installation": {"id": "1", "private_key": "x", "verify_ssl": True}, + }, + ) + repository: Repository = { + "id": 1, + "integration_id": integration.id, + "name": "acme/widget", + "organization_id": self.organization.id, + "is_active": True, + "external_id": "9001", + "provider_name": "github_enterprise", + "web_base_url": "https://github.acme.com", + } + + with ( + patch( + "sentry.scm.private.helpers.integration_service.get_integration", + return_value=integration, + ), + patch.object( + type(integration.get_installation(organization_id=self.organization.id)), + "get_client", + side_effect=IntegrationError("boom"), + ), + ): + assert fetch_service_provider(self.organization.id, repository) is None From aeba674a2d47e217e53275aeec67efb04cd0d6e6 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 27 May 2026 13:36:38 -0700 Subject: [PATCH 31/37] ref(alerts): Disable alert buttons for users without write access (#116306) We were not checking the `alerts:write` permission on the frontend on both the alerts list and details pages, so users in orgs with that setting disabled were able to attempt to create/edit alerts but would eventually get rejected by the form. This PR disables those buttons with an explanatory tooltip in all locations where a user can create/edit alerts. CleanShot 2026-05-26 at 16 54 50 CleanShot 2026-05-26 at 16 54 44 --- .../components/automationListTable/index.tsx | 4 +-- .../components/automationListTable/row.tsx | 6 ++-- .../components/disabledAlert.spec.tsx | 2 +- .../automations/components/disabledAlert.tsx | 26 ++++---------- .../components/editAutomationActions.tsx | 26 ++++++++++++-- static/app/views/automations/detail.spec.tsx | 23 +++++++++++++ static/app/views/automations/detail.tsx | 17 +++++++++- .../hooks/useCanEditAutomation.tsx | 34 +++++++++++++++++++ static/app/views/automations/list.spec.tsx | 13 +++++++ static/app/views/automations/list.tsx | 16 +++++++++ .../components/details/common/automations.tsx | 24 ++++--------- .../components/forms/automateSection.tsx | 33 ++++++++++++++++-- .../list/common/detectorListActions.tsx | 1 + .../list/common/detectorListHeader.tsx | 1 + .../detectors/utils/monitorAccessMessages.tsx | 8 +++-- 15 files changed, 181 insertions(+), 53 deletions(-) create mode 100644 static/app/views/automations/hooks/useCanEditAutomation.tsx diff --git a/static/app/views/automations/components/automationListTable/index.tsx b/static/app/views/automations/components/automationListTable/index.tsx index c9255f40c246..25de05b6e5be 100644 --- a/static/app/views/automations/components/automationListTable/index.tsx +++ b/static/app/views/automations/components/automationListTable/index.tsx @@ -8,7 +8,6 @@ import {LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {LoadingError} from 'sentry/components/loadingError'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {SelectAllHeaderCheckbox} from 'sentry/components/workflowEngine/ui/selectAllHeaderCheckbox'; @@ -25,6 +24,7 @@ import { AutomationListRowSkeleton, } from 'sentry/views/automations/components/automationListTable/row'; import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; +import {useCanEditAutomation} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; type AutomationListTableProps = { @@ -91,7 +91,7 @@ export function AutomationListTable({ allResultsVisible, }: AutomationListTableProps) { const organization = useOrganization(); - const canEditAutomations = hasEveryAccess(['alerts:write'], {organization}); + const canEditAutomations = useCanEditAutomation(); const [query] = useQueryState('query', parseAsString); const [selected, setSelected] = useState(new Set()); diff --git a/static/app/views/automations/components/automationListTable/row.tsx b/static/app/views/automations/components/automationListTable/row.tsx index 331a4a546ace..1f161033d5e1 100644 --- a/static/app/views/automations/components/automationListTable/row.tsx +++ b/static/app/views/automations/components/automationListTable/row.tsx @@ -3,16 +3,15 @@ import styled from '@emotion/styled'; import {Checkbox} from '@sentry/scraps/checkbox'; import {Flex} from '@sentry/scraps/layout'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {Placeholder} from 'sentry/components/placeholder'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {ActionCell} from 'sentry/components/workflowEngine/gridCell/actionCell'; import {AutomationTitleCell} from 'sentry/components/workflowEngine/gridCell/automationTitleCell'; import {TimeAgoCell} from 'sentry/components/workflowEngine/gridCell/timeAgoCell'; import type {Automation} from 'sentry/types/workflowEngine/automations'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {AutomationListConnectedDetectors} from 'sentry/views/automations/components/automationListTable/connectedDetectors'; import {ProjectsCell} from 'sentry/views/automations/components/automationListTable/projectsCell'; +import {useCanEditAutomation} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActions} from 'sentry/views/automations/hooks/utils'; type AutomationListRowProps = { @@ -26,8 +25,7 @@ export function AutomationListRow({ selected, onSelect, }: AutomationListRowProps) { - const organization = useOrganization(); - const canEditAutomations = hasEveryAccess(['alerts:write'], {organization}); + const canEditAutomations = useCanEditAutomation(); const actions = getAutomationActions(automation); const {enabled, lastTriggered, detectorIds} = automation; diff --git a/static/app/views/automations/components/disabledAlert.spec.tsx b/static/app/views/automations/components/disabledAlert.spec.tsx index f7bedc2c5d3a..9e9dacc96268 100644 --- a/static/app/views/automations/components/disabledAlert.spec.tsx +++ b/static/app/views/automations/components/disabledAlert.spec.tsx @@ -89,7 +89,7 @@ describe('DisabledAlert', () => { expect( await screen.findByText( textWithMarkupMatcher( - 'You do not have permission to edit this alert. Ask your organization owner or manager to enable alert access for you.' + 'You do not have permission to create or edit alerts. Ask your organization owner or manager to enable alert access for you.' ) ) ).toBeInTheDocument(); diff --git a/static/app/views/automations/components/disabledAlert.tsx b/static/app/views/automations/components/disabledAlert.tsx index ea553502c757..f75380e576a0 100644 --- a/static/app/views/automations/components/disabledAlert.tsx +++ b/static/app/views/automations/components/disabledAlert.tsx @@ -1,14 +1,15 @@ import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; -import {Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import {IconPlay} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +import {t} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {useUpdateAutomation} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; type DisabledAlertProps = { automation: Automation; @@ -20,10 +21,9 @@ type DisabledAlertProps = { * enable it. The alert automatically hides when the automation is enabled. */ export function DisabledAlert({automation}: DisabledAlertProps) { - const organization = useOrganization(); const {mutate: updateAutomation, isPending: isEnabling} = useUpdateAutomation(); - const canEdit = hasEveryAccess(['alerts:write'], {organization}); + const canEdit = useCanEditAutomation(); if (automation.enabled) { return null; @@ -37,19 +37,7 @@ export function DisabledAlert({automation}: DisabledAlertProps) { }); }; - const permissionTooltipText = tct( - 'You do not have permission to edit this alert. Ask your organization owner or manager to [settingsLink:enable alert access] for you.', - { - settingsLink: ( - - ), - } - ); + const permissionTooltipText = getNoAlertWritePermissionTooltip(); return ( diff --git a/static/app/views/automations/components/editAutomationActions.tsx b/static/app/views/automations/components/editAutomationActions.tsx index b9aa817afe27..13cc9fb3ba06 100644 --- a/static/app/views/automations/components/editAutomationActions.tsx +++ b/static/app/views/automations/components/editAutomationActions.tsx @@ -14,6 +14,10 @@ import { useDeleteAutomationMutation, useUpdateAutomation, } from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeAutomationBasePathname} from 'sentry/views/automations/pathnames'; interface EditAutomationActionsProps { @@ -24,6 +28,8 @@ interface EditAutomationActionsProps { export function EditAutomationActions({automation, form}: EditAutomationActionsProps) { const organization = useOrganization(); const navigate = useNavigate(); + const canEdit = useCanEditAutomation(); + const permissionTooltipText = canEdit ? undefined : getNoAlertWritePermissionTooltip(); const {mutateAsync: deleteAutomation, isPending: isDeleting} = useDeleteAutomationMutation(); const {mutate: updateAutomation, isPending: isUpdating} = useUpdateAutomation(); @@ -63,16 +69,30 @@ export function EditAutomationActions({automation, form}: EditAutomationActionsP variant="secondary" size="sm" onClick={toggleDisabled} - disabled={isUpdating} + disabled={!canEdit || isUpdating} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {automation.enabled ? t('Disable') : t('Enable')} - {() => ( - )} diff --git a/static/app/views/automations/detail.spec.tsx b/static/app/views/automations/detail.spec.tsx index d463da4ce91f..8a769867aad2 100644 --- a/static/app/views/automations/detail.spec.tsx +++ b/static/app/views/automations/detail.spec.tsx @@ -267,6 +267,29 @@ describe('AutomationDetail', () => { ).not.toBeInTheDocument(); }); + it('disables action buttons without alerts:write permission', async () => { + const noWriteOrg = OrganizationFixture({ + features: ['workflow-engine-ui'], + access: ['org:read', 'alerts:read'], + }); + + render(, { + organization: noWriteOrg, + initialRouterConfig: { + route: '/alerts/:automationId/', + location: {pathname: '/alerts/123/'}, + }, + }); + + await screen.findByRole('heading', {name: 'Test Automation'}); + + expect(screen.getByRole('button', {name: 'Disable'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Edit'})).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + it('displays connected projects and monitors', async () => { const project = ProjectFixture({id: '10', slug: 'my-project', name: 'My Project'}); ProjectsStore.loadInitialData([project]); diff --git a/static/app/views/automations/detail.tsx b/static/app/views/automations/detail.tsx index 63d1b7d68f15..2bde170607bd 100644 --- a/static/app/views/automations/detail.tsx +++ b/static/app/views/automations/detail.tsx @@ -37,6 +37,10 @@ import {ConnectedMonitorsList} from 'sentry/views/automations/components/connect import {ConnectedProjectsList} from 'sentry/views/automations/components/connectedProjectsList'; import {DisabledAlert} from 'sentry/views/automations/components/disabledAlert'; import {useAutomationQuery, useUpdateAutomation} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActionsWarning} from 'sentry/views/automations/hooks/utils'; import { makeAutomationBasePathname, @@ -270,6 +274,8 @@ export default function AutomationDetail() { function Actions({automation, size}: {automation: Automation; size?: 'sm'}) { const organization = useOrganization(); const {mutate: updateAutomation, isPending: isUpdating} = useUpdateAutomation(); + const canEdit = useCanEditAutomation(); + const permissionTooltipText = canEdit ? undefined : getNoAlertWritePermissionTooltip(); const toggleDisabled = () => { const newEnabled = !automation.enabled; @@ -289,11 +295,20 @@ function Actions({automation, size}: {automation: Automation; size?: 'sm'}) { return ( - } size={size} diff --git a/static/app/views/automations/hooks/useCanEditAutomation.tsx b/static/app/views/automations/hooks/useCanEditAutomation.tsx new file mode 100644 index 000000000000..94d5f2853ec5 --- /dev/null +++ b/static/app/views/automations/hooks/useCanEditAutomation.tsx @@ -0,0 +1,34 @@ +import type {ReactNode} from 'react'; + +import {Link} from '@sentry/scraps/link'; + +import {hasEveryAccess} from 'sentry/components/acl/access'; +import {tct} from 'sentry/locale'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useCanEditAutomation(): boolean { + const organization = useOrganization(); + return hasEveryAccess(['alerts:write'], {organization}); +} + +function AlertsMemberWriteSettingsLink({children}: {children?: ReactNode}) { + const organization = useOrganization(); + + return ( + + {children} + + ); +} + +export function getNoAlertWritePermissionTooltip() { + return tct( + 'You do not have permission to create or edit alerts. Ask your organization owner or manager to [settingsLink:enable alert access] for you.', + {settingsLink: } + ); +} diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index f4c6a925be89..8fb761f34734 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -591,4 +591,17 @@ describe('AutomationsList', () => { ).toBeInTheDocument(); }); }); + + it('disables the create alert button without alerts:write permission', async () => { + const noWriteOrg = OrganizationFixture({ + features: ['workflow-engine-ui'], + access: ['org:read', 'alerts:read'], + }); + + render(, {organization: noWriteOrg}); + await screen.findByText('Automation 1'); + + const createButton = screen.getByRole('button', {name: 'Create Alert'}); + expect(createButton).toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index c8b07687377c..d41939d77dd0 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -25,6 +25,10 @@ import {AutomationListTable} from 'sentry/views/automations/components/automatio import {AutomationSearch} from 'sentry/views/automations/components/automationListTable/search'; import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; import {automationsApiOptions} from 'sentry/views/automations/hooks'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; @@ -131,6 +135,7 @@ function TableHeader() { const location = useLocation(); const navigate = useNavigate(); const hasPageFrameFeature = useHasPageFrameFeature(); + const canCreateAlert = useCanEditAutomation(); const initialQuery = typeof location.query.query === 'string' ? location.query.query : ''; @@ -159,6 +164,11 @@ function TableHeader() { {hasPageFrameFeature ? ( } size="sm" @@ -174,6 +184,7 @@ function TableHeader() { function Actions() { const organization = useOrganization(); const hasPageFrameFeature = useHasPageFrameFeature(); + const canCreateAlert = useCanEditAutomation(); return ( @@ -181,6 +192,11 @@ function Actions() { {hasPageFrameFeature ? null : ( } size="sm" diff --git a/static/app/views/detectors/components/details/common/automations.tsx b/static/app/views/detectors/components/details/common/automations.tsx index 9e33ea6d399d..8585176c9130 100644 --- a/static/app/views/detectors/components/details/common/automations.tsx +++ b/static/app/views/detectors/components/details/common/automations.tsx @@ -7,7 +7,6 @@ import {ProjectAvatar} from '@sentry/scraps/avatar'; import {Button} from '@sentry/scraps/button'; import {useDrawer} from '@sentry/scraps/drawer'; import {Flex, Stack} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -27,6 +26,7 @@ import {useProjectFromId} from 'sentry/utils/useProjectFromId'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; import {AutomationSearch} from 'sentry/views/automations/components/automationListTable/search'; import {automationsApiOptions} from 'sentry/views/automations/hooks'; +import {getNoAlertWritePermissionTooltip} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {getAutomationActions} from 'sentry/views/automations/hooks/utils'; import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connectAutomationsDrawer'; import {useUpdateDetector} from 'sentry/views/detectors/hooks'; @@ -217,19 +217,7 @@ export function DetectorDetailsAutomations({detector}: Props) { const permissionTooltipText = canEditWorkflowConnections ? undefined - : t( - 'Ask your organization owner or manager to [settingsLink:enable alerts access] for you.', - { - settingsLink: ( - - ), - } - ); + : getNoAlertWritePermissionTooltip(); return ( @@ -242,7 +230,7 @@ export function DetectorDetailsAutomations({detector}: Props) { icon={} onClick={openCreateDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('New Alert')} @@ -250,7 +238,7 @@ export function DetectorDetailsAutomations({detector}: Props) { size="xs" onClick={toggleDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} icon={} > {t('Edit Alerts')} @@ -268,7 +256,7 @@ export function DetectorDetailsAutomations({detector}: Props) { size="sm" onClick={toggleDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Connect Existing Alerts')} @@ -277,7 +265,7 @@ export function DetectorDetailsAutomations({detector}: Props) { icon={} onClick={openCreateDrawer} disabled={!canEditWorkflowConnections} - tooltipProps={{title: permissionTooltipText}} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Create a New Alert')} diff --git a/static/app/views/detectors/components/forms/automateSection.tsx b/static/app/views/detectors/components/forms/automateSection.tsx index 8df0067d6968..0775cc973650 100644 --- a/static/app/views/detectors/components/forms/automateSection.tsx +++ b/static/app/views/detectors/components/forms/automateSection.tsx @@ -16,6 +16,10 @@ import {IconAdd, IconEdit} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; +import { + getNoAlertWritePermissionTooltip, + useCanEditAutomation, +} from 'sentry/views/automations/hooks/useCanEditAutomation'; import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connectAutomationsDrawer'; import {ConnectedAutomationsList} from 'sentry/views/detectors/components/connectedAutomationList'; import {useDetectorFormProject} from 'sentry/views/detectors/components/forms/common/useDetectorFormProject'; @@ -82,6 +86,10 @@ function AutomateSectionInner({ }: AutomateSectionInnerProps) { const ref = useRef(null); const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); + const canEditAutomation = useCanEditAutomation(); + const permissionTooltipText = canEditAutomation + ? undefined + : getNoAlertWritePermissionTooltip(); const toggleDrawer = () => { if (isDrawerOpen) { @@ -140,10 +148,22 @@ function AutomateSectionInner({ /> - - @@ -170,10 +190,17 @@ function AutomateSectionInner({ size="sm" style={{width: 'min-content'}} onClick={toggleDrawer} + disabled={!canEditAutomation} + tooltipProps={{title: permissionTooltipText, isHoverable: true}} > {t('Connect Existing Alerts')} - diff --git a/static/app/views/detectors/list/common/detectorListActions.tsx b/static/app/views/detectors/list/common/detectorListActions.tsx index 41c39d873593..3e9beedccf3c 100644 --- a/static/app/views/detectors/list/common/detectorListActions.tsx +++ b/static/app/views/detectors/list/common/detectorListActions.tsx @@ -46,6 +46,7 @@ export function DetectorListActions({children, detectorType}: DetectorListAction title: canCreateDetector ? undefined : getNoPermissionToCreateMonitorsTooltip(), + isHoverable: true, }} > {t('Create Monitor')} diff --git a/static/app/views/detectors/list/common/detectorListHeader.tsx b/static/app/views/detectors/list/common/detectorListHeader.tsx index 9d2f32c8f175..286f36c0e993 100644 --- a/static/app/views/detectors/list/common/detectorListHeader.tsx +++ b/static/app/views/detectors/list/common/detectorListHeader.tsx @@ -80,6 +80,7 @@ export function DetectorListHeader({ title: canCreateDetector ? undefined : getNoPermissionToCreateMonitorsTooltip(), + isHoverable: true, }} > {t('Create Monitor')} diff --git a/static/app/views/detectors/utils/monitorAccessMessages.tsx b/static/app/views/detectors/utils/monitorAccessMessages.tsx index fbdcca0c79d2..8d60285e9635 100644 --- a/static/app/views/detectors/utils/monitorAccessMessages.tsx +++ b/static/app/views/detectors/utils/monitorAccessMessages.tsx @@ -1,9 +1,11 @@ +import type {ReactNode} from 'react'; + import {Link} from '@sentry/scraps/link'; import {t, tct} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; -function AlertsMemberWriteSettingsLink() { +function AlertsMemberWriteSettingsLink({children}: {children?: ReactNode}) { const organization = useOrganization(); return ( @@ -12,7 +14,9 @@ function AlertsMemberWriteSettingsLink() { hash: 'alertsMemberWrite', pathname: `/settings/${organization.slug}/`, }} - /> + > + {children} + ); } From fc813b0da49e47498c9141087ed839f3180484bf Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 27 May 2026 16:47:41 -0400 Subject: [PATCH 32/37] chore(workflow-engine): Build out new registry for activities (#116200) Called it `WorkflowActivityRegistry` instead of `GroupActivityRegistry`, not sure if the other is preferred, let me know. Added some basic tests and a todo for the member I'll add later. --- src/sentry/workflow_engine/apps.py | 1 + .../workflow/workflow_activity_handlers.py | 9 +++++ src/sentry/workflow_engine/registry.py | 15 +++++++- src/sentry/workflow_engine/types.py | 4 ++ .../handlers/workflow/__init__.py | 0 .../test_workflow_activity_handlers.py | 38 +++++++++++++++++++ 6 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py create mode 100644 tests/sentry/workflow_engine/handlers/workflow/__init__.py create mode 100644 tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py diff --git a/src/sentry/workflow_engine/apps.py b/src/sentry/workflow_engine/apps.py index 17b893f6efef..6145830a0371 100644 --- a/src/sentry/workflow_engine/apps.py +++ b/src/sentry/workflow_engine/apps.py @@ -8,4 +8,5 @@ def ready(self) -> None: # Import items that use registries or respond to events import sentry.workflow_engine.handlers # NOQA import sentry.workflow_engine.receivers # NOQA + import sentry.workflow_engine.handlers.workflow.workflow_activity_handlers # NOQA from sentry.workflow_engine.endpoints import serializers # NOQA diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py new file mode 100644 index 000000000000..3df4b76102be --- /dev/null +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py @@ -0,0 +1,9 @@ +from sentry.models.activity import Activity +from sentry.models.group import Group +from sentry.workflow_engine.registry import workflow_activity_registry + + +@workflow_activity_registry.register("seer_activity") +def seer_activity_handler(group: Group, activity: Activity) -> None: + # TODO(Leander): Implement this handler + pass diff --git a/src/sentry/workflow_engine/registry.py b/src/sentry/workflow_engine/registry.py index fda4a5744cdd..6ec921a6ab63 100644 --- a/src/sentry/workflow_engine/registry.py +++ b/src/sentry/workflow_engine/registry.py @@ -1,8 +1,21 @@ from typing import Any +from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.utils.registry import Registry -from sentry.workflow_engine.types import ActionHandler, DataConditionHandler, DataSourceTypeHandler +from sentry.workflow_engine.types import ( + ActionHandler, + DataConditionHandler, + DataSourceTypeHandler, + WorkflowActivityHandler, +) data_source_type_registry = Registry[type[DataSourceTypeHandler[Any]]]() condition_handler_registry = Registry[type[DataConditionHandler[Any]]](enable_reverse_lookup=False) action_handler_registry = Registry[type[ActionHandler]](enable_reverse_lookup=False) +workflow_activity_registry = Registry[WorkflowActivityHandler](enable_reverse_lookup=False) + + +def invoke_workflow_activity_handlers(group: Group, activity: Activity) -> None: + for handler in workflow_activity_registry.registrations.values(): + handler(group, activity) diff --git a/src/sentry/workflow_engine/types.py b/src/sentry/workflow_engine/types.py index d783e26be1b1..febb8a838482 100644 --- a/src/sentry/workflow_engine/types.py +++ b/src/sentry/workflow_engine/types.py @@ -2,6 +2,7 @@ import random from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum, StrEnum from logging import Logger @@ -423,3 +424,6 @@ class DetectorSettings: validator: type[BaseDetectorTypeValidator] | None = None config_schema: dict[str, Any] = field(default_factory=dict) filter: Q | None = None + + +WorkflowActivityHandler: TypeAlias = Callable[["Group", "Activity"], None] diff --git a/tests/sentry/workflow_engine/handlers/workflow/__init__.py b/tests/sentry/workflow_engine/handlers/workflow/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py new file mode 100644 index 000000000000..3a0b094b48e4 --- /dev/null +++ b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py @@ -0,0 +1,38 @@ +from unittest import mock + +from sentry.testutils.cases import TestCase +from sentry.types.activity import ActivityType +from sentry.workflow_engine.registry import ( + invoke_workflow_activity_handlers, + workflow_activity_registry, +) + + +class WorkflowActivityRegistryTest(TestCase): + def setUp(self) -> None: + self.group = self.create_group() + self.activity = self.create_group_activity( + group=self.group, type=ActivityType.SEER_PR_CREATED.value + ) + + def test_registrants(self) -> None: + assert "seer_activity" in workflow_activity_registry.registrations + assert len(workflow_activity_registry.registrations) == 1 + + def test_invoke_handlers(self) -> None: + handler_a = mock.Mock() + handler_b = mock.Mock() + + with mock.patch.dict( + workflow_activity_registry.registrations, + {"handler_a": handler_a, "handler_b": handler_b}, + clear=True, + ): + invoke_workflow_activity_handlers(self.group, self.activity) + + handler_a.assert_called_once_with(self.group, self.activity) + handler_b.assert_called_once_with(self.group, self.activity) + + def test_invoke_handlers_no_registrants(self) -> None: + with mock.patch.dict(workflow_activity_registry.registrations, {}, clear=True): + invoke_workflow_activity_handlers(self.group, self.activity) From 396d2ab745083ae0f185c44c38118ea731fe8bd8 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Wed, 27 May 2026 17:21:14 -0400 Subject: [PATCH 33/37] fix(autofix): Set default stopping point based on preferences (#116340) Previously, legacy autofix agent would read the preferences to decide a stopping point for automated runs without a stopping point. The seer agent doesnt do that so to fix it, we can just pass the stopping point from the beginning to avoid another fetch. See https://github.com/getsentry/seer/blob/67e6c232f708a03b59b4cab09381668ae5eb92b9/src/seer/automation/autofix/tasks.py#L145-L148 --------- Co-authored-by: Claude --- src/sentry/seer/autofix/issue_summary.py | 20 +++++++++++-------- .../sentry/seer/autofix/test_issue_summary.py | 13 +++++++----- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 973c8a1939ff..9b0f73ef53ec 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -423,9 +423,7 @@ def run_automation( if is_seer_autotriggered_autofix_rate_limited_and_increment(group.project, group.organization): return - stopping_point = None - if is_seer_seat_based_tier_enabled(group.organization): - stopping_point = get_automation_stopping_point(group) + stopping_point = get_automation_stopping_point(group) _trigger_autofix_task.delay( group_id=group.id, @@ -464,16 +462,22 @@ def is_group_triggering_automation(group: Group) -> bool: return True -def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint: +def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint | None: """ Get the automation stopping point for a group. """ - fixability_score = get_and_update_group_fixability_score(group) - fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) - user_preference = read_preference_from_sentry_db(group.project).automated_run_stopping_point - return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) + if is_seer_seat_based_tier_enabled(group.organization): + fixability_score = get_and_update_group_fixability_score(group) + fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) + + return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) + + if user_preference: + return AutofixStoppingPoint(user_preference) + + return None def _generate_summary( diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 6c3763a9854e..5abbaaafc7fb 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -924,7 +924,7 @@ def test_without_seat_based_tier( run_automation(self.group, self.user, self.event, SeerAutomationSource.POST_PROCESS) mock_trigger.assert_called_once() - assert mock_trigger.call_args[1]["stopping_point"] is None + assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") @patch("sentry.seer.autofix.issue_summary.is_group_triggering_automation", return_value=True) @@ -1277,6 +1277,7 @@ def test_returns_false_when_rate_limited(self, mock_fixability, mock_quota, mock assert is_group_triggering_automation(self.group) is False +@patch("sentry.seer.autofix.issue_summary.is_seer_seat_based_tier_enabled", return_value=True) @with_feature({"organizations:gen-ai-features": True}) class TestGetAutomationStoppingPoint(TestCase): def setUp(self) -> None: @@ -1284,21 +1285,21 @@ def setUp(self) -> None: self.group = self.create_group() @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_default_preference_limits_stopping_point(self, mock_fixability): + def test_default_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): """Unset preference falls back to the well-known default (code_changes).""" mock_fixability.return_value = 0.80 assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.CODE_CHANGES @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_user_preference_limits_stopping_point(self, mock_fixability): + def test_user_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): mock_fixability.return_value = 0.80 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "solution") assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.SOLUTION @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_low_fixability_returns_root_cause(self, mock_fixability): + def test_low_fixability_returns_root_cause(self, mock_fixability, mock_seat_based_tier): mock_fixability.return_value = 0.50 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") @@ -1306,7 +1307,9 @@ def test_low_fixability_returns_root_cause(self, mock_fixability): @patch("sentry.seer.autofix.issue_summary.read_preference_from_sentry_db") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_null_stopping_point_uses_fixability_only(self, mock_fixability, mock_read_pref): + def test_null_stopping_point_uses_fixability_only( + self, mock_fixability, mock_read_pref, mock_seat_based_tier + ): """When preference.automated_run_stopping_point is None, fixability score alone drives the result.""" from sentry.seer.models.seer_api_models import SeerProjectPreference From 4a2fb82fb9406954377bac1d1a910c9aa75da183 Mon Sep 17 00:00:00 2001 From: joshuarli Date: Wed, 27 May 2026 14:33:51 -0700 Subject: [PATCH 34/37] feat: install sentry-options (#115835) we aren't using this here right now, but it's required for https://github.com/getsentry/getsentry/pull/20354 --- pyproject.toml | 2 ++ uv.lock | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4703bccfde49..af50cabb9b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ dependencies = [ "sentry-forked-email-reply-parser>=0.5.12.post1", "sentry-kafka-schemas>=2.1.27", "sentry-ophio>=1.1.3", + # sentry-options is only used in getsentry for now + "sentry-options>=1.0.13", "sentry-protos>=0.13.0", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.27", diff --git a/uv.lock b/uv.lock index c980bb329056..810372d3af48 100644 --- a/uv.lock +++ b/uv.lock @@ -2262,6 +2262,7 @@ dependencies = [ { name = "sentry-forked-email-reply-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-kafka-schemas", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-ophio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sentry-options", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-redis-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-relay", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2435,6 +2436,7 @@ requires-dist = [ { name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.27" }, { name = "sentry-ophio", specifier = ">=1.1.3" }, + { name = "sentry-options", specifier = ">=1.0.13" }, { name = "sentry-protos", specifier = ">=0.13.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, { name = "sentry-relay", specifier = ">=0.9.27" }, @@ -2607,6 +2609,16 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_ophio-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c2feae9d4842e941ade5989c48982e60a743f262bb3c28222f1844fffa12ea" }, ] +[[package]] +name = "sentry-options" +version = "1.0.13" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:54008be0ada4a761776bf8e819fcd78ff3c03a9880be6983238c49db0cc8f333" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88360d1125c72c15c0a683e62deddabc75fa8efea18380e5b67bfea9eb78d532" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.0.13-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c3dbe0dde282aea1d6416c0820a125a9e3ab88c05998452813255ce7ea07372" }, +] + [[package]] name = "sentry-protos" version = "0.13.0" From dc79a1d295d55035599dfe02a525cd895d2c2661 Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Wed, 27 May 2026 17:51:25 -0400 Subject: [PATCH 35/37] fix(options): Suppress option seen logs in debug mode (#116324) Skip `option.seen` audit logs when running with `DEBUG` enabled so local mypy and process output is not flooded by option reads. Non-debug environments still emit the audit log for production visibility. --------- Co-authored-by: OpenAI Codex --- src/sentry/options/manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 69d165ebdd0e..8ca58acbc051 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -287,8 +287,10 @@ def is_set_on_disk(self, key: str) -> bool: def _record_seen(self, key: str) -> None: """Emit one log line per key per process lifetime so reads can be audited in GCP. Logs before adding to _seen so a logging failure - doesn't permanently suppress the event.""" - logger.info("option.seen", extra={"option_key": key}) + doesn't permanently suppress the event. In debug mode, mark keys as + seen without logging to keep local tooling output clean.""" + if not settings.DEBUG: + logger.info("option.seen", extra={"option_key": key}) self._seen.add(key) def get(self, key: str, silent=False): From 38e98569bb18b0fee9bbc5864fa542a5fad43b88 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Wed, 27 May 2026 17:52:02 -0400 Subject: [PATCH 36/37] fix(api-logs): log snuba throttle_threshold on rate-limited requests (#116338) Follow up on https://github.com/getsentry/sentry/pull/116263. Log the throttled threshold when we get a 429 response due to throttling from snuba. Co-authored-by: Claude --- src/sentry/api/handlers.py | 1 + src/sentry/middleware/access_log.py | 1 + src/sentry/types/ratelimit.py | 1 + src/sentry/utils/snuba.py | 4 +++- tests/sentry/middleware/test_access_log_middleware.py | 3 +++ tests/sentry/utils/test_snuba.py | 2 ++ 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/handlers.py b/src/sentry/api/handlers.py index 7e037b185eb4..01c88092bb1f 100644 --- a/src/sentry/api/handlers.py +++ b/src/sentry/api/handlers.py @@ -21,6 +21,7 @@ def custom_exception_handler(exc, context): storage_key=exc.storage_key, quota_used=exc.quota_used, rejection_threshold=exc.rejection_threshold, + throttle_threshold=exc.throttle_threshold, ) # capture the rate limited exception so we can see it in Sentry diff --git a/src/sentry/middleware/access_log.py b/src/sentry/middleware/access_log.py index 696ecebaf688..983916d73e1d 100644 --- a/src/sentry/middleware/access_log.py +++ b/src/sentry/middleware/access_log.py @@ -75,6 +75,7 @@ def _get_rate_limit_stats_dict(request: Request) -> dict[str, str | int | None]: "snuba_rejection_threshold": getattr( snuba_rate_limit_metadata, "rejection_threshold", None ), + "snuba_throttle_threshold": getattr(snuba_rate_limit_metadata, "throttle_threshold", None), "snuba_storage_key": getattr(snuba_rate_limit_metadata, "storage_key", None), } diff --git a/src/sentry/types/ratelimit.py b/src/sentry/types/ratelimit.py index 360bd4228ba2..8b44915c0b64 100644 --- a/src/sentry/types/ratelimit.py +++ b/src/sentry/types/ratelimit.py @@ -76,4 +76,5 @@ class SnubaRateLimitMeta: quota_unit: str | None quota_used: int | None rejection_threshold: int | None + throttle_threshold: int | None storage_key: str | None diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index 528451597d45..9df4ab0bdc0e 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -387,6 +387,7 @@ def __init__( storage_key: str | None = None, quota_used: int | None = None, rejection_threshold: int | None = None, + throttle_threshold: int | None = None, ) -> None: super().__init__(message) self.policy = policy @@ -394,6 +395,7 @@ def __init__( self.storage_key = storage_key self.quota_used = quota_used self.rejection_threshold = rejection_threshold + self.throttle_threshold = throttle_threshold class SchemaValidationError(QueryExecutionError): @@ -1351,8 +1353,8 @@ def _bulk_snuba_query(snuba_requests: Sequence[SnubaRequest]) -> ResultSet: quota_unit=policy_info["quota_unit"], storage_key=policy_info["storage_key"], quota_used=policy_info["quota_used"], - # We won't have rejection_threshold for throttled_by errors rejection_threshold=policy_info.get("rejection_threshold"), + throttle_threshold=policy_info.get("throttle_threshold"), ) except KeyError: logger.warning( diff --git a/tests/sentry/middleware/test_access_log_middleware.py b/tests/sentry/middleware/test_access_log_middleware.py index 23acd6efdbf5..01048de169a1 100644 --- a/tests/sentry/middleware/test_access_log_middleware.py +++ b/tests/sentry/middleware/test_access_log_middleware.py @@ -88,6 +88,7 @@ def get(self, request): policy="ConcurrentRateLimitAllocationPolicy", quota_used=41, rejection_threshold=40, + throttle_threshold=30, quota_unit="no_units", storage_key="test_storage_key", ) @@ -202,6 +203,7 @@ def get(self, request, organization_context, organization): "snuba_storage_key", "snuba_quota_used", "snuba_rejection_threshold", + "snuba_throttle_threshold", "token_last_characters", "gateway_proxy", ) @@ -265,6 +267,7 @@ def test_access_log_snuba_rate_limited(self) -> None: assert self.captured_logs[0].snuba_storage_key == "test_storage_key" assert self.captured_logs[0].snuba_quota_used == "41" assert self.captured_logs[0].snuba_rejection_threshold == "40" + assert self.captured_logs[0].snuba_throttle_threshold == "30" @all_silo_test diff --git a/tests/sentry/utils/test_snuba.py b/tests/sentry/utils/test_snuba.py index d656d66ca789..4259177faf57 100644 --- a/tests/sentry/utils/test_snuba.py +++ b/tests/sentry/utils/test_snuba.py @@ -666,3 +666,5 @@ def test_rate_limit_error_handling_throttle_only(self, mock_snuba_query) -> None assert exc_info.value.storage_key == "errors_ro" assert exc_info.value.quota_used == 1500000000000 assert exc_info.value.quota_unit == "bytes" + assert exc_info.value.throttle_threshold == 1000000000000 + assert exc_info.value.rejection_threshold is None From f5603c73341a7a7af1b875514e57aac18a5b4764 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Wed, 27 May 2026 14:54:24 -0700 Subject: [PATCH 37/37] fix(workflows): Update Workflows with org-scoped envs when transfered with a project (#116239) If we're moving a Workflow to a new organization, we need to make sure the associated Environment is updated to one in that org. Fixes ISWF-2687. --- src/sentry/models/project.py | 17 +++++++++++++++-- tests/sentry/models/test_project.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py index 20082b4ff89c..4138f49aa516 100644 --- a/src/sentry/models/project.py +++ b/src/sentry/models/project.py @@ -681,9 +681,22 @@ def transfer_to(self, organization: Organization) -> None: .values_list("id", flat=True) ) - Workflow.objects.filter(id__in=exclusive_workflow_ids).update( - organization_id=organization.id + # Update org and environment references for transferred workflows + workflows_with_env = dict( + Workflow.objects.filter( + id__in=exclusive_workflow_ids, environment_id__isnull=False + ).values_list("id", "environment_id") ) + for workflow_id, env_id in workflows_with_env.items(): + Workflow.objects.filter(id=workflow_id).update( + organization_id=organization.id, + environment_id=Environment.get_or_create( + self, name=environment_names.get(env_id) + ).id, + ) + Workflow.objects.filter(id__in=exclusive_workflow_ids).exclude( + id__in=workflows_with_env.keys() + ).update(organization_id=organization.id) # Update DataConditionGroups connected to the transferred workflows # These are linked via WorkflowDataConditionGroup with a unique constraint on condition_group diff --git a/tests/sentry/models/test_project.py b/tests/sentry/models/test_project.py index a8d8daf2e569..20806a95c927 100644 --- a/tests/sentry/models/test_project.py +++ b/tests/sentry/models/test_project.py @@ -638,6 +638,28 @@ def test_transfer_to_organization_with_workflow_when_condition_groups(self) -> N assert workflow.organization_id == to_org.id assert when_condition_group.organization_id == to_org.id + def test_transfer_to_organization_updates_workflow_environment(self) -> None: + from_org = self.create_organization() + to_org = self.create_organization() + team = self.create_team(organization=from_org) + project = self.create_project(teams=[team]) + + env = self.create_environment(project=project, name="production") + detector = self.create_detector(project=project) + workflow = self.create_workflow(organization=from_org, environment=env) + self.create_detector_workflow(detector=detector, workflow=workflow) + + project.transfer_to(organization=to_org) + + workflow.refresh_from_db() + + assert workflow.organization_id == to_org.id + assert workflow.environment_id is not None + assert workflow.environment_id != env.id + new_env = Environment.objects.get(id=workflow.environment_id) + assert new_env.organization_id == to_org.id + assert new_env.name == "production" + def test_transfer_to_organization_nulls_detector_owner(self) -> None: from_user = self.create_user() from_org = self.create_organization(owner=from_user)