From 3aae89a5286815c937ff0484fb29192ad4936019 Mon Sep 17 00:00:00 2001 From: Ogi Date: Thu, 2 Apr 2026 12:49:05 +0200 Subject: [PATCH 01/13] fix(conversations): Handle [Filtered] values in conversation bubbles (#112092) --- .../utils/conversationMessages.spec.ts | 53 +++++++++++++++++++ .../utils/conversationMessages.ts | 21 +++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts b/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts index 354df3eae35c..5f4e48f1cea9 100644 --- a/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts @@ -252,6 +252,16 @@ describe('conversationMessages utilities', () => { const node = createMockNode({id: 'node-1'}); expect(parseUserContent(node as any)).toBeNull(); }); + + it('returns [Filtered] when input messages are scrubbed', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: '[Filtered]', + }, + }); + expect(parseUserContent(node as any)).toBe('[Filtered]'); + }); }); describe('parseAssistantContent', () => { @@ -304,6 +314,16 @@ describe('conversationMessages utilities', () => { const node = createMockNode({id: 'node-1'}); expect(parseAssistantContent(node as any)).toBeNull(); }); + + it('returns [Filtered] when output messages are scrubbed', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_OUTPUT_MESSAGES]: '[Filtered]', + }, + }); + expect(parseAssistantContent(node as any)).toBe('[Filtered]'); + }); }); describe('partitionSpansByType', () => { @@ -613,6 +633,39 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(1); }); + it('does not deduplicate [Filtered] messages across turns', () => { + const turns = [ + { + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + userContent: '[Filtered]', + assistantContent: '[Filtered]', + toolCalls: [], + userEmail: undefined, + }, + { + generation: { + id: 'gen-2', + value: {start_timestamp: 2000, end_timestamp: 2100}, + } as any, + userContent: '[Filtered]', + assistantContent: '[Filtered]', + toolCalls: [], + userEmail: undefined, + }, + ]; + + const messages = turnsToMessages(turns); + + const userMessages = messages.filter(m => m.role === 'user'); + const assistantMessages = messages.filter(m => m.role === 'assistant'); + + expect(userMessages).toHaveLength(2); + expect(assistantMessages).toHaveLength(2); + }); + it('attaches tool calls to assistant messages', () => { const turns = [ { diff --git a/static/app/views/insights/pages/conversations/utils/conversationMessages.ts b/static/app/views/insights/pages/conversations/utils/conversationMessages.ts index d21ac56a53db..86e60d42c08e 100644 --- a/static/app/views/insights/pages/conversations/utils/conversationMessages.ts +++ b/static/app/views/insights/pages/conversations/utils/conversationMessages.ts @@ -9,6 +9,8 @@ import { import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; import {SpanFields} from 'sentry/views/insights/types'; +const FILTERED = '[Filtered]'; + export interface ToolCall { hasError: boolean; name: string; @@ -152,7 +154,10 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ for (const turn of turns) { const timestamp = getNodeTimestamp(turn.generation); - if (turn.userContent && !seenUserContent.has(turn.userContent)) { + if ( + turn.userContent && + (turn.userContent === FILTERED || !seenUserContent.has(turn.userContent)) + ) { seenUserContent.add(turn.userContent); messages.push({ id: `user-${turn.generation.id}`, @@ -164,7 +169,11 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ }); } - if (turn.assistantContent && !seenAssistantContent.has(turn.assistantContent)) { + if ( + turn.assistantContent && + (turn.assistantContent === FILTERED || + !seenAssistantContent.has(turn.assistantContent)) + ) { seenAssistantContent.add(turn.assistantContent); // Duration: from start of generation span to end of last span (generation or tool) @@ -215,6 +224,10 @@ export function parseUserContent(node: AITraceSpanNode): string | null { return null; } + if (requestMessages === FILTERED) { + return FILTERED; + } + try { const messagesArray: RequestMessage[] = JSON.parse(requestMessages); const userMessage = messagesArray.findLast( @@ -233,6 +246,10 @@ export function parseAssistantContent(node: AITraceSpanNode): string | null { const outputMessages = getStringAttr(node, SpanFields.GEN_AI_OUTPUT_MESSAGES); if (outputMessages) { + if (outputMessages === FILTERED) { + return FILTERED; + } + try { const messagesArray: RequestMessage[] = JSON.parse(outputMessages); const assistantMessage = messagesArray.findLast( From 1e18a89a9b557cd18bffc4132ccd08b0c9b7a008 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Thu, 2 Apr 2026 09:27:33 -0400 Subject: [PATCH 02/13] feat(outboxes) Add logging to outbox backfill operations (#111997) Get some more visibility into what is happening within outbox backfill machinery. --- src/sentry/hybridcloud/tasks/backfill_outboxes.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/sentry/hybridcloud/tasks/backfill_outboxes.py b/src/sentry/hybridcloud/tasks/backfill_outboxes.py index 463b48c09919..642462366dcf 100644 --- a/src/sentry/hybridcloud/tasks/backfill_outboxes.py +++ b/src/sentry/hybridcloud/tasks/backfill_outboxes.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from django.apps import apps @@ -21,6 +22,8 @@ from sentry.users.models.user import User from sentry.utils import json, metrics, redis +logger = logging.getLogger(__name__) + def _get_redis_client() -> RedisCluster[str] | StrictRedis[str]: return redis.redis_clusters.get(settings.SENTRY_HYBRIDCLOUD_BACKFILL_OUTBOXES_REDIS_CLUSTER) @@ -138,8 +141,19 @@ def process_outbox_backfill_batch( model, batch_size=batch_size, force_synchronous=force_synchronous ) if not processing_state: + logger.info("processing_state.missing", extra={"model": model.__name__}) return None + logger.info( + "processing_state.current", + extra={ + "model": model.__name__, + "batch_low": processing_state.low, + "batch_up": processing_state.up, + "version": processing_state.version, + }, + ) + for inst in model.objects.filter(id__gte=processing_state.low, id__lte=processing_state.up): with outbox_context(transaction.atomic(router.db_for_write(model)), flush=False): if isinstance(inst, CellOutboxProducingModel): From a36999f97bb39cd1abb6e62d66f7ac331f8cd99e Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:44:38 -0400 Subject: [PATCH 03/13] feat(slack): Add tags to Slack event endpoint for observability (#112023) --- src/sentry/integrations/slack/webhooks/event.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index b6b347df46b8..b5f1adc94d37 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -176,6 +176,7 @@ def _get_unfurlable_links( ) -> dict[LinkType, list[UnfurlableUrl]]: matches: dict[LinkType, list[UnfurlableUrl]] = defaultdict(list) links_seen = set() + link_types: set[str] = set() for item in data.get("links", []): with MessagingInteractionEvent( @@ -223,6 +224,10 @@ def _get_unfurlable_links( links_seen.add(seen_marker) matches[link_type].append(UnfurlableUrl(url=url, args=args)) + link_types.add(getattr(link_type, "value", str(link_type))) + + if len(link_types) > 0: + sentry_sdk.set_tag("slack.link_type", ",".join(sorted(link_types))) return matches @@ -267,6 +272,12 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo ) organization = organization_context.organization if organization_context else None + if organization: + sentry_sdk.set_tag("organization.slug", organization.slug) + identity_user = slack_request.get_identity_user() + if identity_user: + sentry_sdk.set_tag("user.email", identity_user.email) + logger_params = { "integration_id": slack_request.integration.id, "team_id": slack_request.team_id, @@ -427,6 +438,9 @@ def post(self, request: Request) -> Response: if slack_request.is_challenge(): return self.on_url_verification(request, slack_request.data) + + sentry_sdk.set_tag("slack.event_type", slack_request.type) + if slack_request.type == "link_shared": if self.on_link_shared(request, slack_request): return self.respond() From c49bbac11a1bd375a33239fe6e2c8d2499281fc7 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:58:44 -0400 Subject: [PATCH 04/13] feat(errors): add page filters and search bar UI (#112003) I'm mainly adding UI to start, so I've added the page filters and search bar but they don't work yet! This is what it looks like image --- .../dashboards/utils/getWidgetExploreUrl.tsx | 1 + .../app/views/explore/errors/content.spec.tsx | 52 ++++++++++++++++++- static/app/views/explore/errors/content.tsx | 20 ++++++- .../views/explore/errors/filterContent.tsx | 46 ++++++++++++++++ .../explore/spans/spansTabSearchSection.tsx | 2 +- static/app/views/explore/types.tsx | 1 + static/app/views/explore/utils.tsx | 1 + 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 static/app/views/explore/errors/filterContent.tsx diff --git a/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx b/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx index b25b2abe77e0..a52f3af39069 100644 --- a/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx +++ b/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx @@ -89,6 +89,7 @@ const WIDGET_TRACE_ITEM_TO_URL_FUNCTION: Record< [TraceItemDataset.PREPROD]: undefined, [TraceItemDataset.REPLAYS]: undefined, [TraceItemDataset.PROCESSING_ERRORS]: undefined, + [TraceItemDataset.ERRORS]: undefined, }; export function getWidgetExploreUrl( diff --git a/static/app/views/explore/errors/content.spec.tsx b/static/app/views/explore/errors/content.spec.tsx index 80133e7ceb75..d4198cf7f3ad 100644 --- a/static/app/views/explore/errors/content.spec.tsx +++ b/static/app/views/explore/errors/content.spec.tsx @@ -1,13 +1,61 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; + import ErrorsContent from './content'; describe('ErrorsContent', () => { - it('renders the Errors page title', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState({ + projects: [], + environments: [], + datetime: {period: '14d', start: null, end: null, utc: false}, + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + method: 'GET', + body: [ProjectFixture()], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/trace-items/attributes/', + method: 'GET', + body: [], + }); + }); + + it('renders the Errors page title', async () => { const organization = OrganizationFixture(); render(, {organization}); - expect(screen.getByText('Errors')).toBeInTheDocument(); + expect(await screen.findByText('Errors')).toBeInTheDocument(); + }); + + it('renders page filter bar with project, environment, and date filters', async () => { + const organization = OrganizationFixture(); + render(, {organization}); + + expect(await screen.findByTestId('page-filter-project-selector')).toBeInTheDocument(); + expect(screen.getByTestId('page-filter-environment-selector')).toBeInTheDocument(); + expect(screen.getByTestId('page-filter-timerange-selector')).toBeInTheDocument(); + }); + + it('renders the search query builder', async () => { + const organization = OrganizationFixture(); + render(, {organization}); + + expect( + await screen.findByRole('combobox', {name: /add a search term/i}) + ).toBeInTheDocument(); }); }); diff --git a/static/app/views/explore/errors/content.tsx b/static/app/views/explore/errors/content.tsx index bce9acfec0a0..2d3fb49ad0fb 100644 --- a/static/app/views/explore/errors/content.tsx +++ b/static/app/views/explore/errors/content.tsx @@ -1,15 +1,27 @@ +import {FeatureBadge} from '@sentry/scraps/badge'; + +import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; +import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {ExploreBodySearch} from 'sentry/views/explore/components/styles'; +import {ErrorsFilterSection} from 'sentry/views/explore/errors/filterContent'; export default function ErrorsContent() { const organization = useOrganization(); + // TODO: max pickable days logic for error occurences return ( + + + + + ); @@ -19,9 +31,13 @@ function ErrorsHeader() { return ( - {t('Errors')} + + {t('Errors')} + - + + + ); } diff --git a/static/app/views/explore/errors/filterContent.tsx b/static/app/views/explore/errors/filterContent.tsx new file mode 100644 index 000000000000..2e0240d87e6a --- /dev/null +++ b/static/app/views/explore/errors/filterContent.tsx @@ -0,0 +1,46 @@ +import {Grid} from '@sentry/scraps/layout'; + +import * as Layout from 'sentry/components/layouts/thirds'; +import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter'; +import {EnvironmentPageFilter} from 'sentry/components/pageFilters/environment/environmentPageFilter'; +import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {SearchQueryBuilderProvider} from 'sentry/components/searchQueryBuilder/context'; +import {t} from 'sentry/locale'; +import {TraceItemSearchQueryBuilder} from 'sentry/views/explore/components/traceItemSearchQueryBuilder'; +import {StyledPageFilterBar} from 'sentry/views/explore/spans/spansTabSearchSection'; +import {TraceItemDataset} from 'sentry/views/explore/types'; + +export function ErrorsFilterSection() { + return ( + + Promise.resolve([])} + initialQuery="" + searchSource="errors-filter" + placeholder={t('Search for errors, users, tags, and more')} + > + {/* TODO: add in min-content column for cross event querying when that's implemented */} + + + + + + + + + + + + ); +} diff --git a/static/app/views/explore/spans/spansTabSearchSection.tsx b/static/app/views/explore/spans/spansTabSearchSection.tsx index 37c1d1fcff9b..31880086f743 100644 --- a/static/app/views/explore/spans/spansTabSearchSection.tsx +++ b/static/app/views/explore/spans/spansTabSearchSection.tsx @@ -506,6 +506,6 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection ); } -const StyledPageFilterBar = styled(PageFilterBar)` +export const StyledPageFilterBar = styled(PageFilterBar)` width: auto; `; diff --git a/static/app/views/explore/types.tsx b/static/app/views/explore/types.tsx index b25570a25bab..ea87fca37886 100644 --- a/static/app/views/explore/types.tsx +++ b/static/app/views/explore/types.tsx @@ -8,6 +8,7 @@ export enum TraceItemDataset { PREPROD = 'preprod', REPLAYS = 'replays', PROCESSING_ERRORS = 'processing_errors', + ERRORS = 'errors', } export interface UseTraceItemAttributeBaseProps { diff --git a/static/app/views/explore/utils.tsx b/static/app/views/explore/utils.tsx index 6c984930ea2d..cd4a259243f1 100644 --- a/static/app/views/explore/utils.tsx +++ b/static/app/views/explore/utils.tsx @@ -713,6 +713,7 @@ const TRACE_ITEM_TO_URL_FUNCTION: Record< [TraceItemDataset.PREPROD]: undefined, [TraceItemDataset.REPLAYS]: getReplayUrlFromSavedQueryUrl, [TraceItemDataset.PROCESSING_ERRORS]: undefined, + [TraceItemDataset.ERRORS]: undefined, }; /** From 122ef3d3fa1152d234b28e13358d879699589700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 2 Apr 2026 10:16:46 -0400 Subject: [PATCH 05/13] feat(logs): Add ourlogs-table-expando feature flag (#112031) Register the `organizations:ourlogs-table-expando` feature flag to gate the expand/collapse table height toggle in the logs UI. Split out from #109819 so the flag lands independently of the UI changes. Made with [Cursor](https://cursor.com) Co-authored-by: Claude Opus --- 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 16af60e86bff..9cae42e7eba9 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -459,6 +459,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:ourlogs-stats", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable overlaying charts in logs manager.add("organizations:ourlogs-overlay-charts-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable the expand/collapse table height toggle in the logs UI + manager.add("organizations:ourlogs-table-expando", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable alerting on trace metrics manager.add("organizations:tracemetrics-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable attributes dropdown side panel in trace metrics From cdc11493bca671bbeb8e3b9083dbc18bd29be4ce Mon Sep 17 00:00:00 2001 From: klochek Date: Thu, 2 Apr 2026 10:20:07 -0400 Subject: [PATCH 06/13] feat(workflows): start using the action filters cache (#111817) --- src/sentry/features/temporary.py | 2 ++ .../workflow_engine/processors/workflow.py | 25 +++++++++---- .../processors/test_workflow.py | 36 +++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 9cae42e7eba9..2f86c0fa7366 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -420,6 +420,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:workflow-engine-metric-alert-dual-processing-logs", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Creation of Metric Alerts that use the `group_by` field in the workflow_engine manager.add("organizations:workflow-engine-metric-alert-group-by-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable caching for workflow action filters + manager.add("organizations:workflow-engine-action-filters-cache", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable ingestion through trusted relays only manager.add("organizations:ingest-through-trusted-relays-only", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable metric issue UI for issue alerts diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 002a39d64bcc..a4f0ad1ef229 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -12,6 +12,7 @@ from sentry.models.environment import Environment from sentry.services.eventstore.models import GroupEvent from sentry.workflow_engine.buffer.batch_client import DelayedWorkflowClient, DelayedWorkflowItem +from sentry.workflow_engine.caches.action_filters import get_action_filters_by_workflows from sentry.workflow_engine.caches.workflow import get_workflows_by_detectors from sentry.workflow_engine.models import DataConditionGroup, Detector, DetectorWorkflow, Workflow from sentry.workflow_engine.models.data_condition import DataCondition @@ -269,12 +270,24 @@ def evaluate_workflows_action_filters( queue_items_by_workflow.keys() ) - action_conditions_to_workflow: dict[DataConditionGroup, Workflow] = { - wdcg.condition_group: wdcg.workflow - for wdcg in WorkflowDataConditionGroup.objects.select_related( - "workflow", "condition_group" - ).filter(workflow__in=all_workflows) - } + organization = event_data.event.project.organization + + action_conditions_to_workflow: dict[DataConditionGroup, Workflow] = {} + + if features.has("organizations:workflow-engine-action-filters-cache", organization): + all_workflows_lookup: dict[int, Workflow] = {w.id: w for w in all_workflows} + action_filters_by_workflows = get_action_filters_by_workflows(all_workflows) + + for workflow_id, dcgs in action_filters_by_workflows.items(): + for dcg in dcgs: + action_conditions_to_workflow[dcg] = all_workflows_lookup[workflow_id] + else: + action_conditions_to_workflow = { + wdcg.condition_group: wdcg.workflow + for wdcg in WorkflowDataConditionGroup.objects.select_related( + "workflow", "condition_group" + ).filter(workflow__in=all_workflows) + } filtered_action_groups: set[DataConditionGroup] = set() diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index 2c0631c0a16a..07818e0ee035 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -742,6 +742,16 @@ def test_evaluation_stats_add(self) -> None: b = EvaluationStats(tainted=3, untainted=4) assert a + b == EvaluationStats(tainted=4, untainted=6) + # Temporary test to exercise all evaluate_workflows_action_filters paths + # with caching enabled + def test_action_filter_stats_excludes_delayed_workflows__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_action_filter_stats_excludes_delayed_workflows() + + def test_action_filter_stats_from_trigger_result__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_action_filter_stats_from_trigger_result() + @freeze_time(FROZEN_TIME) class TestWorkflowEnqueuing(BaseWorkflowTest): @@ -1127,6 +1137,32 @@ def test_enqueues_when_slow_conditions(self) -> None: ) assert list(project_ids.keys()) == [self.project.id] + # Temporary tests to exercise all evaluate_workflows_action_filters paths + # with caching enabled + def test_activity__with_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_activity__with_slow_conditions() + + def test_enqueues_when_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_enqueues_when_slow_conditions() + + def test_with_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_with_slow_conditions() + + def test_basic__with_filter__filtered__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__with_filter__filtered() + + def test_basic__with_filter__passes__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__with_filter__passes() + + def test_basic__no_filter__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__no_filter() + class TestEnqueueWorkflows(BaseWorkflowTest): def setUp(self) -> None: From eb6e40c666b892c9fe6272d460388ecd74c97349 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Thu, 2 Apr 2026 10:34:55 -0400 Subject: [PATCH 07/13] fix: Fix merge_users failing on IntegrityError (#111882) We have incrementally patched a few problematic associations. This change should solve those problems generally by using schema reflection and generating query conditions dynamically based on relationship trees. Refs SENTRY-5HVP --- src/sentry/backup/dependencies.py | 128 ++++++++++++++----------- tests/sentry/users/models/test_user.py | 126 ++++++++++++++++++++++-- 2 files changed, 193 insertions(+), 61 deletions(-) diff --git a/src/sentry/backup/dependencies.py b/src/sentry/backup/dependencies.py index e7325c0d5547..3089a5821b0c 100644 --- a/src/sentry/backup/dependencies.py +++ b/src/sentry/backup/dependencies.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections import defaultdict +from collections import defaultdict, deque from dataclasses import dataclass from enum import Enum, auto, unique from functools import lru_cache @@ -667,41 +667,47 @@ def get_exportable_sentry_models() -> set[type[models.base.Model]]: ) -def dedupe_and_reassign_groupseen_in_org( - organization_id: int, from_user_id: int, to_user_id: int -) -> None: - """ - Dedupe GroupSeen rows inside an organization and reassign them to the new user. +def _get_org_scope_condition(model_relations: ModelRelations, organization_id: int) -> Q: """ - from sentry.models.groupseen import GroupSeen - - scoped = Q(group__project__organization_id=organization_id) - # Remove from_user rows that would collide with to_user for the same group - GroupSeen.objects.filter( - scoped, - user_id=from_user_id, - group_id__in=GroupSeen.objects.filter(scoped, user_id=to_user_id).values("group_id"), - ).delete() - GroupSeen.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id) + Finds a path from this model to Organization through FK relationships and returns a Q object + scoping the model to the given organization_id. Uses BFS to find the shortest path. + Only traverses real DB-level FK types (FlexibleForeignKey, DefaultForeignKey, OneToOneField + variants). HybridCloudForeignKey and ImplicitForeignKey are skipped because they don't support + Django ORM __ traversal. We skip over nullable relations to avoid generating conditions + that don't find any records. -def dedupe_and_reassign_groupsubscription_in_org( - organization_id: int, from_user_id: int, to_user_id: int -) -> None: - """ - Dedupe GroupSubscription rows inside an organization and reassign them to the new user. + Returns Q() if no path to Organization is found (caller's queries will be unscoped). """ - from sentry.models.groupsubscription import GroupSubscription + from sentry.models.organization import Organization + + traversable = { + ForeignFieldKind.FlexibleForeignKey, + ForeignFieldKind.DefaultForeignKey, + ForeignFieldKind.OneToOneCascadeDeletes, + ForeignFieldKind.DefaultOneToOneField, + } + all_deps = dependencies() + visited: set[NormalizedModelName] = {get_model_name(model_relations.model)} + queue: deque[tuple[ModelRelations, str]] = deque([(model_relations, "")]) + + while queue: + current, prefix = queue.popleft() + for field_name, fk in current.foreign_keys.items(): + if fk.model is Organization: + col = field_name if field_name.endswith("_id") else f"{field_name}_id" + return Q(**{f"{prefix}{col}": organization_id}) + if fk.kind not in traversable: + continue + if fk.nullable: + continue + related_name = get_model_name(fk.model) + if related_name not in visited and related_name in all_deps: + visited.add(related_name) + traversal = field_name[:-3] if field_name.endswith("_id") else field_name + queue.append((all_deps[related_name], f"{prefix}{traversal}__")) - scoped = Q(group__project__organization_id=organization_id) - GroupSubscription.objects.filter( - scoped, - user_id=from_user_id, - group_id__in=GroupSubscription.objects.filter(scoped, user_id=to_user_id).values( - "group_id" - ), - ).delete() - GroupSubscription.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id) + return Q() def merge_users_for_model_in_org( @@ -710,34 +716,46 @@ def merge_users_for_model_in_org( """ All instances of this model in a certain organization that reference both the organization and user in question will be pointed at the new user instead. - """ - from sentry.models.groupseen import GroupSeen - from sentry.models.groupsubscription import GroupSubscription - from sentry.models.organization import Organization + For models with unique constraints that include a user field, conflicting rows (where the + to_user already has a row matching the other unique fields) are deleted before the update to + avoid IntegrityErrors. + """ from sentry.users.models.user import User - # Special-case: GroupSeen has unique_together (user_id, group). Dedupe conflicts inside org - # then update remaining rows. - if model is GroupSeen: - dedupe_and_reassign_groupseen_in_org(organization_id, from_user_id, to_user_id) - return - - # Special-case: GroupSubscription has unique_together (group, user_id). Same pattern. - if model is GroupSubscription: - dedupe_and_reassign_groupsubscription_in_org(organization_id, from_user_id, to_user_id) - return - model_relations = dependencies()[get_model_name(model)] user_refs = {k for k, v in model_relations.foreign_keys.items() if v.model == User} - - org_refs = { - k if k.endswith("_id") else f"{k}_id" - for k, v in model_relations.foreign_keys.items() - if v.model == Organization - } - for_this_org = Q(**{field_name: organization_id for field_name in org_refs}) + for_this_org = _get_org_scope_condition(model_relations, organization_id) + + # model_relations.uniques only contains fields, and needs to be json encodable. + unique_constraints: list[tuple[frozenset[str], Q]] = [] + for unique_fields in model._meta.unique_together: + unique_constraints.append((frozenset(unique_fields), Q())) + for constraint in model._meta.constraints: + if not isinstance(constraint, UniqueConstraint): + continue + unique_constraints.append((frozenset(constraint.fields), constraint.condition or Q())) for user_ref in user_refs: - q = for_this_org & Q(**{user_ref: from_user_id}) - model.objects.filter(q).update(**{user_ref: to_user_id}) + # For any unique constraint that includes a user/user_id field, delete rows that would + # collide after reassignment before doing the update. + user_uniques = [u for u in unique_constraints if user_ref in u[0]] + for user_constraint in user_uniques: + other_fields = list(user_constraint[0] - {user_ref}) + if not other_fields: + # user_ref is unique on its own, delete from_user row so that + # updates of to_user -> from_user don't conflict. + model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: from_user_id} + ).delete() + else: + for matching in model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: to_user_id} + ).values(*other_fields): + model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: from_user_id}, **matching + ).delete() + + model.objects.filter(for_this_org & Q(**{user_ref: from_user_id})).update( + **{user_ref: to_user_id} + ) diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index cd02dcf9a8e4..238dafb0925d 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -5,7 +5,11 @@ from django.db.models import Q import sentry.hybridcloud.rpc.caching as caching_module -from sentry.backup.dependencies import NormalizedModelName, dependencies, get_model_name +from sentry.backup.dependencies import ( + NormalizedModelName, + dependencies, + get_model_name, +) from sentry.db.models.base import Model from sentry.deletions.tasks.hybrid_cloud import schedule_hybrid_cloud_foreign_key_jobs from sentry.incidents.models.alert_rule import AlertRule, AlertRuleActivity @@ -31,7 +35,8 @@ from sentry.models.recentsearch import RecentSearch from sentry.models.rule import Rule, RuleActivity from sentry.models.rulesnooze import RuleSnooze -from sentry.models.savedsearch import SavedSearch +from sentry.models.savedsearch import SavedSearch, Visibility +from sentry.models.search_common import SearchType from sentry.models.tombstone import CellTombstone from sentry.monitors.models import Monitor from sentry.silo.base import SiloMode @@ -261,10 +266,8 @@ def test_merge_handles_groupseen_conflicts(self) -> None: from_user = self.create_user("from-user@example.com") to_user = self.create_user("to-user@example.com") org = self.create_organization(name="conflict-org") - - with outbox_runner(): - with assume_test_silo_mode(SiloMode.CELL): - self.create_member(user=from_user, organization=org) + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) with assume_test_silo_mode(SiloMode.CELL): project = self.create_project(organization=org) @@ -307,6 +310,117 @@ def test_merge_handles_groupsubscription_conflicts(self) -> None: assert not GroupSubscription.objects.filter(group=group, user_id=from_user.id).exists() assert GroupSubscription.objects.filter(group=group, user_id=to_user.id).count() == 1 + def test_merge_to_users_in_same_org_recentsearch_no_collision(self) -> None: + # from_user and to_user have different queries — no unique constraint conflict. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_search = RecentSearch.objects.create( + organization=org, + user_id=from_user.id, + type=SearchType.ISSUE.value, + query="from user query", + ) + to_search = RecentSearch.objects.create( + organization=org, + user_id=to_user.id, + type=SearchType.ISSUE.value, + query="to user query", + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert RecentSearch.objects.filter(id=from_search.id, user_id=to_user.id).exists() + assert RecentSearch.objects.filter(id=to_search.id, user_id=to_user.id).exists() + assert not RecentSearch.objects.filter(user_id=from_user.id).exists() + + def test_merge_recentsearch_collision_deletes_from_user_row(self) -> None: + # from_user and to_user have the same (org, type, query) — the from_user row must be + # deleted before the update to avoid violating the unique_together constraint. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_search = RecentSearch.objects.create( + organization=org, + user_id=from_user.id, + type=SearchType.ISSUE.value, + query="duplicate query", + ) + to_search = RecentSearch.objects.create( + organization=org, + user_id=to_user.id, + type=SearchType.ISSUE.value, + query="duplicate query", + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert not RecentSearch.objects.filter(id=from_search.id).exists() + assert RecentSearch.objects.filter(id=to_search.id, user_id=to_user.id).exists() + assert not RecentSearch.objects.filter(user_id=from_user.id).exists() + + def test_merge_savedsearch_unique_condition_preserved(self) -> None: + # SharedSearch has a conditional unique constraint. + # from_user and to_user both have saved searches that don't meet that condition, + # and both should be preserved, while the searches matching the condition should only + # have one retained. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_user_org = SavedSearch.objects.create( + organization=org, + owner_id=from_user.id, + type=SearchType.ISSUE.value, + query="duplicate query should be retained", + visibility=Visibility.ORGANIZATION, + ) + from_user_pinned = SavedSearch.objects.create( + organization=org, + owner_id=from_user.id, + type=SearchType.ISSUE.value, + query="should be deleted because of visiblilty", + visibility=Visibility.OWNER_PINNED, + ) + to_user_org = SavedSearch.objects.create( + organization=org, + owner_id=to_user.id, + type=SearchType.ISSUE.value, + query="duplicate query should be retained", + visibility=Visibility.ORGANIZATION, + ) + to_user_pinned = SavedSearch.objects.create( + organization=org, + owner_id=to_user.id, + type=SearchType.ISSUE.value, + query="should be retained", + visibility=Visibility.OWNER_PINNED, + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert SavedSearch.objects.filter(id=from_user_org.id, owner_id=to_user.id).exists() + assert SavedSearch.objects.filter(id=to_user_org.id, owner_id=to_user.id).exists() + assert SavedSearch.objects.filter(id=to_user_pinned.id, owner_id=to_user.id).exists() + assert not SavedSearch.objects.filter(id=from_user_pinned.id).exists() + @expect_models( ORG_MEMBER_MERGE_TESTED, OrgAuthToken, From e3dc55f3c911cad3780f3e90649bc067e7577b08 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 2 Apr 2026 10:35:55 -0400 Subject: [PATCH 08/13] fix(stories): avoid circular dependencies on `Button` (#112039) Currently we use `import * as Stories from 'sentry/stories'` in quite a few places. When working on #111369, I discovered that this can cause circular imports in the `Button` component because `APIReference` and `ThemeSwitcher` imported `Button` internally. This PR cleans up the `sentry/stories` barrel file to remove circular import opportunities --- static/app/stories/index.tsx | 2 -- static/app/stories/storybook.tsx | 25 ++++++++++++++++-------- static/app/stories/view/storyExports.tsx | 3 ++- static/app/stories/view/storyHeader.tsx | 4 ++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/static/app/stories/index.tsx b/static/app/stories/index.tsx index c463a3d1d6cb..ef46fd3fe3bd 100644 --- a/static/app/stories/index.tsx +++ b/static/app/stories/index.tsx @@ -1,4 +1,3 @@ -export {APIReference} from './apiReference'; export {ColorReference} from './colorReference'; export {Demo} from './demo'; export {JSXNode, JSXProperty} from './jsx'; @@ -8,6 +7,5 @@ export {Section} from './layout'; export {SideBySide} from './layout'; export {SizingWindow, Grid} from './layout'; export {story} from './storybook'; -export {ThemeSwitcher} from './theme'; export {TokenReference} from './tokenReference'; export {StoryTable as Table} from './table'; diff --git a/static/app/stories/storybook.tsx b/static/app/stories/storybook.tsx index 972303bd69b2..39f989d75e16 100644 --- a/static/app/stories/storybook.tsx +++ b/static/app/stories/storybook.tsx @@ -1,14 +1,19 @@ import type {ReactNode} from 'react'; -import {Children, Fragment, useEffect} from 'react'; +import {Children, Fragment, Suspense, lazy, useEffect} from 'react'; import {Container} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; -import {StoryHeading} from 'sentry/stories/view/storyHeading'; - -import {APIReference} from './apiReference'; import {Section, SideBySide} from './layout'; +// Lazy-loaded to bypass circular dependencies on Button +const StoryHeading = lazy(() => + import('sentry/stories/view/storyHeading').then(m => ({default: m.StoryHeading})) +); +const APIReference = lazy(() => + import('./apiReference').then(m => ({default: m.APIReference})) +); + function makeStorybookDocumentTitle(title: string | undefined): string { return title ? `${title} — Scraps` : 'Scraps'; } @@ -51,7 +56,9 @@ export function story(title: string, setup: SetupFunction): StoryRenderFunction ))} {APIDocumentation.map((documentation, i) => ( - + + + ))} ); @@ -65,9 +72,11 @@ function Story(props: {name: string; render: StoryRenderFunction}) { return (
- - {props.name} - + {props.name}}> + + {props.name} + + {isOneChild ? children : {children}}
diff --git a/static/app/stories/view/storyExports.tsx b/static/app/stories/view/storyExports.tsx index 6b9ed03caefa..0b874637af5d 100644 --- a/static/app/stories/view/storyExports.tsx +++ b/static/app/stories/view/storyExports.tsx @@ -13,6 +13,7 @@ import {Heading, Text} from '@sentry/scraps/text'; import {t} from 'sentry/locale'; import * as Storybook from 'sentry/stories'; +import {APIReference} from 'sentry/stories/apiReference'; import {useQuery} from 'sentry/utils/queryClient'; import {StoryFooter} from './storyFooter'; @@ -250,7 +251,7 @@ function StoryAPI(props: {documentation: TypeLoader.TypeLoaderResult | undefined return ( {Object.entries(props.documentation.props ?? {}).map(([key, value]) => { - return ; + return ; })} ); diff --git a/static/app/stories/view/storyHeader.tsx b/static/app/stories/view/storyHeader.tsx index 530b9817d1aa..66094fd1937e 100644 --- a/static/app/stories/view/storyHeader.tsx +++ b/static/app/stories/view/storyHeader.tsx @@ -6,7 +6,7 @@ import {Link} from '@sentry/scraps/link'; import {Heading} from '@sentry/scraps/text'; import {IconGithub, IconLink} from 'sentry/icons'; -import * as Storybook from 'sentry/stories'; +import {ThemeSwitcher} from 'sentry/stories/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -57,7 +57,7 @@ export function StoryHeader() { sentry.io - + ); From e10a9fe42431ef4107c1f3f316e6a5ba6413a53b Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 2 Apr 2026 08:08:19 -0700 Subject: [PATCH 09/13] deps(ui): Upgrade typescript-eslint packages (#112080) Supports typescript v6, fix some reasonable new lint errors --- package.json | 6 +- pnpm-lock.yaml | 220 ++++++++++-------- .../autofix/autofixSolutionEventItem.tsx | 2 +- .../widgetBuilder/addToDashboardModal.tsx | 3 +- .../components/searchSyntax/mutableSearch.tsx | 4 +- static/app/stores/guideStore.tsx | 2 +- .../replays/hooks/useExtractDiffMutations.tsx | 5 +- static/app/utils/replays/replayReader.tsx | 2 +- .../components/tables/queriesTable.tsx | 2 +- .../tables/queryTransactionsTable.tsx | 2 +- .../common/components/tables/screensTable.tsx | 2 +- .../components/tables/eventSamplesTable.tsx | 2 +- .../performance/eap/overviewSpansTable.tsx | 2 +- .../performance/eap/segmentSpansTable.tsx | 2 +- .../newTraceDetails/traceConfigurations.tsx | 2 +- .../components/customers/customerOverview.tsx | 5 +- 16 files changed, 141 insertions(+), 122 deletions(-) diff --git a/package.json b/package.json index ff38e7f980ea..66c00ae0a974 100644 --- a/package.json +++ b/package.json @@ -248,8 +248,8 @@ "@types/d3-zoom": "^3.0.8", "@types/gettext-parser": "8.0.0", "@types/node": "^22.9.1", - "@typescript-eslint/rule-tester": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/rule-tester": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260112.1", "@volar/typescript": "^2.4.28", "babel-jest": "30.3.0", @@ -286,7 +286,7 @@ "stylelint": "16.10.0", "stylelint-config-recommended": "^14.0.1", "terser": "5.40.0", - "typescript-eslint": "8.56.1" + "typescript-eslint": "8.58.0" }, "optionalDependencies": { "fsevents": "^2.3.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4a3bc04787..7f58f5feb8ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,11 +602,11 @@ importers: specifier: ^22.9.1 version: 22.15.21 '@typescript-eslint/rule-tester': - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) '@typescript/native-preview': specifier: 7.0.0-dev.20260112.1 version: 7.0.0-dev.20260112.1 @@ -627,13 +627,13 @@ importers: version: 3.8.3(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-boundaries: specifier: 6.0.2 - version: 6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + version: 6.0.2(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-jest: specifier: 29.15.0 - version: 29.15.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.15.0(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3) eslint-plugin-jest-dom: specifier: ^5.5.0 version: 5.5.0(@testing-library/dom@10.4.1)(eslint@9.34.0(jiti@2.6.1)) @@ -663,7 +663,7 @@ importers: version: 7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-typescript-sort-keys: specifier: ^3.3.0 - version: 3.3.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + version: 3.3.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-unicorn: specifier: ^57.0.0 version: 57.0.0(eslint@9.34.0(jiti@2.6.1)) @@ -716,8 +716,8 @@ importers: specifier: 5.40.0 version: 5.40.0 typescript-eslint: - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: fsevents: specifier: ^2.3.3 @@ -4152,13 +4152,13 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.58.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/experimental-utils@5.62.0': resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} @@ -4166,21 +4166,21 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/rule-tester@8.56.1': - resolution: {integrity: sha512-EWuV5Vq1EFYJEOVcILyWPO35PjnT0c6tv99PCpD12PgfZae5/Jo+F17hGjsEs2Moe+Dy1J7KIr8y037cK8+/rQ==} + '@typescript-eslint/rule-tester@8.58.0': + resolution: {integrity: sha512-a/J72Cxeo5ug5sbey7+Dcna6tMBc4Z4eYwBEKM6MVuBqbxnROpLm8yn/j00lPZc75joPZJVR5oiTZxbK95zp+w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4193,18 +4193,22 @@ packages: resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@5.62.0': resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} @@ -4214,6 +4218,10 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@5.62.0': resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4223,11 +4231,11 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@5.62.0': resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} @@ -4235,12 +4243,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@5.62.0': resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} @@ -4250,6 +4258,10 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260112.1': resolution: {integrity: sha512-FUOOGN0/9LF+AOX07SOqfX1hBQfP3rezMFCwDlwAVW52leJ2Fur8efrQR5oUNL8hDt/NMGJwsg3wreZGdYSqJg==} cpu: [arm64] @@ -9095,8 +9107,8 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -9196,12 +9208,12 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript-template-language-service-decorator@2.3.2: resolution: {integrity: sha512-hN0zNkr5luPCeXTlXKxsfBPlkAzx86ZRM1vPdL7DbEqqWoeXSxplACy98NpKpLmXsdq7iePUzAXloCAoPKBV6A==} @@ -10806,10 +10818,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': + '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': dependencies: eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 @@ -13405,7 +13417,7 @@ snapshots: '@tanstack/eslint-plugin-query@5.96.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 @@ -13963,18 +13975,18 @@ snapshots: dependencies: '@types/yargs-parser': 15.0.0 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 eslint: 9.34.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13987,32 +13999,32 @@ snapshots: - supports-color - typescript - '@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/rule-tester@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/rule-tester@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) ajv: 6.12.6 eslint: 9.34.0(jiti@2.6.1) json-stable-stringify-without-jsonify: 1.0.1 @@ -14032,18 +14044,23 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.34.0(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -14052,6 +14069,8 @@ snapshots: '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 5.62.0 @@ -14066,17 +14085,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.3 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -14096,12 +14115,12 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.34.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -14117,6 +14136,11 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260112.1': optional: true @@ -15685,7 +15709,7 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -15709,24 +15733,24 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: - '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) chalk: 4.1.2 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) handlebars: 4.7.9 micromatch: 4.0.8 transitivePeerDependencies: @@ -15735,7 +15759,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15746,7 +15770,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -15758,7 +15782,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -15772,12 +15796,12 @@ snapshots: optionalDependencies: '@testing-library/dom': 10.4.1 - eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3): + eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) jest: 30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)) typescript: 5.9.3 transitivePeerDependencies: @@ -15861,16 +15885,16 @@ snapshots: eslint-plugin-testing-library@7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-typescript-sort-keys@3.3.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-typescript-sort-keys@3.3.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/experimental-utils': 5.62.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) json-schema: 0.4.0 natural-compare-lite: 1.4.0 @@ -20103,7 +20127,7 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -20220,12 +20244,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: diff --git a/static/app/components/events/autofix/autofixSolutionEventItem.tsx b/static/app/components/events/autofix/autofixSolutionEventItem.tsx index 246b1aed9c05..26a63e4aa8be 100644 --- a/static/app/components/events/autofix/autofixSolutionEventItem.tsx +++ b/static/app/components/events/autofix/autofixSolutionEventItem.tsx @@ -187,7 +187,7 @@ export function SolutionEventItem({ > - {event.relevant_code_file && event.relevant_code_file.url && ( + {event.relevant_code_file?.url && ( diff --git a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx index bc9823575a2f..a8c2ceafc146 100644 --- a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx +++ b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx @@ -143,8 +143,7 @@ function AddToDashboardModal({ const widgetTemplates = getTopNConvertedDefaultWidgets(organization); const widgetTemplate = widgetTemplates.find(w => w.displayType === widget.displayType); const shouldOpenWidgetLibrary = - !isWidgetEditable(widget.displayType) || - (widgetTemplate && widgetTemplate.isCustomizable === false); + !isWidgetEditable(widget.displayType) || widgetTemplate?.isCustomizable === false; const handleWidgetTableSort = (sort: Sort) => { const newOrderBy = `${sort.kind === 'desc' ? '-' : ''}${sort.field}`; diff --git a/static/app/components/searchSyntax/mutableSearch.tsx b/static/app/components/searchSyntax/mutableSearch.tsx index be4fd712a30c..ddf6cce29ac5 100644 --- a/static/app/components/searchSyntax/mutableSearch.tsx +++ b/static/app/components/searchSyntax/mutableSearch.tsx @@ -188,10 +188,10 @@ function parseToFlatTokens(query: string): Token[] { let rawVal: string; let valueWasQuoted = false; let listValues: string[] | undefined; - if (t.value && t.value.type === ParserToken.VALUE_TEXT) { + if (t.value?.type === ParserToken.VALUE_TEXT) { rawVal = t.value.value; valueWasQuoted = t.value.quoted; - } else if (t.value && t.value.type === ParserToken.VALUE_TEXT_LIST) { + } else if (t.value?.type === ParserToken.VALUE_TEXT_LIST) { // Extract individual list items from the AST listValues = t.value.items .map(item => item.value?.value ?? '') diff --git a/static/app/stores/guideStore.tsx b/static/app/stores/guideStore.tsx index fc1928562cb3..4911a3fba0c6 100644 --- a/static/app/stores/guideStore.tsx +++ b/static/app/stores/guideStore.tsx @@ -235,7 +235,7 @@ const storeConfig: GuideStoreDefinition = { return; } - if (!prevGuide || prevGuide.guide !== nextGuide.guide) { + if (prevGuide?.guide !== nextGuide.guide) { this.recordCue(nextGuide.guide); this.state = {...this.state, prevGuide: nextGuide}; } diff --git a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx index b0543f23035d..b778469fec4b 100644 --- a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx +++ b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx @@ -62,7 +62,7 @@ async function extractDiffMutations({ }, onVisitFrame: (frame, collection, replayer) => { const mirror = replayer.getMirror(); - if (lastFrame && lastFrame.type === EventType.FullSnapshot) { + if (lastFrame?.type === EventType.FullSnapshot) { const node = mirror.getNode(lastFrame.data.node.id) as Document | null; const item = collection.get(lastFrame); if (node && item) { @@ -79,8 +79,7 @@ async function extractDiffMutations({ }; } } else if ( - lastFrame && - lastFrame.type === EventType.IncrementalSnapshot && + lastFrame?.type === EventType.IncrementalSnapshot && 'source' in lastFrame.data && lastFrame.data.source === IncrementalSource.Mutation ) { diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index 7e9bdbcaeb6f..bc601484cc9d 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -864,7 +864,7 @@ function findCanvasInMutation(event: incrementalSnapshotEvent) { } return event.data.adds.find( - add => add.node && add.node.type === 2 && add.node.tagName === 'canvas' + add => add.node?.type === 2 && add.node.tagName === 'canvas' ); } diff --git a/static/app/views/insights/database/components/tables/queriesTable.tsx b/static/app/views/insights/database/components/tables/queriesTable.tsx index 765b97a62750..de655ebed312 100644 --- a/static/app/views/insights/database/components/tables/queriesTable.tsx +++ b/static/app/views/insights/database/components/tables/queriesTable.tsx @@ -171,7 +171,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx b/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx index 33444265689e..d66f3355106e 100644 --- a/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx +++ b/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx @@ -177,7 +177,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/mobile/common/components/tables/screensTable.tsx b/static/app/views/insights/mobile/common/components/tables/screensTable.tsx index 632df623ea7f..3c71dbf926c8 100644 --- a/static/app/views/insights/mobile/common/components/tables/screensTable.tsx +++ b/static/app/views/insights/mobile/common/components/tables/screensTable.tsx @@ -77,7 +77,7 @@ export function ScreensTable({ column: GridColumn, row: TableDataRow ): React.ReactNode { - if (!data?.meta || !data?.meta.fields) { + if (!data?.meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx b/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx index f51c2dc3f189..4147100c30cc 100644 --- a/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx +++ b/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx @@ -70,7 +70,7 @@ export function EventSamplesTable({ const eventViewColumns = eventView.getColumns(); function renderBodyCell(column: any, row: any): React.ReactNode { - if (!data?.meta || !data?.meta.fields) { + if (!data?.meta?.fields) { return row[column.key]; } diff --git a/static/app/views/performance/eap/overviewSpansTable.tsx b/static/app/views/performance/eap/overviewSpansTable.tsx index 94db3235e325..b79f1d263812 100644 --- a/static/app/views/performance/eap/overviewSpansTable.tsx +++ b/static/app/views/performance/eap/overviewSpansTable.tsx @@ -200,7 +200,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/performance/eap/segmentSpansTable.tsx b/static/app/views/performance/eap/segmentSpansTable.tsx index 2afc650f11e1..2f12c3b65a92 100644 --- a/static/app/views/performance/eap/segmentSpansTable.tsx +++ b/static/app/views/performance/eap/segmentSpansTable.tsx @@ -228,7 +228,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/performance/newTraceDetails/traceConfigurations.tsx b/static/app/views/performance/newTraceDetails/traceConfigurations.tsx index b2ffe3ca9d89..6b45d3f4b678 100644 --- a/static/app/views/performance/newTraceDetails/traceConfigurations.tsx +++ b/static/app/views/performance/newTraceDetails/traceConfigurations.tsx @@ -37,7 +37,7 @@ function parsePlatform(platform: string): ParsedPlatform { export function getCustomInstrumentationLink(project: Project | undefined): string { // Default to JavaScript guide if project or platform is not available - if (!project || !project.platform) { + if (!project?.platform) { return `https://docs.sentry.io/platforms/javascript/tracing/instrumentation/custom-instrumentation/`; } diff --git a/static/gsAdmin/components/customers/customerOverview.tsx b/static/gsAdmin/components/customers/customerOverview.tsx index 3cd3e3c534e2..5d2aea8bdd74 100644 --- a/static/gsAdmin/components/customers/customerOverview.tsx +++ b/static/gsAdmin/components/customers/customerOverview.tsx @@ -315,10 +315,7 @@ function OnDemandSummary({customer}: OnDemandSummaryProps) { ) { const {onDemandBudgets} = customer; - if ( - onDemandBudgets && - onDemandBudgets.budgetMode === OnDemandBudgetMode.PER_CATEGORY - ) { + if (onDemandBudgets?.budgetMode === OnDemandBudgetMode.PER_CATEGORY) { return ( {onDemandPeriod} From 19ac7deabbbb89e5d9c5b5035d98eb44dfde80c9 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Thu, 2 Apr 2026 11:17:14 -0400 Subject: [PATCH 10/13] chore(seer-slack): Allow for a slack-staging provider (#111745) This PR re-implements the `slack` integration and all its capabilities as a `slack-staging` provider to allow for testing a staging application in production. Details for this [in this doc](https://www.notion.so/sentry/Supporting-a-Staging-Slack-App-32d8b10e4b5d8074a921c2b854e53cf0?source=copy_link). Currently tested: - Alerts - Personal notifications - Notification platform (new) - Installation/uninstallation - Webhooks, slash commands https://linear.app/getsentry/issue/ISWF-2299/prototype-the-separate-staging-provider-slack-app --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/conf/server.py | 1 + src/sentry/features/temporary.py | 4 +- src/sentry/identity/__init__.py | 3 +- src/sentry/identity/slack/provider.py | 16 +++ .../integrations/api/bases/external_actor.py | 1 + .../organization_config_integrations.py | 15 +-- src/sentry/integrations/base.py | 19 +++ src/sentry/integrations/pipeline.py | 12 +- src/sentry/integrations/slack/__init__.py | 2 + .../integrations/slack/handlers/__init__.py | 4 +- .../slack/handlers/slack_action_handler.py | 5 + .../integrations/slack/notifications.py | 34 +++++- .../integrations/slack/requests/base.py | 26 +++- .../integrations/slack/staging/__init__.py | 0 .../integrations/slack/staging/integration.py | 43 +++++++ .../slack/staging/notification.py | 9 ++ src/sentry/integrations/slack/staging/spec.py | 31 +++++ src/sentry/integrations/slack/staging/urls.py | 29 +++++ src/sentry/integrations/types.py | 6 + src/sentry/integrations/utils/identities.py | 2 +- .../integrations/classifications.py | 2 + .../integrations/parsers/__init__.py | 2 + .../integrations/parsers/slack_staging.py | 6 + .../additional_attachment_manager.py | 4 +- .../notification_action_request.py | 4 +- .../models/notificationaction.py | 3 + .../notification_action/__init__.py | 6 + .../notification_action/action_validation.py | 5 + .../issue_alert_registry/__init__.py | 6 +- .../handlers/slack_issue_alert_handler.py | 5 + .../metric_alert_registry/__init__.py | 6 +- .../handlers/slack_metric_alert_handler.py | 5 + .../notifications/notificationcontroller.py | 2 +- .../notifications/platform/slack/provider.py | 5 + src/sentry/notifications/platform/types.py | 1 + src/sentry/runner/commands/notifications.py | 113 +++++++++++++----- .../seer/entrypoints/slack/entrypoint.py | 2 - .../seer/entrypoints/slack/messaging.py | 2 - src/sentry/seer/entrypoints/slack/tasks.py | 10 +- src/sentry/web/urls.py | 4 + src/sentry/workflow_engine/models/action.py | 1 + .../typings/notification_action.py | 20 +++- static/app/plugins/components/pluginIcon.tsx | 1 + .../account/notifications/constants.tsx | 7 +- .../settings/account/notifications/fields.tsx | 1 + .../notificationSettingsByType.tsx | 29 +++-- .../settings/components/identityIcon.tsx | 1 + .../addIntegration.tsx | 4 +- .../organizationIntegrations/constants.tsx | 1 + .../integrationDetailedView.tsx | 38 ------ .../test_project_rules_configuration.py | 10 +- .../integrations/slack/staging/__init__.py | 0 .../slack/staging/test_feature_flag.py | 45 +++++++ .../test_organization_integration_setup.py | 11 ++ ...st_user_notification_settings_providers.py | 2 +- .../notifications/platform/test_registry.py | 6 +- tests/sentry/notifications/test_apps.py | 6 +- 57 files changed, 505 insertions(+), 133 deletions(-) create mode 100644 src/sentry/integrations/slack/staging/__init__.py create mode 100644 src/sentry/integrations/slack/staging/integration.py create mode 100644 src/sentry/integrations/slack/staging/notification.py create mode 100644 src/sentry/integrations/slack/staging/spec.py create mode 100644 src/sentry/integrations/slack/staging/urls.py create mode 100644 src/sentry/middleware/integrations/parsers/slack_staging.py create mode 100644 tests/sentry/integrations/slack/staging/__init__.py create mode 100644 tests/sentry/integrations/slack/staging/test_feature_flag.py diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index de5184194252..2f8c70b2005b 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2225,6 +2225,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "sentry.integrations.bitbucket.integration.BitbucketIntegrationProvider", "sentry.integrations.bitbucket_server.integration.BitbucketServerIntegrationProvider", "sentry.integrations.slack.SlackIntegrationProvider", + "sentry.integrations.slack.staging.integration.SlackStagingIntegrationProvider", "sentry.integrations.github.integration.GitHubIntegrationProvider", "sentry.integrations.github_enterprise.integration.GitHubEnterpriseIntegrationProvider", "sentry.integrations.gitlab.integration.GitlabIntegrationProvider", diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 2f86c0fa7366..6ac75f5ff115 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -130,6 +130,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:increased-issue-owners-rate-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Starfish: extract metrics from the spans manager.add("organizations:indexed-spans-extraction", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) + # These flags follow the pattern expected by IntegrationProvider.requires_feature_flag's usage on the config endpoint # Enable integration functionality to work deployment integrations like Vercel manager.add("organizations:integrations-deployment", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True) manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -137,6 +138,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Project Management Integrations Feature Parity Flags manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -311,8 +313,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable new compact issue alert UI in Slack manager.add("organizations:slack-compact-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable the Slack staging app - manager.add("organizations:slack-staging-app", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Explorer in Slack via @mentions manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable search query attribute validation diff --git a/src/sentry/identity/__init__.py b/src/sentry/identity/__init__.py index 4cf95538cfeb..193da131e7ba 100644 --- a/src/sentry/identity/__init__.py +++ b/src/sentry/identity/__init__.py @@ -16,7 +16,7 @@ def _register_providers() -> None: from .github_enterprise.provider import GitHubEnterpriseIdentityProvider from .gitlab.provider import GitlabIdentityProvider from .google.provider import GoogleIdentityProvider - from .slack.provider import SlackIdentityProvider + from .slack.provider import SlackIdentityProvider, SlackStagingIdentityProvider from .vercel.provider import VercelIdentityProvider from .vsts.provider import VSTSIdentityProvider, VSTSNewIdentityProvider from .vsts_extension.provider import VstsExtensionIdentityProvider @@ -24,6 +24,7 @@ def _register_providers() -> None: # TODO(epurkhiser): Should this be moved into it's own plugin, it should be # initialized there. register(SlackIdentityProvider) + register(SlackStagingIdentityProvider) register(GitHubIdentityProvider) register(GitHubEnterpriseIdentityProvider) register(VSTSNewIdentityProvider) diff --git a/src/sentry/identity/slack/provider.py b/src/sentry/identity/slack/provider.py index d8cd87778ca2..76cfe73fc371 100644 --- a/src/sentry/identity/slack/provider.py +++ b/src/sentry/identity/slack/provider.py @@ -71,6 +71,22 @@ def build_identity(self, data): } +class SlackStagingIdentityProvider(SlackIdentityProvider): + key = IntegrationProviderSlug.SLACK_STAGING.value + name = "Slack (Staging)" + + def get_oauth_client_id(self): + return options.get("slack-staging.client-id") + + def get_oauth_client_secret(self): + return options.get("slack-staging.client-secret") + + def build_identity(self, data): + production_identity = super().build_identity(data) + production_identity["type"] = IntegrationProviderSlug.SLACK_STAGING.value + return production_identity + + class SlackOAuth2LoginView(OAuth2LoginView): """ We need to customize the OAuth2LoginView in order to support passing through diff --git a/src/sentry/integrations/api/bases/external_actor.py b/src/sentry/integrations/api/bases/external_actor.py index ba32088a09fa..ee4197cd2733 100644 --- a/src/sentry/integrations/api/bases/external_actor.py +++ b/src/sentry/integrations/api/bases/external_actor.py @@ -32,6 +32,7 @@ ExternalProviders.GITHUB_ENTERPRISE, ExternalProviders.GITLAB, ExternalProviders.SLACK, + ExternalProviders.SLACK_STAGING, ExternalProviders.MSTEAMS, ExternalProviders.JIRA_SERVER, ExternalProviders.PERFORCE, diff --git a/src/sentry/integrations/api/endpoints/organization_config_integrations.py b/src/sentry/integrations/api/endpoints/organization_config_integrations.py index 7cdf2abc4ac2..4da55bd6d0f1 100644 --- a/src/sentry/integrations/api/endpoints/organization_config_integrations.py +++ b/src/sentry/integrations/api/endpoints/organization_config_integrations.py @@ -4,7 +4,6 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -18,6 +17,7 @@ IntegrationProviderResponse, IntegrationProviderSerializer, ) +from sentry.integrations.base import is_provider_enabled from sentry.integrations.manager import default_manager as integrations from sentry.models.organization import Organization @@ -54,21 +54,14 @@ def get(self, request: Request, organization: Organization) -> Response: Get integration provider information about all available integrations for an organization. """ - def is_provider_enabled(provider): - if not provider.requires_feature_flag: - return True - flag = ( - provider.feature_flag_name - or "organizations:integrations-%s" % provider.key.replace("_", "-") - ) - return features.has(flag, organization, actor=request.user) - providers = list(integrations.all()) provider_key = request.GET.get("provider_key") or request.GET.get("providerKey") if provider_key: providers = [p for p in providers if p.key == provider_key] - providers = list(filter(is_provider_enabled, providers)) + providers = list( + filter(lambda p: is_provider_enabled(p, organization, actor=request.user), providers) + ) providers.sort(key=lambda i: i.key) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 197f79b69ddf..1d3f4de6a278 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -46,11 +46,15 @@ from sentry.utils.audit import create_audit_entry if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser from django.utils.functional import _StrPromise from sentry.integrations.pipeline import IntegrationPipeline # noqa: F401 from sentry.integrations.services.integration import RpcOrganizationIntegration from sentry.integrations.services.integration.model import RpcIntegration + from sentry.models.organization import Organization + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser logger = logging.getLogger(__name__) @@ -586,3 +590,18 @@ def get_integration_types(provider: str) -> list[IntegrationDomain]: if provider in providers: types.append(integration_type) return types + + +def is_provider_enabled( + provider: IntegrationProvider, + organization: Organization | RpcOrganization, + actor: User | RpcUser | AnonymousUser | None = None, +) -> bool: + from sentry import features + + if not provider.requires_feature_flag: + return True + flag = provider.feature_flag_name or "organizations:integrations-%s" % provider.key.replace( + "_", "-" + ) + return features.has(flag, organization, actor=actor) diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index 7fd4cdc32b76..b53dcef29583 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -18,7 +18,12 @@ from sentry.auth.superuser import superuser_has_permission from sentry.constants import ObjectStatus from sentry.features.exceptions import FeatureNotRegistered -from sentry.integrations.base import IntegrationData, IntegrationDomain, IntegrationProvider +from sentry.integrations.base import ( + IntegrationData, + IntegrationDomain, + IntegrationProvider, + is_provider_enabled, +) from sentry.integrations.manager import default_manager from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration @@ -74,6 +79,11 @@ def initialize_integration_pipeline( assert isinstance(pipeline.provider, IntegrationProvider) + if not is_provider_enabled(pipeline.provider, organization): + raise IntegrationPipelineError( + "This integration is not available for your organization.", not_found=True + ) + is_feature_enabled: dict[str, bool] = {} for feature in pipeline.provider.features: feature_flag_name = "organizations:integrations-%s" % feature.value diff --git a/src/sentry/integrations/slack/__init__.py b/src/sentry/integrations/slack/__init__.py index 3ce06d897a57..74da44c00f3c 100644 --- a/src/sentry/integrations/slack/__init__.py +++ b/src/sentry/integrations/slack/__init__.py @@ -1,4 +1,5 @@ from sentry.integrations.slack.spec import SlackMessagingSpec +from sentry.integrations.slack.staging.spec import SlackStagingMessagingSpec from .actions.form import * # noqa: F401,F403 from .actions.notification import * # noqa: F401,F403 @@ -42,3 +43,4 @@ from .webhooks.event import * # noqa: F401,F403 SlackMessagingSpec().initialize() +SlackStagingMessagingSpec().initialize() diff --git a/src/sentry/integrations/slack/handlers/__init__.py b/src/sentry/integrations/slack/handlers/__init__.py index a227b3a76808..9ecdc9c689ce 100644 --- a/src/sentry/integrations/slack/handlers/__init__.py +++ b/src/sentry/integrations/slack/handlers/__init__.py @@ -1,3 +1,3 @@ -__all__ = ["SlackActionHandler"] +__all__ = ["SlackActionHandler", "SlackStagingActionHandler"] -from .slack_action_handler import SlackActionHandler +from .slack_action_handler import SlackActionHandler, SlackStagingActionHandler diff --git a/src/sentry/integrations/slack/handlers/slack_action_handler.py b/src/sentry/integrations/slack/handlers/slack_action_handler.py index 2e90d4a15d54..0b9f81301203 100644 --- a/src/sentry/integrations/slack/handlers/slack_action_handler.py +++ b/src/sentry/integrations/slack/handlers/slack_action_handler.py @@ -43,3 +43,8 @@ def execute(invocation: ActionInvocation) -> None: from sentry.notifications.notification_action.utils import execute_via_group_type_registry execute_via_group_type_registry(invocation) + + +@action_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingActionHandler(SlackActionHandler): + provider_slug = IntegrationProviderSlug.SLACK_STAGING diff --git a/src/sentry/integrations/slack/notifications.py b/src/sentry/integrations/slack/notifications.py index bb5397ecf859..894697d74ae7 100644 --- a/src/sentry/integrations/slack/notifications.py +++ b/src/sentry/integrations/slack/notifications.py @@ -18,12 +18,12 @@ logger = logging.getLogger("sentry.notifications") -@register_notification_provider(ExternalProviders.SLACK) -def send_notification_as_slack( +def _send_slack_notification( notification: BaseNotification, recipients: Iterable[Actor | User], shared_context: Mapping[str, Any], extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, + provider: ExternalProviders, ) -> None: """Send an "activity" or "alert rule" notification to a Slack user or team, but NOT to a channel directly. Sending Slack notifications to a channel is in integrations/slack/actions/notification.py""" @@ -31,7 +31,7 @@ def send_notification_as_slack( service = SlackService.default() with sentry_sdk.start_span(op="notification.send_slack", name="gen_channel_integration_map"): data = get_integrations_by_channel_by_recipient( - notification.organization, recipients, ExternalProviders.SLACK + notification.organization, recipients, provider ) for recipient, integrations_by_channel in data.items(): @@ -59,3 +59,31 @@ def send_notification_as_slack( instance=f"slack.{notification.metrics_key}.notification", skip_internal=False, ) + + +@register_notification_provider(ExternalProviders.SLACK) +def send_notification_as_slack( + notification: BaseNotification, + recipients: Iterable[Actor | User], + shared_context: Mapping[str, Any], + extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, +) -> None: + _send_slack_notification( + notification, recipients, shared_context, extra_context_by_actor, ExternalProviders.SLACK + ) + + +@register_notification_provider(ExternalProviders.SLACK_STAGING) +def send_notification_as_slack_staging( + notification: BaseNotification, + recipients: Iterable[Actor | User], + shared_context: Mapping[str, Any], + extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, +) -> None: + _send_slack_notification( + notification, + recipients, + shared_context, + extra_context_by_actor, + ExternalProviders.SLACK_STAGING, + ) diff --git a/src/sentry/integrations/slack/requests/base.py b/src/sentry/integrations/slack/requests/base.py index 4be0e1874c1f..e8267f76c0e6 100644 --- a/src/sentry/integrations/slack/requests/base.py +++ b/src/sentry/integrations/slack/requests/base.py @@ -70,6 +70,18 @@ def __init__(self, request: Request) -> None: self._user: RpcUser | None = None self._data: MutableMapping[str, Any] = {} + def _is_staging_request(self) -> bool: + path = self.request.path + return isinstance(path, str) and "/extensions/slack-staging/" in path + + @property + def _provider_slug(self) -> str: + return ( + IntegrationProviderSlug.SLACK_STAGING.value + if self._is_staging_request() + else IntegrationProviderSlug.SLACK.value + ) + def validate(self) -> None: """ Ensure everything is present to properly process this request @@ -100,7 +112,7 @@ def _get_context(self) -> None: except Exception: pass context = integration_service.get_integration_identity_context( - integration_provider=IntegrationProviderSlug.SLACK.value, + integration_provider=self._provider_slug, integration_external_id=team_id, identity_external_id=user_id, identity_provider_external_id=team_id, @@ -163,7 +175,7 @@ def get_identity(self) -> RpcIdentity | None: if self._provider is None: self._provider = identity_service.get_provider( - provider_type=IntegrationProviderSlug.SLACK.value, provider_ext_id=self.team_id + provider_type=self._provider_slug, provider_ext_id=self.team_id ) if self._provider is not None: @@ -197,8 +209,12 @@ def authorize(self) -> None: # XXX(meredith): Signing secrets are the preferred way # but self-hosted could still have an older slack bot # app that just has the verification token. - signing_secret = options.get("slack.signing-secret") - verification_token = options.get("slack.verification-token") + if self._is_staging_request(): + signing_secret = options.get("slack-staging.signing-secret") + verification_token = None + else: + signing_secret = options.get("slack.signing-secret") + verification_token = options.get("slack.verification-token") if signing_secret: if self._check_signing_secret(signing_secret): @@ -226,7 +242,7 @@ def _check_verification_token(self, verification_token: str) -> bool: def validate_integration(self) -> None: if not self._integration: self._integration = integration_service.get_integration( - provider=IntegrationProviderSlug.SLACK.value, + provider=self._provider_slug, external_id=self.team_id, status=ObjectStatus.ACTIVE, ) diff --git a/src/sentry/integrations/slack/staging/__init__.py b/src/sentry/integrations/slack/staging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/integrations/slack/staging/integration.py b/src/sentry/integrations/slack/staging/integration.py new file mode 100644 index 000000000000..37558e5addbd --- /dev/null +++ b/src/sentry/integrations/slack/staging/integration.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from sentry.identity.pipeline import IdentityPipeline +from sentry.integrations.base import ( + IntegrationData, +) +from sentry.integrations.pipeline import IntegrationPipeline +from sentry.integrations.slack.integration import SlackIntegrationProvider +from sentry.integrations.types import IntegrationProviderSlug +from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.views.nested import NestedPipelineView +from sentry.utils.http import absolute_uri + +_logger = logging.getLogger("sentry.integrations.slack") + + +class SlackStagingIntegrationProvider(SlackIntegrationProvider): + key = IntegrationProviderSlug.SLACK_STAGING.value + name = "Slack (Staging)" + requires_feature_flag = True + + def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]: + return NestedPipelineView( + bind_key="identity", + provider_key=IntegrationProviderSlug.SLACK_STAGING.value, + pipeline_cls=IdentityPipeline, + config={ + "oauth_scopes": self._get_oauth_scopes(), + "user_scopes": self.user_scopes, + "redirect_url": absolute_uri("/extensions/slack-staging/setup/"), + }, + ) + + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: + production_integration = super().build_integration(state=state) + production_integration["user_identity"]["type"] = ( + IntegrationProviderSlug.SLACK_STAGING.value + ) + return production_integration diff --git a/src/sentry/integrations/slack/staging/notification.py b/src/sentry/integrations/slack/staging/notification.py new file mode 100644 index 000000000000..1562db8354fb --- /dev/null +++ b/src/sentry/integrations/slack/staging/notification.py @@ -0,0 +1,9 @@ +from sentry.integrations.slack.actions.notification import SlackNotifyServiceAction +from sentry.integrations.types import IntegrationProviderSlug + + +class SlackStagingNotifyServiceAction(SlackNotifyServiceAction): + id = "sentry.integrations.slack.staging.notify_action.SlackStagingNotifyServiceAction" + prompt = "Send a Slack (Staging) notification" + provider = IntegrationProviderSlug.SLACK_STAGING.value + label = "Send a notification from the Staging app to the {workspace} Slack workspace to {channel} (optionally, an ID: {channel_id}) and show tags {tags} and notes {notes} in notification" diff --git a/src/sentry/integrations/slack/staging/spec.py b/src/sentry/integrations/slack/staging/spec.py new file mode 100644 index 000000000000..53e045990761 --- /dev/null +++ b/src/sentry/integrations/slack/staging/spec.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from sentry.integrations.base import IntegrationProvider +from sentry.integrations.slack.spec import SlackMessagingSpec +from sentry.integrations.types import IntegrationProviderSlug +from sentry.notifications.models.notificationaction import ActionService +from sentry.rules.actions import IntegrationEventAction + + +class SlackStagingMessagingSpec(SlackMessagingSpec): + @property + def provider_slug(self) -> str: + return IntegrationProviderSlug.SLACK_STAGING.value + + @property + def action_service(self) -> ActionService: + return ActionService.SLACK_STAGING + + @property + def integration_provider(self) -> type[IntegrationProvider]: + from sentry.integrations.slack.staging.integration import SlackStagingIntegrationProvider + + return SlackStagingIntegrationProvider + + @property + def notify_service_action(self) -> type[IntegrationEventAction] | None: + from sentry.integrations.slack.staging.notification import ( + SlackStagingNotifyServiceAction, + ) + + return SlackStagingNotifyServiceAction diff --git a/src/sentry/integrations/slack/staging/urls.py b/src/sentry/integrations/slack/staging/urls.py new file mode 100644 index 000000000000..4b458b27a402 --- /dev/null +++ b/src/sentry/integrations/slack/staging/urls.py @@ -0,0 +1,29 @@ +from django.urls import re_path + +from sentry.integrations.slack.webhooks.action import SlackActionEndpoint +from sentry.integrations.slack.webhooks.command import SlackCommandsEndpoint +from sentry.integrations.slack.webhooks.event import SlackEventEndpoint +from sentry.integrations.slack.webhooks.options_load import SlackOptionsLoadEndpoint + +urlpatterns = [ + re_path( + r"^action/$", + SlackActionEndpoint.as_view(), + name="sentry-integration-slack-staging-action", + ), + re_path( + r"^commands/$", + SlackCommandsEndpoint.as_view(), + name="sentry-integration-slack-staging-commands", + ), + re_path( + r"^event/$", + SlackEventEndpoint.as_view(), + name="sentry-integration-slack-staging-event", + ), + re_path( + r"^options-load/$", + SlackOptionsLoadEndpoint.as_view(), + name="sentry-integration-slack-staging-options-load", + ), +] diff --git a/src/sentry/integrations/types.py b/src/sentry/integrations/types.py index be119b0a36be..a9d168718091 100644 --- a/src/sentry/integrations/types.py +++ b/src/sentry/integrations/types.py @@ -11,6 +11,7 @@ class ExternalProviders(ValueEqualityEnum): EMAIL = 100 SLACK = 110 + SLACK_STAGING = 111 MSTEAMS = 120 PAGERDUTY = 130 DISCORD = 140 @@ -31,6 +32,7 @@ def name(self) -> str: class IntegrationProviderSlug(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" DISCORD = "discord" MSTEAMS = "msteams" JIRA = "jira" @@ -56,6 +58,7 @@ class ExternalProviderEnum(StrEnum): EMAIL = "email" CUSTOM = "custom_scm" SLACK = IntegrationProviderSlug.SLACK + SLACK_STAGING = IntegrationProviderSlug.SLACK_STAGING MSTEAMS = IntegrationProviderSlug.MSTEAMS PAGERDUTY = IntegrationProviderSlug.PAGERDUTY DISCORD = IntegrationProviderSlug.DISCORD @@ -70,6 +73,7 @@ class ExternalProviderEnum(StrEnum): EXTERNAL_PROVIDERS_REVERSE = { ExternalProviderEnum.EMAIL: ExternalProviders.EMAIL, ExternalProviderEnum.SLACK: ExternalProviders.SLACK, + ExternalProviderEnum.SLACK_STAGING: ExternalProviders.SLACK_STAGING, ExternalProviderEnum.MSTEAMS: ExternalProviders.MSTEAMS, ExternalProviderEnum.PAGERDUTY: ExternalProviders.PAGERDUTY, ExternalProviderEnum.DISCORD: ExternalProviders.DISCORD, @@ -86,6 +90,7 @@ class ExternalProviderEnum(StrEnum): EXTERNAL_PROVIDERS = { ExternalProviders.EMAIL: ExternalProviderEnum.EMAIL.value, ExternalProviders.SLACK: ExternalProviderEnum.SLACK.value, + ExternalProviders.SLACK_STAGING: ExternalProviderEnum.SLACK_STAGING.value, ExternalProviders.MSTEAMS: ExternalProviderEnum.MSTEAMS.value, ExternalProviders.PAGERDUTY: ExternalProviderEnum.PAGERDUTY.value, ExternalProviders.DISCORD: ExternalProviderEnum.DISCORD.value, @@ -101,6 +106,7 @@ class ExternalProviderEnum(StrEnum): PERSONAL_NOTIFICATION_PROVIDERS = [ ExternalProviderEnum.EMAIL.value, ExternalProviderEnum.SLACK.value, + ExternalProviderEnum.SLACK_STAGING.value, ExternalProviderEnum.MSTEAMS.value, ] diff --git a/src/sentry/integrations/utils/identities.py b/src/sentry/integrations/utils/identities.py index e42eb8e09434..a41d1dcadcf7 100644 --- a/src/sentry/integrations/utils/identities.py +++ b/src/sentry/integrations/utils/identities.py @@ -41,7 +41,7 @@ def get_identity_or_404( raise Http404 idp = IdentityProvider.objects.filter( - external_id=integration.external_id, type=EXTERNAL_PROVIDERS[provider] + external_id=integration.external_id, type=integration.provider ).first() logger_metadata["external_id"] = integration.external_id if idp is None: diff --git a/src/sentry/middleware/integrations/classifications.py b/src/sentry/middleware/integrations/classifications.py index 5200e0b492ec..67096eb0d994 100644 --- a/src/sentry/middleware/integrations/classifications.py +++ b/src/sentry/middleware/integrations/classifications.py @@ -73,6 +73,7 @@ def integration_parsers(self) -> Mapping[str, type[BaseRequestParser]]: JiraServerRequestParser, MsTeamsRequestParser, SlackRequestParser, + SlackStagingRequestParser, VercelRequestParser, VstsRequestParser, ) @@ -89,6 +90,7 @@ def integration_parsers(self) -> Mapping[str, type[BaseRequestParser]]: JiraServerRequestParser, MsTeamsRequestParser, SlackRequestParser, + SlackStagingRequestParser, VercelRequestParser, VstsRequestParser, ] diff --git a/src/sentry/middleware/integrations/parsers/__init__.py b/src/sentry/middleware/integrations/parsers/__init__.py index 3f86f633a3fc..3503207a9428 100644 --- a/src/sentry/middleware/integrations/parsers/__init__.py +++ b/src/sentry/middleware/integrations/parsers/__init__.py @@ -10,6 +10,7 @@ from .msteams import MsTeamsRequestParser from .plugin import PluginRequestParser from .slack import SlackRequestParser +from .slack_staging import SlackStagingRequestParser from .vercel import VercelRequestParser from .vsts import VstsRequestParser @@ -26,6 +27,7 @@ "MsTeamsRequestParser", "PluginRequestParser", "SlackRequestParser", + "SlackStagingRequestParser", "VercelRequestParser", "VstsRequestParser", ) diff --git a/src/sentry/middleware/integrations/parsers/slack_staging.py b/src/sentry/middleware/integrations/parsers/slack_staging.py new file mode 100644 index 000000000000..8732e52a5988 --- /dev/null +++ b/src/sentry/middleware/integrations/parsers/slack_staging.py @@ -0,0 +1,6 @@ +from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders +from sentry.middleware.integrations.parsers.slack import SlackRequestParser + + +class SlackStagingRequestParser(SlackRequestParser): + provider = EXTERNAL_PROVIDERS[ExternalProviders.SLACK_STAGING] diff --git a/src/sentry/notifications/additional_attachment_manager.py b/src/sentry/notifications/additional_attachment_manager.py index 26c5dba58967..f7e1f702a128 100644 --- a/src/sentry/notifications/additional_attachment_manager.py +++ b/src/sentry/notifications/additional_attachment_manager.py @@ -30,7 +30,9 @@ def get_additional_attachment( organization: Organization | RpcOrganization, ) -> list[SlackBlock] | None: # look up the generator by the provider but only accepting slack for now - provider = validate_provider(integration.provider, {ExternalProviders.SLACK}) + provider = validate_provider( + integration.provider, {ExternalProviders.SLACK, ExternalProviders.SLACK_STAGING} + ) attachment_generator = self.attachment_generators.get(provider) if attachment_generator is None: return None diff --git a/src/sentry/notifications/api/serializers/notification_action_request.py b/src/sentry/notifications/api/serializers/notification_action_request.py index 17c16b0b84f0..3a672d32c07b 100644 --- a/src/sentry/notifications/api/serializers/notification_action_request.py +++ b/src/sentry/notifications/api/serializers/notification_action_request.py @@ -28,6 +28,7 @@ def format_choices_text(choices: Sequence[tuple[int, str]]): INTEGRATION_SERVICES = { ActionService.PAGERDUTY.value, ActionService.SLACK.value, + ActionService.SLACK_STAGING.value, ActionService.MSTEAMS.value, ActionService.OPSGENIE.value, } @@ -208,7 +209,8 @@ def validate_slack_channel( NOTE: Reaches out to via slack integration to verify channel """ if ( - data["service_type"] != ActionService.SLACK.value + data["service_type"] + not in (ActionService.SLACK.value, ActionService.SLACK_STAGING.value) or data["target_type"] != ActionTarget.SPECIFIC.value ): return data diff --git a/src/sentry/notifications/models/notificationaction.py b/src/sentry/notifications/models/notificationaction.py index 0f52e7e06875..c54b87658274 100644 --- a/src/sentry/notifications/models/notificationaction.py +++ b/src/sentry/notifications/models/notificationaction.py @@ -52,12 +52,14 @@ class ActionService(FlexibleIntEnum): SENTRY_NOTIFICATION = 5 # Use personal notification platform (src/sentry/notifications) OPSGENIE = 6 DISCORD = 7 + SLACK_STAGING = 8 @classmethod def as_choices(cls) -> tuple[tuple[int, str], ...]: assert ExternalProviders.EMAIL.name is not None assert ExternalProviders.PAGERDUTY.name is not None assert ExternalProviders.SLACK.name is not None + assert ExternalProviders.SLACK_STAGING.name is not None assert ExternalProviders.MSTEAMS.name is not None assert ExternalProviders.OPSGENIE.name is not None assert ExternalProviders.DISCORD.name is not None @@ -65,6 +67,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: (cls.EMAIL.value, ExternalProviders.EMAIL.name), (cls.PAGERDUTY.value, ExternalProviders.PAGERDUTY.name), (cls.SLACK.value, ExternalProviders.SLACK.name), + (cls.SLACK_STAGING.value, ExternalProviders.SLACK_STAGING.name), (cls.MSTEAMS.value, ExternalProviders.MSTEAMS.name), (cls.SENTRY_APP.value, "sentry_app"), (cls.SENTRY_NOTIFICATION.value, "sentry_notification"), diff --git a/src/sentry/notifications/notification_action/__init__.py b/src/sentry/notifications/notification_action/__init__.py index e004c751ead4..a29df41e104a 100644 --- a/src/sentry/notifications/notification_action/__init__.py +++ b/src/sentry/notifications/notification_action/__init__.py @@ -13,6 +13,7 @@ "PagerDutyIssueAlertHandler", "PluginIssueAlertHandler", "SlackIssueAlertHandler", + "SlackStagingIssueAlertHandler", "WebhookIssueAlertHandler", "DiscordMetricAlertHandler", "MSTeamsMetricAlertHandler", @@ -20,12 +21,14 @@ "PagerDutyMetricAlertHandler", "SentryAppMetricAlertHandler", "SlackMetricAlertHandler", + "SlackStagingMetricAlertHandler", "EmailMetricAlertHandler", "PluginActionHandler", "WebhookActionHandler", "SentryAppActionHandler", "SendTestNotification", "SlackActionValidatorHandler", + "SlackStagingActionValidatorHandler", "MSTeamsActionValidatorHandler", "DiscordActionValidatorHandler", "JiraActionValidatorHandler", @@ -56,6 +59,7 @@ PagerdutyActionValidatorHandler, SentryAppActionValidatorHandler, SlackActionValidatorHandler, + SlackStagingActionValidatorHandler, WebhookActionValidatorHandler, ) from .group_type_notification_registry import IssueAlertRegistryHandler, MetricAlertRegistryHandler @@ -73,6 +77,7 @@ PagerDutyIssueAlertHandler, PluginIssueAlertHandler, SlackIssueAlertHandler, + SlackStagingIssueAlertHandler, WebhookIssueAlertHandler, ) from .metric_alert_registry import ( @@ -83,4 +88,5 @@ PagerDutyMetricAlertHandler, SentryAppMetricAlertHandler, SlackMetricAlertHandler, + SlackStagingMetricAlertHandler, ) diff --git a/src/sentry/notifications/notification_action/action_validation.py b/src/sentry/notifications/notification_action/action_validation.py index c7a362f19fa9..894fd040b54b 100644 --- a/src/sentry/notifications/notification_action/action_validation.py +++ b/src/sentry/notifications/notification_action/action_validation.py @@ -47,6 +47,11 @@ def update_action_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: return self.validated_data +@action_validator_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingActionValidatorHandler(SlackActionValidatorHandler): + provider = Action.Type.SLACK_STAGING + + @action_validator_registry.register(Action.Type.MSTEAMS) class MSTeamsActionValidatorHandler(BaseActionValidatorHandler): provider = Action.Type.MSTEAMS diff --git a/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py b/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py index 6686926cfcdc..bd720774ce78 100644 --- a/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py +++ b/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py @@ -12,6 +12,7 @@ "PluginIssueAlertHandler", "SentryAppIssueAlertHandler", "SlackIssueAlertHandler", + "SlackStagingIssueAlertHandler", "WebhookIssueAlertHandler", "PagerDutyIssueAlertHandler", ] @@ -28,5 +29,8 @@ from .handlers.pagerduty_issue_alert_handler import PagerDutyIssueAlertHandler from .handlers.plugin_issue_alert_handler import PluginIssueAlertHandler from .handlers.sentry_app_issue_alert_handler import SentryAppIssueAlertHandler -from .handlers.slack_issue_alert_handler import SlackIssueAlertHandler +from .handlers.slack_issue_alert_handler import ( + SlackIssueAlertHandler, + SlackStagingIssueAlertHandler, +) from .handlers.webhook_issue_alert_handler import WebhookIssueAlertHandler diff --git a/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py b/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py index 27440c8e6276..b305402155e7 100644 --- a/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py +++ b/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py @@ -49,3 +49,8 @@ def render_label(cls, organization_id: int, blob: dict[str, Any]) -> str: label += " in notification" return label + + +@issue_alert_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingIssueAlertHandler(SlackIssueAlertHandler): + pass diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py b/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py index 6d90a5c5ebf1..bb2d99ea6754 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py @@ -4,6 +4,7 @@ "MSTeamsMetricAlertHandler", "DiscordMetricAlertHandler", "SlackMetricAlertHandler", + "SlackStagingMetricAlertHandler", "SentryAppMetricAlertHandler", "EmailMetricAlertHandler", ] @@ -14,4 +15,7 @@ from .handlers.opsgenie_metric_alert_handler import OpsgenieMetricAlertHandler from .handlers.pagerduty_metric_alert_handler import PagerDutyMetricAlertHandler from .handlers.sentry_app_metric_alert_handler import SentryAppMetricAlertHandler -from .handlers.slack_metric_alert_handler import SlackMetricAlertHandler +from .handlers.slack_metric_alert_handler import ( + SlackMetricAlertHandler, + SlackStagingMetricAlertHandler, +) diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py index e322ce16f612..04cd547b9d76 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py @@ -69,3 +69,8 @@ def send_alert( incident_serialized_response=incident_serialized_response, detector_serialized_response=detector_serialized_response, ) + + +@metric_alert_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingMetricAlertHandler(SlackMetricAlertHandler): + pass diff --git a/src/sentry/notifications/notificationcontroller.py b/src/sentry/notifications/notificationcontroller.py index c25a69ce99c4..f11ed1aaba38 100644 --- a/src/sentry/notifications/notificationcontroller.py +++ b/src/sentry/notifications/notificationcontroller.py @@ -32,7 +32,7 @@ from sentry.users.services.user.model import RpcUser Recipient = Union[Actor, Team, RpcUser, User] -TEAM_NOTIFICATION_PROVIDERS = [ExternalProviderEnum.SLACK] +TEAM_NOTIFICATION_PROVIDERS = [ExternalProviderEnum.SLACK, ExternalProviderEnum.SLACK_STAGING] def sort_settings_by_scope(setting: NotificationSettingOption | NotificationSettingProvider) -> int: diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 8ae2d6418249..d51c7eee27cb 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -206,3 +206,8 @@ def _send_with_threading( return SendSuccessResult(provider_message_id=response.get("ts"), is_threaded=True) except IntegrationError as e: return integration_error_result(e, is_threaded=True) + + +@provider_registry.register(NotificationProviderKey.SLACK_STAGING) +class SlackStagingNotificationProvider(SlackNotificationProvider): + key = NotificationProviderKey.SLACK_STAGING diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index b3b6765173b4..78c717dbb676 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -114,6 +114,7 @@ class NotificationProviderKey(StrEnum): EMAIL = ExternalProviderEnum.EMAIL SLACK = ExternalProviderEnum.SLACK + SLACK_STAGING = ExternalProviderEnum.SLACK_STAGING MSTEAMS = ExternalProviderEnum.MSTEAMS DISCORD = ExternalProviderEnum.DISCORD diff --git a/src/sentry/runner/commands/notifications.py b/src/sentry/runner/commands/notifications.py index 0a57d03ffdeb..6254e49f6d5e 100644 --- a/src/sentry/runner/commands/notifications.py +++ b/src/sentry/runner/commands/notifications.py @@ -66,39 +66,24 @@ def send_email(source: str, email: str) -> None: click.echo(f"Example '{source}' email sent to {email}.") -@send_cmd.command("slack") -@click.option( - "-s", - "--source", - help="Registered template source (see `sentry notifications list registry`)", - default="error-alert-service", -) -@click.option("-o", "--organization_slug", help="Organization slug") -@click.option("-i", "--integration_name", help="Slack integration name", default=None) -@click.option("-c", "--channel_name", help="Slack channel name", default=None) -def send_slack( - source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +def _send_slack_notification( + source: str, + organization_slug: str, + integration_name: str | None, + channel_name: str | None, + provider_slug: str, + provider_key: Any, + provider_label: str, ) -> None: - """ - Send a Slack notification. - """ from sentry import options - from sentry.runner import configure - - configure() - from sentry.constants import ObjectStatus from sentry.integrations.models.integration import Integration from sentry.integrations.slack.utils.channel import get_channel_id - from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organizationmapping import OrganizationMapping from sentry.notifications.platform.registry import template_registry from sentry.notifications.platform.service import NotificationService from sentry.notifications.platform.target import IntegrationNotificationTarget - from sentry.notifications.platform.types import ( - NotificationProviderKey, - NotificationTargetResourceType, - ) + from sentry.notifications.platform.types import NotificationTargetResourceType try: organization_mapping = OrganizationMapping.objects.get(slug=organization_slug) @@ -109,7 +94,7 @@ def send_slack( integration_name = integration_name or options.get("slack.debug-workspace") if integration_name is None or integration_name == "": click.echo( - "\nThis command requires a slack integration name." + f"\nThis command requires a {provider_label} integration name." "\nProvide it with the `-i` flag or by setting `slack.debug-workspace` in .sentry/config.yml." f"\nBrowse the local integrations with `sentry notifications list integrations -o {organization_slug}`." ) @@ -117,12 +102,12 @@ def send_slack( try: integration = Integration.objects.get( - provider=IntegrationProviderSlug.SLACK.value, + provider=provider_slug, name=integration_name, status=ObjectStatus.ACTIVE, ) except Integration.DoesNotExist: - click.echo(f"Slack integration '{integration_name}' not found!") + click.echo(f"{provider_label} integration '{integration_name}' not found!") return channel_name = channel_name or options.get("slack.debug-channel") @@ -144,7 +129,7 @@ def send_slack( return slack_target = IntegrationNotificationTarget( - provider_key=NotificationProviderKey.SLACK, + provider_key=provider_key, resource_type=NotificationTargetResourceType.CHANNEL, integration_id=integration.id, resource_id=channel_data.channel_id, @@ -153,7 +138,77 @@ def send_slack( template_cls = template_registry.get(source) NotificationService(data=template_cls.example_data).notify_sync(targets=[slack_target]) - click.echo(f"Example '{source}' slack message sent to {integration.name}.") + click.echo(f"Example '{source}' {provider_label} message sent to {integration.name}.") + + +@send_cmd.command("slack") +@click.option( + "-s", + "--source", + help="Registered template source (see `sentry notifications list registry`)", + default="error-alert-service", +) +@click.option("-o", "--organization_slug", help="Organization slug") +@click.option("-i", "--integration_name", help="Slack integration name", default=None) +@click.option("-c", "--channel_name", help="Slack channel name", default=None) +def send_slack( + source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +) -> None: + """ + Send a Slack notification. + """ + + from sentry.runner import configure + + configure() + + from sentry.integrations.types import IntegrationProviderSlug + from sentry.notifications.platform.types import NotificationProviderKey + + _send_slack_notification( + source=source, + organization_slug=organization_slug, + integration_name=integration_name, + channel_name=channel_name, + provider_slug=IntegrationProviderSlug.SLACK.value, + provider_key=NotificationProviderKey.SLACK, + provider_label="Slack", + ) + + +@send_cmd.command("slack-staging") +@click.option( + "-s", + "--source", + help="Registered template source (see `sentry notifications list registry`)", + default="error-alert-service", +) +@click.option("-o", "--organization_slug", help="Organization slug") +@click.option("-i", "--integration_name", help="Slack (Staging) integration name", default=None) +@click.option("-c", "--channel_name", help="Slack channel name", default=None) +def send_slack_staging( + source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +) -> None: + """ + Send a Slack (Staging) notification. + """ + + from sentry.runner import configure + + configure() + + from sentry.integrations.types import IntegrationProviderSlug + from sentry.notifications.platform.types import NotificationProviderKey + + _send_slack_notification( + source=source, + organization_slug=organization_slug, + integration_name=integration_name, + channel_name=channel_name, + provider_slug=IntegrationProviderSlug.SLACK_STAGING.value, + provider_key=NotificationProviderKey.SLACK_STAGING, + provider_label="Slack (Staging)", + ) @send_cmd.command("msteams") diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index f51dabaa6746..1e84f2b031a0 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -349,12 +349,10 @@ def __init__( ): from sentry.integrations.services.integration import integration_service from sentry.integrations.slack.integration import SlackIntegration - from sentry.integrations.types import IntegrationProviderSlug integration = integration_service.get_integration( integration_id=integration_id, organization_id=organization_id, - provider=IntegrationProviderSlug.SLACK.value, status=ObjectStatus.ACTIVE, ) if not integration: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 83296b6785d4..5ed6c48d2c19 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -10,7 +10,6 @@ from sentry.constants import ObjectStatus from sentry.integrations.services.integration.service import integration_service -from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.platform.registry import provider_registry, template_registry from sentry.notifications.platform.service import ( NotificationService, @@ -125,7 +124,6 @@ def process_thread_update( integration = integration_service.get_integration( integration_id=integration_id, organization_id=organization_id, - provider=IntegrationProviderSlug.SLACK.value, status=ObjectStatus.ACTIVE, ) if not integration: diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index 9151568e9fc6..fd30fd0b2265 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -6,8 +6,8 @@ from taskbroker_client.retry import Retry from sentry.identity.services.identity import identity_service +from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.seer.entrypoints.metrics import ( @@ -98,7 +98,7 @@ def process_mention_for_slack( return user = _resolve_user( - integration_external_id=entrypoint.integration.external_id, + integration=entrypoint.integration, slack_user_id=slack_user_id, ) if not user: @@ -152,13 +152,13 @@ def process_mention_for_slack( def _resolve_user( *, - integration_external_id: str, + integration: RpcIntegration, slack_user_id: str, ) -> RpcUser | None: """Resolve the Sentry user from a Slack user ID via linked identity.""" provider = identity_service.get_provider( - provider_type=IntegrationProviderSlug.SLACK.value, - provider_ext_id=integration_external_id, + provider_type=integration.provider, + provider_ext_id=integration.external_id, ) if not provider: return None diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index cbc834ca9e56..3790497227bd 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -1297,6 +1297,10 @@ r"^slack/", include("sentry.integrations.slack.urls"), ), + re_path( + r"^slack-staging/", + include("sentry.integrations.slack.staging.urls"), + ), re_path( r"^github/", include("sentry.integrations.github.urls"), diff --git a/src/sentry/workflow_engine/models/action.py b/src/sentry/workflow_engine/models/action.py index 840121b25dba..df76a491d996 100644 --- a/src/sentry/workflow_engine/models/action.py +++ b/src/sentry/workflow_engine/models/action.py @@ -55,6 +55,7 @@ class Action(DefaultFieldsModel, JSONConfigBase): class Type(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" MSTEAMS = "msteams" DISCORD = "discord" diff --git a/src/sentry/workflow_engine/typings/notification_action.py b/src/sentry/workflow_engine/typings/notification_action.py index 2c488c40221a..69318dce88ff 100644 --- a/src/sentry/workflow_engine/typings/notification_action.py +++ b/src/sentry/workflow_engine/typings/notification_action.py @@ -41,6 +41,7 @@ class FallthroughChoiceType(Enum): class ActionType(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" MSTEAMS = "msteams" DISCORD = "discord" @@ -115,6 +116,12 @@ class ActionFieldMapping(TypedDict): target_identifier_key="channel_id", target_display_key="channel", ), + ActionType.SLACK_STAGING: ActionFieldMapping( + id="sentry.integrations.slack.staging.notify_action.SlackStagingNotifyServiceAction", + integration_id_key="workspace", + target_identifier_key="channel_id", + target_display_key="channel", + ), ActionType.DISCORD: ActionFieldMapping( id="sentry.integrations.discord.notify_action.DiscordNotifyServiceAction", integration_id_key="server", @@ -299,13 +306,13 @@ def action_type(self) -> ActionType: @property def required_fields(self) -> list[str]: return [ - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.INTEGRATION_ID_KEY.value ], - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.TARGET_IDENTIFIER_KEY.value ], - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.TARGET_DISPLAY_KEY.value ], ] @@ -319,6 +326,12 @@ def blob_type(self) -> type[DataBlob]: return SlackDataBlob +class SlackStagingActionTranslator(SlackActionTranslator): + @property + def action_type(self) -> ActionType: + return ActionType.SLACK_STAGING + + class DiscordActionTranslator(BaseActionTranslator): @property def action_type(self) -> ActionType: @@ -766,6 +779,7 @@ class EmailDataBlob(DataBlob): issue_alert_action_translator_mapping: dict[str, type[BaseActionTranslator]] = { ACTION_FIELD_MAPPINGS[ActionType.SLACK]["id"]: SlackActionTranslator, + ACTION_FIELD_MAPPINGS[ActionType.SLACK_STAGING]["id"]: SlackStagingActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.DISCORD]["id"]: DiscordActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.MSTEAMS]["id"]: MSTeamsActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.PAGERDUTY]["id"]: PagerDutyActionTranslator, diff --git a/static/app/plugins/components/pluginIcon.tsx b/static/app/plugins/components/pluginIcon.tsx index e18ea7faa60a..8facbad4276c 100644 --- a/static/app/plugins/components/pluginIcon.tsx +++ b/static/app/plugins/components/pluginIcon.tsx @@ -69,6 +69,7 @@ const PLUGIN_ICONS = { redmine, segment, slack, + slack_staging: slack, splunk, trello, twilio, diff --git a/static/app/views/settings/account/notifications/constants.tsx b/static/app/views/settings/account/notifications/constants.tsx index e91a4335ac1c..e143d852fce3 100644 --- a/static/app/views/settings/account/notifications/constants.tsx +++ b/static/app/views/settings/account/notifications/constants.tsx @@ -1,4 +1,9 @@ -export const SUPPORTED_PROVIDERS = ['email', 'slack', 'msteams'] as const; +export const SUPPORTED_PROVIDERS = [ + 'email', + 'slack', + 'slack_staging', + 'msteams', +] as const; export type SupportedProviders = (typeof SUPPORTED_PROVIDERS)[number]; type ProviderValue = 'always' | 'never'; diff --git a/static/app/views/settings/account/notifications/fields.tsx b/static/app/views/settings/account/notifications/fields.tsx index 6ea6d58b738b..4c4cc91838eb 100644 --- a/static/app/views/settings/account/notifications/fields.tsx +++ b/static/app/views/settings/account/notifications/fields.tsx @@ -159,6 +159,7 @@ export const NOTIFICATION_SETTING_FIELDS = { choices: [ ['email', t('Email')], ['slack', t('Slack')], + ['slack_staging', t('Slack (Staging)')], ['msteams', t('Microsoft Teams')], ], help: t('Where personal notifications will be sent.'), diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index bc4dc9fb7d52..6f5270697b63 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -46,6 +46,9 @@ const typeMappedChildren: Record = { quota: QUOTA_FIELDS.map(field => field.name), }; +// Ideally, we could just use SUPPORTED_PROVIDERS here, but 'msteams' is not widely tested. +const ALLOWED_PROVIDERS = new Set(SUPPORTED_PROVIDERS.filter(p => p.includes('slack'))); + const getQueryParams = (notificationType: string) => { // if we need multiple settings on this page // then omit the type so we can load all settings @@ -86,21 +89,22 @@ export function NotificationSettingsByType({notificationType}: Props) { ], {staleTime: 30_000} ); - const {data: identities = [], status: identitiesStatus} = useApiQuery( - [ - getApiUrl('/users/$userId/identities/', {path: {userId: 'me'}}), - {query: {provider: 'slack'}}, - ], + const {data: allIdentities = [], status: identitiesStatus} = useApiQuery( + [getApiUrl('/users/$userId/identities/', {path: {userId: 'me'}})], {staleTime: 30_000} ); - const {data: organizationIntegrations = [], status: organizationIntegrationStatus} = + const identities = allIdentities.filter(identity => + ALLOWED_PROVIDERS.has(identity?.identityProvider?.type as SupportedProviders) + ); + + const {data: allOrgIntegrations = [], status: organizationIntegrationStatus} = useApiQuery( - [ - getApiUrl('/users/$userId/organization-integrations/', {path: {userId: 'me'}}), - {query: {provider: 'slack'}}, - ], + [getApiUrl('/users/$userId/organization-integrations/', {path: {userId: 'me'}})], {staleTime: 30_000} ); + const organizationIntegrations = allOrgIntegrations.filter(orgIntegration => + ALLOWED_PROVIDERS.has(orgIntegration.provider.key as SupportedProviders) + ); const {data: defaultSettings, status: defaultSettingsStatus} = useApiQuery([getApiUrl('/notification-defaults/')], { staleTime: 30_000, @@ -353,6 +357,7 @@ export function NotificationSettingsByType({notificationType}: Props) { }); const unlinkedSlackOrgs = getUnlinkedOrgs('slack'); + const unlinkedSlackStagingOrgs = getUnlinkedOrgs('slack_staging'); let notificationDetails = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; if ( notificationType === 'quota' && @@ -509,6 +514,10 @@ export function NotificationSettingsByType({notificationType}: Props) { unlinkedSlackOrgs.length > 0 ? ( ) : null} + {(field.state.value ?? initialProviders).includes('slack_staging') && + unlinkedSlackStagingOrgs.length > 0 ? ( + + ) : null} ; diff --git a/static/app/views/settings/organizationIntegrations/addIntegration.tsx b/static/app/views/settings/organizationIntegrations/addIntegration.tsx index 0a24a42d47dd..7f632f732bfb 100644 --- a/static/app/views/settings/organizationIntegrations/addIntegration.tsx +++ b/static/app/views/settings/organizationIntegrations/addIntegration.tsx @@ -77,9 +77,7 @@ export class AddIntegration extends Component { organization, ...analyticsParams, }); - const name = modalParams?.use_staging - ? 'sentryAddStagingIntegration' - : 'sentryAddIntegration'; + const name = 'sentryAddIntegration'; const {url, width, height} = provider.setupDialog; const {left, top} = this.computeCenteredWindow(width, height); diff --git a/static/app/views/settings/organizationIntegrations/constants.tsx b/static/app/views/settings/organizationIntegrations/constants.tsx index 80d217b1046d..ae64e97fb6b6 100644 --- a/static/app/views/settings/organizationIntegrations/constants.tsx +++ b/static/app/views/settings/organizationIntegrations/constants.tsx @@ -13,6 +13,7 @@ export const PENDING_DELETION = 'Pending Deletion'; export const POPULARITY_WEIGHT: Record = { // First-party-integrations slack: 50, + slack_staging: 49, github: 20, jira: 15, bitbucket: 10, diff --git a/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx b/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx index a7fedeef2f7a..b324bb10f3af 100644 --- a/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx @@ -328,46 +328,8 @@ export default function IntegrationDetailedView() { return null; } - const showStagingButton = - integrationSlug === 'slack' && - organization.features.includes('slack-staging-app'); - return ( - {showStagingButton && ( - - { - trackIntegrationAnalytics('integrations.installation_start', { - view: 'integrations_directory_integration_detail', - integration: integrationSlug, - integration_type: integrationType, - already_installed: installationStatus !== 'Not Installed', - organization, - }); - }} - buttonProps={{ - ...buttonProps, - 'data-test-id': 'install-staging-button', - buttonText: t('Add %s to Staging', provider.metadata.noun), - }} - /> - - )} None: self.create_project(teams=[team], name="baz") response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 9 assert len(response.data["filters"]) == 10 @@ -135,7 +135,7 @@ def test_sentry_app_alertable_webhook(self) -> None: response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 13 + assert len(response.data["actions"]) == 14 assert { "id": "sentry.rules.actions.notify_event_service.NotifyEventServiceAction", "label": "Send a notification via {service}", @@ -165,7 +165,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer: Magic ) response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 13 + assert len(response.data["actions"]) == 14 assert { "id": SENTRY_APP_ALERT_ACTION, "service": sentry_app.slug, @@ -181,7 +181,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer: Magic def test_issue_type_and_category_filter_feature(self) -> None: response = self.get_success_response(self.organization.slug, self.project.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 9 assert len(response.data["filters"]) == 10 @@ -204,7 +204,7 @@ def test_issue_type_and_category_filter_feature(self) -> None: @with_feature("organizations:event-unique-user-frequency-condition-with-conditions") def test_issue_type_and_category_filter_feature_with_conditions(self) -> None: response = self.get_success_response(self.organization.slug, self.project.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 10 assert len(response.data["filters"]) == 10 diff --git a/tests/sentry/integrations/slack/staging/__init__.py b/tests/sentry/integrations/slack/staging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/integrations/slack/staging/test_feature_flag.py b/tests/sentry/integrations/slack/staging/test_feature_flag.py new file mode 100644 index 000000000000..969d1beb01c9 --- /dev/null +++ b/tests/sentry/integrations/slack/staging/test_feature_flag.py @@ -0,0 +1,45 @@ +from sentry.testutils.cases import APITestCase, TestCase +from sentry.testutils.silo import control_silo_test + + +class SlackStagingConfigVisibilityTest(APITestCase): + """Test that the slack-staging provider is only visible to orgs with the feature flag.""" + + endpoint = "sentry-api-0-organization-config-integrations" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def test_hidden_without_flag(self) -> None: + response = self.get_success_response(self.organization.slug) + provider_keys = [p["key"] for p in response.data["providers"]] + assert "slack_staging" not in provider_keys + + def test_visible_with_flag(self) -> None: + with self.feature("organizations:integrations-slack-staging"): + response = self.get_success_response(self.organization.slug) + provider_keys = [p["key"] for p in response.data["providers"]] + assert "slack_staging" in provider_keys + + +@control_silo_test +class SlackStagingSetupAccessTest(TestCase): + """Test that the slack-staging install flow is gated by the feature flag.""" + + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(name="test", owner=self.user) + self.login_as(self.user) + self.path = f"/organizations/{self.organization.slug}/integrations/slack_staging/setup/" + + def test_setup_blocked_without_flag(self) -> None: + resp = self.client.get(self.path) + assert resp.status_code == 404 + + def test_setup_allowed_with_flag(self) -> None: + with self.feature("organizations:integrations-slack-staging"): + resp = self.client.get(self.path) + # 302 to Slack OAuth is the expected behavior for a valid setup flow + assert resp.status_code == 302 + assert "slack.com/oauth" in resp["Location"] diff --git a/tests/sentry/integrations/web/test_organization_integration_setup.py b/tests/sentry/integrations/web/test_organization_integration_setup.py index 6f9bdbde7fae..d14a03cabe7c 100644 --- a/tests/sentry/integrations/web/test_organization_integration_setup.py +++ b/tests/sentry/integrations/web/test_organization_integration_setup.py @@ -84,3 +84,14 @@ def test_disallow_integration_with_all_features_disabled(self) -> None: b"At least one feature from this list has to be enabled in order to setup the integration" in resp.content ) + + def test_requires_feature_flag_provider_blocked_without_flag(self) -> None: + """Providers with requires_feature_flag=True return 404 without the flag.""" + self.path = f"/organizations/{self.organization.slug}/integrations/slack_staging/setup/" + resp = self.client.get(self.path) + assert resp.status_code == 404 + + def test_regular_provider_unaffected_by_requires_feature_flag_check(self) -> None: + """Providers without requires_feature_flag still work through the pipeline.""" + resp = self.client.get(self.path) + assert resp.status_code == 200 diff --git a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py index b97a8507ae3b..451d3ec41206 100644 --- a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py +++ b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py @@ -126,7 +126,7 @@ def test_simple(self) -> None: value=NotificationSettingsOptionEnum.ALWAYS.value, provider=ExternalProviderEnum.SLACK.value, ).exists() - assert len(response.data) == 3 + assert len(response.data) == 4 def test_invalid_scope_type(self) -> None: response = self.get_error_response( diff --git a/tests/sentry/notifications/platform/test_registry.py b/tests/sentry/notifications/platform/test_registry.py index ebf3cac5a9c7..ae4cdf6e88f9 100644 --- a/tests/sentry/notifications/platform/test_registry.py +++ b/tests/sentry/notifications/platform/test_registry.py @@ -2,7 +2,10 @@ from sentry.notifications.platform.email.provider import EmailNotificationProvider from sentry.notifications.platform.msteams.provider import MSTeamsNotificationProvider from sentry.notifications.platform.registry import provider_registry, template_registry -from sentry.notifications.platform.slack.provider import SlackNotificationProvider +from sentry.notifications.platform.slack.provider import ( + SlackNotificationProvider, + SlackStagingNotificationProvider, +) from sentry.testutils.cases import TestCase @@ -12,6 +15,7 @@ def test_get_all(self) -> None: expected_providers = [ EmailNotificationProvider, SlackNotificationProvider, + SlackStagingNotificationProvider, MSTeamsNotificationProvider, DiscordNotificationProvider, ] diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 347ad9919cde..578d88721373 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -15,16 +15,18 @@ def test_registers_legacy_providers(self) -> None: """ from sentry.notifications.notify import registry - assert len(registry) == 3 + assert len(registry) == 4 assert registry[ExternalProviders.EMAIL] is not None assert registry[ExternalProviders.SLACK] is not None + assert registry[ExternalProviders.SLACK_STAGING] is not None assert registry[ExternalProviders.MSTEAMS] is not None def test_registers_platform_providers(self) -> None: from sentry.notifications.platform.registry import provider_registry - assert len(provider_registry.registrations) == 4 + assert len(provider_registry.registrations) == 5 assert provider_registry.get(NotificationProviderKey.DISCORD) is not None assert provider_registry.get(NotificationProviderKey.EMAIL) is not None assert provider_registry.get(NotificationProviderKey.MSTEAMS) is not None assert provider_registry.get(NotificationProviderKey.SLACK) is not None + assert provider_registry.get(NotificationProviderKey.SLACK_STAGING) is not None From ad406219fb6a05b1e930311477d1f444f1d04a66 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Apr 2026 17:18:07 +0200 Subject: [PATCH 11/13] feat(preprod): Register feature flag and project option for snapshot PR comments (#112100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the infrastructure needed to gate snapshot PR comments: - Feature flag: `organizations:preprod-snapshot-pr-comments` (FlagPole, api_expose=True) - Project option: `sentry:preprod_snapshot_pr_comments_enabled` (default true) - Serializer field and PUT handler in project details endpoint so the toggle is read/writable via the API This is the first step toward EME-999 — adding PR comment support for snapshot comparisons. Follow-up PRs will add the task, template, frontend toggle, and trigger wiring. Refs EME-999 --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/api/serializers/models/project.py | 3 +++ src/sentry/core/endpoints/project_details.py | 10 ++++++++++ src/sentry/features/temporary.py | 2 ++ src/sentry/models/options/project_option.py | 1 + src/sentry/projectoptions/defaults.py | 3 +++ tests/sentry/core/endpoints/test_project_details.py | 7 +++++++ 6 files changed, 26 insertions(+) diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index 25b5cce08e9e..db0ad6d49ecd 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -1201,6 +1201,9 @@ def format_options(self, attrs: Mapping[str, Any]) -> dict[str, Any]: "sentry:preprod_distribution_pr_comments_enabled_by_customer": self.get_value_with_default( attrs, "sentry:preprod_distribution_pr_comments_enabled_by_customer" ), + "sentry:preprod_snapshot_pr_comments_enabled": self.get_value_with_default( + attrs, "sentry:preprod_snapshot_pr_comments_enabled" + ), } def get_value_with_default(self, attrs, key): diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index 48d9bfb77b6f..5b70d26b87dc 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -119,6 +119,7 @@ class ProjectMemberSerializer(serializers.Serializer): preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField( required=False, allow_null=True ) + preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True) preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True) preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True) @@ -167,6 +168,7 @@ class ProjectMemberSerializer(serializers.Serializer): "preprodSnapshotStatusChecksFailOnAdded", "preprodSnapshotStatusChecksFailOnRemoved", "preprodDistributionPrCommentsEnabledByCustomer", + "preprodSnapshotPrCommentsEnabled", ] ) class ProjectAdminSerializer(ProjectMemberSerializer): @@ -889,6 +891,14 @@ def put(self, request: Request, project) -> Response: changed_proj_settings[ "sentry:preprod_distribution_pr_comments_enabled_by_customer" ] = result["preprodDistributionPrCommentsEnabledByCustomer"] + if "preprodSnapshotPrCommentsEnabled" in result: + if project.update_option( + "sentry:preprod_snapshot_pr_comments_enabled", + result["preprodSnapshotPrCommentsEnabled"], + ): + changed_proj_settings["sentry:preprod_snapshot_pr_comments_enabled"] = result[ + "preprodSnapshotPrCommentsEnabled" + ] if "debugFilesRole" in result: if result["debugFilesRole"] is None: project.delete_option("sentry:debug_files_role") diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 6ac75f5ff115..865c7346a27f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -234,6 +234,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:preprod-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable preprod PR comments for build distribution manager.add("organizations:preprod-build-distribution-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable preprod PR comments for snapshots + manager.add("organizations:preprod-snapshot-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable enforcement of preprod size quota checks (when disabled, size quota checks always return True) manager.add("organizations:preprod-enforce-size-quota", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable enforcement of preprod distribution quota checks (when disabled, distribution quota checks always return True) diff --git a/src/sentry/models/options/project_option.py b/src/sentry/models/options/project_option.py index c5f3690a65b7..47bb0a9aef69 100644 --- a/src/sentry/models/options/project_option.py +++ b/src/sentry/models/options/project_option.py @@ -78,6 +78,7 @@ "sentry:preprod_snapshot_status_checks_fail_on_added", "sentry:preprod_snapshot_status_checks_fail_on_removed", "sentry:preprod_distribution_pr_comments_enabled_by_customer", + "sentry:preprod_snapshot_pr_comments_enabled", "sentry:scm_source_context_enabled", "quotas:spike-protection-disabled", "feedback:branding", diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 6a29b4add734..79098a2222d5 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -211,5 +211,8 @@ # Boolean to enable/disable build distribution PR comments for this project. register(key="sentry:preprod_distribution_pr_comments_enabled_by_customer", default=True) +# Boolean to enable/disable snapshot PR comments for this project. +register(key="sentry:preprod_snapshot_pr_comments_enabled", default=True) + # Whether to enable on-demand source context fetching from SCM integrations register(key="sentry:scm_source_context_enabled", default=False) diff --git a/tests/sentry/core/endpoints/test_project_details.py b/tests/sentry/core/endpoints/test_project_details.py index a583e095d941..c38dac58c9f8 100644 --- a/tests/sentry/core/endpoints/test_project_details.py +++ b/tests/sentry/core/endpoints/test_project_details.py @@ -810,6 +810,13 @@ def test_options(self) -> None: ], ) + def test_preprod_snapshot_pr_comments_option(self) -> None: + self.get_success_response( + self.org_slug, self.proj_slug, preprodSnapshotPrCommentsEnabled=False + ) + project = Project.objects.get(id=self.project.id) + assert project.get_option("sentry:preprod_snapshot_pr_comments_enabled") is False + def test_bookmarks(self) -> None: self.get_success_response(self.org_slug, self.proj_slug, isBookmarked="false") assert not ProjectBookmark.objects.filter( From cf3f63b4fa625c2b57df02303776ca6cdf3f1f63 Mon Sep 17 00:00:00 2001 From: matt-codecov <137832199+matt-codecov@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:29:06 -0700 Subject: [PATCH 12/13] feat(symbolicator): pass objectstore token to symbolicator (#112058) pass an objectstore token to symbolicator alongside minidump/applecrashreport attachment URLs NOTE: token expiry time is set in the global client and shared across usecases/sessions. the default value is 60s. --- pyproject.toml | 2 +- src/sentry/lang/native/symbolicator.py | 29 ++++++++++++++++++++++---- uv.lock | 6 +++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d323c498368..704559ec8e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "mmh3>=4.0.0", "msgspec>=0.19.0", "msgpack>=1.1.0", - "objectstore-client>=0.1.1", + "objectstore-client>=0.1.5", "openai>=1.3.5", "orjson>=3.10.10", "p4python>=2025.1.2767466", diff --git a/src/sentry/lang/native/symbolicator.py b/src/sentry/lang/native/symbolicator.py index 9e2d701837d5..5d788dd104b1 100644 --- a/src/sentry/lang/native/symbolicator.py +++ b/src/sentry/lang/native/symbolicator.py @@ -114,11 +114,21 @@ def __init__( self.project = project self.event_id = event_id - def _process(self, task_name: str, path: str, **kwargs): + def _process( + self, + task_name: str, + path: str, + kwargs_cb: Callable[[], dict[str, Any]] | None = None, + **kwargs: Any, + ) -> Any: """ This function will submit a symbolication task to a Symbolicator and handle polling it using the `SymbolicatorSession`. It will also correctly handle `TaskIdNotFound` and `ServiceUnavailable` errors. + + `kwargs_cb`, if provided, is called on every new task submission and its result + is merged over `kwargs`. Use this for values that must be fresh on each + (re)submission, such as expiring tokens. """ session = SymbolicatorSession( url=self.base_url, @@ -137,7 +147,8 @@ def _process(self, task_name: str, path: str, **kwargs): try: if not task_id: # We are submitting a new task to Symbolicator - json_response = session.create_task(path, **kwargs) + create_kwargs = {**kwargs, **(kwargs_cb() if kwargs_cb else {})} + json_response = session.create_task(path, **create_kwargs) else: # The task has already been submitted to Symbolicator and we are polling json_response = session.query_task(task_id) @@ -201,7 +212,12 @@ def process_minidump( "rewrite_first_module": rewrite_first_module, }, } - res = self._process("process_minidump", "symbolicate-any", json=json) + + def cb() -> dict[str, Any]: + json["symbolicate"]["storage_token"] = session.mint_token() + return {"json": json} + + res = self._process("process_minidump", "symbolicate-any", kwargs_cb=cb) return process_response(res) data = { @@ -233,7 +249,12 @@ def process_applecrashreport(self, platform: str, report: CachedAttachment): "storage_url": storage_url, }, } - res = self._process("process_applecrashreport", "symbolicate-any", json=json) + + def cb() -> dict[str, Any]: + json["symbolicate"]["storage_token"] = session.mint_token() + return {"json": json} + + res = self._process("process_applecrashreport", "symbolicate-any", kwargs_cb=cb) return process_response(res) data = { diff --git a/uv.lock b/uv.lock index cb70c3108333..d4c5e7b677ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1322,7 +1322,7 @@ wheels = [ [[package]] name = "objectstore-client" -version = "0.1.1" +version = "0.1.5" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -1332,7 +1332,7 @@ dependencies = [ { name = "zstandard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/objectstore_client-0.1.1-py3-none-any.whl", hash = "sha256:fb63e88b6db101440b45f951810a35df41ade599f90791a1068e4da4e8e10691" }, + { url = "https://pypi.devinfra.sentry.io/wheels/objectstore_client-0.1.5-py3-none-any.whl", hash = "sha256:19ffcef5e33070d418268067e424fcb8de0739bfd2d310bc2de95a6791845857" }, ] [[package]] @@ -2337,7 +2337,7 @@ requires-dist = [ { name = "mmh3", specifier = ">=4.0.0" }, { name = "msgpack", specifier = ">=1.1.0" }, { name = "msgspec", specifier = ">=0.19.0" }, - { name = "objectstore-client", specifier = ">=0.1.1" }, + { name = "objectstore-client", specifier = ">=0.1.5" }, { name = "openai", specifier = ">=1.3.5" }, { name = "orjson", specifier = ">=3.10.10" }, { name = "p4python", specifier = ">=2025.1.2767466" }, From 2bbf724c39ca2722b18f27bd7a29722b771fdae9 Mon Sep 17 00:00:00 2001 From: Alex Sohn <44201357+alexsohn1126@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:42:12 -0400 Subject: [PATCH 13/13] fix(seer-slack): use feature flag for extended slack scopes (#112102) Previously, we only used an option to decide whether the extended slack scopes should be used or not. This had a couple of problems: - No org-level control. If one org wants to try out extended scopes, all of Sentry would need to enable extended scopes which leads to: - Extra scope requested for the not-updated prod Slack app. If we request more scope than required in the production Slack app, then Slack will return an error, saying extra scope was requested. Now that we have a SlackStagingIntegrationProvider, we can assume we already have the appropriate scopes in the Staging app, and use the extended scopes for all Staging app installations. --- src/sentry/integrations/slack/integration.py | 6 +----- src/sentry/integrations/slack/staging/integration.py | 3 +++ src/sentry/options/defaults.py | 4 ---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 81a1106abc23..5a3adf7d141d 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -9,7 +9,6 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -from sentry import options from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.base import ( FeatureDescription, @@ -299,7 +298,7 @@ class SlackIntegrationProvider(IntegrationProvider): ] ) # Extended scopes that require Slack marketplace approval - # Gated by slack.extended-scopes-enabled option + # Used by SlackStagingIntegrationProvider extended_oauth_scopes = frozenset( [ SlackScope.REACTIONS_WRITE, @@ -319,10 +318,7 @@ class SlackIntegrationProvider(IntegrationProvider): def _get_oauth_scopes(self) -> frozenset[str]: """ Returns the OAuth scopes to request during installation. - Extended scopes are included when slack.extended-scopes-enabled is True. """ - if options.get("slack.extended-scopes-enabled"): - return self.identity_oauth_scopes | self.extended_oauth_scopes return self.identity_oauth_scopes setup_dialog_config = {"width": 600, "height": 900} diff --git a/src/sentry/integrations/slack/staging/integration.py b/src/sentry/integrations/slack/staging/integration.py index 37558e5addbd..a0914bd9827b 100644 --- a/src/sentry/integrations/slack/staging/integration.py +++ b/src/sentry/integrations/slack/staging/integration.py @@ -23,6 +23,9 @@ class SlackStagingIntegrationProvider(SlackIntegrationProvider): name = "Slack (Staging)" requires_feature_flag = True + def _get_oauth_scopes(self) -> frozenset[str]: + return self.identity_oauth_scopes | self.extended_oauth_scopes + def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]: return NestedPipelineView( bind_key="identity", diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 63979642b702..3e9788bdbb0e 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -687,10 +687,6 @@ register("slack.debug-channel", flags=FLAG_AUTOMATOR_MODIFIABLE) # Log unfurl payloads for debugging register("slack.log-unfurl-payload", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) -# When enabled, new Slack installations will request extended scopes -# (reactions:write, channels:history, groups:history, app_mentions:read) -# Existing installations must re-authorize to get these scopes. -register("slack.extended-scopes-enabled", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) # Slack Staging App register("slack-staging.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE)