diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index b508747d5eb408..e0a1031b0adfb9 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -142,8 +142,27 @@ def _get_pattern(self, raw_pattern: str) -> str: (\d{4}-?[01]\d-?[0-3]\d[\sT][0-2]\d:?[0-5]\d) # no seconds ) | - # Kitchen - ([1-9]\d?:\d{2}(:\d{2})?(?:\s?[aApP][Mm])?) + # Kitchen, 12-hr + ( + (? None: "notification_context": asdict(notification_context), "alert_context": asdict(alert_context), "metric_issue_context": asdict(metric_issue_context), - "open_period_context": asdict(open_period_context), + "open_period_context": open_period_context.dict(), "trigger_status": trigger_status, }, ) diff --git a/src/sentry/notifications/platform/service.py b/src/sentry/notifications/platform/service.py index 83eb55b4d955d1..5e52c7efea8a1c 100644 --- a/src/sentry/notifications/platform/service.py +++ b/src/sentry/notifications/platform/service.py @@ -49,6 +49,10 @@ class NotificationServiceError(Exception): pass +class NotificationRenderError(NotificationServiceError): + pass + + class NotificationService[T: NotificationData]: def __init__(self, *, data: T): self.data: Final[T] = data @@ -94,9 +98,15 @@ def notify_target( # Update the lifecycle with the notification category now that we know it event_lifecycle.notification_category = template.category - renderable = NotificationService.render_template( - data=self.data, template=template, provider=provider - ) + try: + renderable = NotificationService.render_template( + data=self.data, template=template, provider=provider + ) + except Exception as e: + lifecycle.record_failure(failure_reason=e, create_issue=True) + raise NotificationRenderError( + f"Failed to render notification for source={self.data.source}" + ) from e # Step 3: Resolve thread if threading requested thread_context: ThreadContext | None = None @@ -321,9 +331,15 @@ def notify_target_async( template_cls = template_registry.get(notification_data.source) template = template_cls() lifecycle_metric.notification_category = template.category - renderable = NotificationService.render_template( - data=notification_data, template=template, provider=provider - ) + try: + renderable = NotificationService.render_template( + data=notification_data, template=template, provider=provider + ) + except Exception as e: + lifecycle.record_failure(failure_reason=e, create_issue=True) + raise NotificationRenderError( + f"Failed to render notification for source={notification_data.source}" + ) from e # Step 4: Resolve thread if threading requested thread_context: ThreadContext | None = None diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 52ea7fe619745c..8ae2d64182494e 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NotRequired, TypedDict from slack_sdk.models.blocks import ( ActionsBlock, @@ -59,6 +59,7 @@ class SlackProviderThreadingContext(ProviderThreadingContext): class SlackRenderable(TypedDict): blocks: list[Block] text: str + color: NotRequired[str] class SlackRenderer(NotificationRenderer[SlackRenderable]): @@ -138,12 +139,17 @@ def get_renderer( from sentry.notifications.platform.slack.renderers.issue import ( IssueSlackRenderer, ) + from sentry.notifications.platform.slack.renderers.metric_alert import ( + SlackMetricAlertRenderer, + ) from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer if category == NotificationCategory.SEER: return SeerSlackRenderer if category == NotificationCategory.ISSUE: return IssueSlackRenderer + if category == NotificationCategory.METRIC_ALERT: + return SlackMetricAlertRenderer return cls.default_renderer @classmethod diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py new file mode 100644 index 00000000000000..f03196b1c2cd9c --- /dev/null +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import sentry_sdk + +from sentry import features +from sentry.incidents.charts import build_metric_alert_chart +from sentry.incidents.typings.metric_detector import MetricIssueContext +from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( + get_alert_rule_serializer, + get_detector_serializer, +) +from sentry.notifications.notification_action.types import BaseMetricAlertHandler +from sentry.notifications.platform.renderer import NotificationRenderer +from sentry.notifications.platform.slack.provider import SlackRenderable +from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, + BaseMetricAlertNotificationData, + MetricAlertNotificationData, +) +from sentry.notifications.platform.types import ( + NotificationData, + NotificationProviderKey, + NotificationRenderedTemplate, +) +from sentry.services import eventstore +from sentry.services.eventstore.models import GroupEvent +from sentry.workflow_engine.models.detector import Detector + + +def _build_metric_issue_context_from_group_event( + data: MetricAlertNotificationData, +) -> MetricIssueContext: + event = eventstore.backend.get_event_by_id( + data.project_id, data.event_id, group_id=data.group_id + ) + if event is None: + raise ValueError(f"Event {data.event_id} not found") + elif not isinstance(event, GroupEvent): + raise ValueError(f"Event {data.event_id} is not a GroupEvent") + + evidence_data, priority = BaseMetricAlertHandler._extract_from_group_event(event) + return MetricIssueContext.from_group_event(event.group, evidence_data, priority) + + +def _build_metric_issue_context_from_activity( + data: ActivityMetricAlertNotificationData, +) -> MetricIssueContext: + from sentry.models.activity import Activity + + activity = Activity.objects.get(id=data.activity_id) + group = Group.objects.get_from_cache(id=data.group_id) + evidence_data, priority = BaseMetricAlertHandler._extract_from_activity(activity) + return MetricIssueContext.from_group_event(group, evidence_data, priority) + + +class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): + provider_key = NotificationProviderKey.SLACK + + @classmethod + def render[DataT: NotificationData]( + cls, *, data: DataT, rendered_template: NotificationRenderedTemplate + ) -> SlackRenderable: + if not isinstance(data, BaseMetricAlertNotificationData): + raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") + + if isinstance(data, MetricAlertNotificationData): + metric_issue_context = _build_metric_issue_context_from_group_event(data) + elif isinstance(data, ActivityMetricAlertNotificationData): + metric_issue_context = _build_metric_issue_context_from_activity(data) + + organization = Organization.objects.get_from_cache(id=data.organization_id) + detector = Detector.objects.get(id=data.detector_id) + alert_context = data.alert_context.to_alert_context() + open_period_context = data.open_period_context + + chart_url = None + if features.has("organizations:metric-alert-chartcuterie", organization): + try: + chart_url = build_metric_alert_chart( + organization=organization, + alert_rule_serialized_response=get_alert_rule_serializer(detector), + snuba_query=metric_issue_context.snuba_query, + alert_context=alert_context, + open_period_context=open_period_context, + subscription=metric_issue_context.subscription, + detector_serialized_response=get_detector_serializer(detector), + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + slack_body = SlackIncidentsMessageBuilder( + alert_context=alert_context, + metric_issue_context=metric_issue_context, + organization=organization, + date_started=open_period_context.date_started, + chart_url=chart_url, + notification_uuid=data.notification_uuid, + ).build() + + renderable = SlackRenderable( + blocks=slack_body.get("blocks", []), + text=slack_body.get("text", ""), + ) + if (color := slack_body.get("color")) is not None: + renderable["color"] = color + + return renderable diff --git a/src/sentry/notifications/platform/templates/__init__.py b/src/sentry/notifications/platform/templates/__init__.py index 22bdbff3a06c79..6ec2b6c884468b 100644 --- a/src/sentry/notifications/platform/templates/__init__.py +++ b/src/sentry/notifications/platform/templates/__init__.py @@ -1,10 +1,13 @@ from .data_export import DataExportFailureTemplate, DataExportSuccessTemplate from .issue import IssueNotificationTemplate +from .metric_alert import ActivityMetricAlertNotificationTemplate, MetricAlertNotificationTemplate __all__ = ( "DataExportSuccessTemplate", "DataExportFailureTemplate", "IssueNotificationTemplate", + "MetricAlertNotificationTemplate", + "ActivityMetricAlertNotificationTemplate", ) # All templates should be imported here so they are registered in the notifications Django app. # See sentry/notifications/apps.py diff --git a/src/sentry/notifications/platform/templates/metric_alert.py b/src/sentry/notifications/platform/templates/metric_alert.py new file mode 100644 index 00000000000000..f313cf05dcfb04 --- /dev/null +++ b/src/sentry/notifications/platform/templates/metric_alert.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Self + +from pydantic import BaseModel, ConfigDict + +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.notifications.platform.registry import template_registry +from sentry.notifications.platform.types import ( + NotificationCategory, + NotificationData, + NotificationRenderedTemplate, + NotificationSource, + NotificationTemplate, +) +from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType + + +class SerializableAlertContext(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + action_identifier_id: int + threshold_type: int | None = None # AlertRuleThresholdType or AnomalyDetectionThresholdType + detection_type: str # AlertRuleDetectionType value (TextChoices str) + comparison_delta: int | None = None + sensitivity: str | None = None + resolve_threshold: float | None = None + alert_threshold: float | None = None + + @classmethod + def from_alert_context(cls, ac: AlertContext) -> Self: + return cls( + name=ac.name, + action_identifier_id=ac.action_identifier_id, + threshold_type=int(ac.threshold_type.value) if ac.threshold_type is not None else None, + detection_type=ac.detection_type.value, + comparison_delta=ac.comparison_delta, + sensitivity=ac.sensitivity, + resolve_threshold=ac.resolve_threshold, + alert_threshold=ac.alert_threshold, + ) + + def to_alert_context(self) -> AlertContext: + from sentry.incidents.models.alert_rule import ( + AlertRuleDetectionType, + AlertRuleThresholdType, + ) + + detection_type = AlertRuleDetectionType(self.detection_type) + + threshold_type: AlertRuleThresholdType | AnomalyDetectionThresholdType | None = None + if self.threshold_type is not None: + if detection_type == AlertRuleDetectionType.DYNAMIC: + threshold_type = AnomalyDetectionThresholdType(self.threshold_type) + else: + threshold_type = AlertRuleThresholdType(self.threshold_type) + + return AlertContext( + name=self.name, + action_identifier_id=self.action_identifier_id, + threshold_type=threshold_type, + detection_type=detection_type, + comparison_delta=self.comparison_delta, + sensitivity=self.sensitivity, + resolve_threshold=self.resolve_threshold, + alert_threshold=self.alert_threshold, + ) + + +class BaseMetricAlertNotificationData(NotificationData): + group_id: int + organization_id: int + detector_id: int + + alert_context: SerializableAlertContext + open_period_context: OpenPeriodContext + + notification_uuid: str + + +class MetricAlertNotificationData(BaseMetricAlertNotificationData): + """GroupEvent / firing path. Renderer re-fetches GroupEvent from Snuba.""" + + source: NotificationSource = NotificationSource.METRIC_ALERT + + event_id: str + project_id: int + + +class ActivityMetricAlertNotificationData(BaseMetricAlertNotificationData): + """Activity / SET_RESOLVED path. Renderer re-fetches Activity from Postgres.""" + + source: NotificationSource = NotificationSource.ACTIVITY_METRIC_ALERT + + activity_id: int + + +_EXAMPLE_ALERT_CONTEXT = SerializableAlertContext( + name="Example Alert", + action_identifier_id=1, + detection_type="static", +) +_EXAMPLE_OPEN_PERIOD_CONTEXT = OpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, 0, 0, 0), +) + + +@template_registry.register(NotificationSource.METRIC_ALERT) +class MetricAlertNotificationTemplate(NotificationTemplate[MetricAlertNotificationData]): + category = NotificationCategory.METRIC_ALERT + hide_from_debugger = True + example_data = MetricAlertNotificationData( + event_id="abc123", + project_id=1, + group_id=1, + organization_id=1, + detector_id=1, + alert_context=_EXAMPLE_ALERT_CONTEXT, + open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, + notification_uuid="test-uuid", + ) + + def render(self, data: MetricAlertNotificationData) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + +@template_registry.register(NotificationSource.ACTIVITY_METRIC_ALERT) +class ActivityMetricAlertNotificationTemplate( + NotificationTemplate[ActivityMetricAlertNotificationData] +): + category = NotificationCategory.METRIC_ALERT + hide_from_debugger = True + example_data = ActivityMetricAlertNotificationData( + group_id=1, + organization_id=1, + detector_id=1, + alert_context=_EXAMPLE_ALERT_CONTEXT, + open_period_context=_EXAMPLE_OPEN_PERIOD_CONTEXT, + notification_uuid="test-uuid", + activity_id=1, + ) + + def render(self, data: ActivityMetricAlertNotificationData) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate(subject="Metric Alert", body=[]) diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index 3ba8f79db764ef..b3b6765173b418 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -24,6 +24,7 @@ class NotificationCategory(StrEnum): REPOSITORY = "repository" SEER = "seer" ISSUE = "issue" + METRIC_ALERT = "metric-alert" def get_sources(self) -> list[NotificationSource]: return NOTIFICATION_SOURCE_MAP[self] @@ -55,6 +56,10 @@ class NotificationSource(StrEnum): # ISSUE_ALERT ISSUE = "issue" + # METRIC_ALERT + METRIC_ALERT = "metric-alert" + ACTIVITY_METRIC_ALERT = "activity-metric-alert" + # SEER SEER_AUTOFIX_ERROR = "seer-autofix-error" SEER_AUTOFIX_UPDATE = "seer-autofix-update" @@ -87,6 +92,10 @@ class NotificationSource(StrEnum): NotificationCategory.ISSUE: [ NotificationSource.ISSUE, ], + NotificationCategory.METRIC_ALERT: [ + NotificationSource.METRIC_ALERT, + NotificationSource.ACTIVITY_METRIC_ALERT, + ], NotificationCategory.SEER: [ NotificationSource.SEER_AUTOFIX_TRIGGER, NotificationSource.SEER_AUTOFIX_ERROR, diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index cd682cc376277d..84b6df842f4e07 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -116,7 +116,7 @@ def _send_step_webhook( Determines which step just completed and sends the appropriate webhook event. """ - current_step = cls._get_current_step(state) + current_step, current_referrer = cls._get_current_step(state) webhook_payload = { "run_id": run_id, @@ -221,6 +221,12 @@ def _send_step_webhook( }, ) + if current_step is not None: + referrer = current_referrer.value if current_referrer is not None else None + metrics.incr( + "autofix.explorer.complete", tags={"step": current_step.value, "referrer": referrer} + ) + @classmethod def _maybe_trigger_supergroups_embedding( cls, @@ -230,7 +236,7 @@ def _maybe_trigger_supergroups_embedding( group: Group, ) -> None: """Trigger supergroups embedding if feature flag is enabled.""" - current_step = cls._get_current_step(state) + current_step, _ = cls._get_current_step(state) if current_step != AutofixStep.ROOT_CAUSE: return @@ -259,20 +265,33 @@ def _maybe_trigger_supergroups_embedding( ) @classmethod - def _get_current_step(cls, state: SeerRunState) -> AutofixStep | None: + def _get_current_step( + cls, state: SeerRunState + ) -> tuple[AutofixStep, AutofixReferrer | None] | tuple[None, None]: """Determine which step just completed.""" for block in reversed(state.blocks): message = block.message if message.metadata is not None: + referrer = message.metadata.get("referrer") + if referrer is not None: + try: + autofix_referrer = AutofixReferrer(referrer) + except ValueError: + autofix_referrer = None + else: + autofix_referrer = None + # find the first message with a valid step metadata step = message.metadata.get("step") if step is not None: try: - return AutofixStep(step) + autofix_step = AutofixStep(step) except ValueError: continue - return None + return autofix_step, autofix_referrer + + return None, None @classmethod def _get_next_step(cls, current_step: AutofixStep) -> AutofixStep | None: @@ -301,7 +320,7 @@ def _maybe_continue_pipeline( run_id: The run ID state: The current run state """ - current_step = cls._get_current_step(state) + current_step, _ = cls._get_current_step(state) # Get pipeline metadata from state metadata = state.metadata diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 70aea677f65bd4..a3768d08a0921d 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -65,6 +65,8 @@ export interface Organization extends OrganizationSummary { dataScrubberDefaults: boolean; debugFilesRole: string; defaultCodeReviewTriggers: CodeReviewTrigger[]; + defaultCodingAgent: string | null | undefined; + defaultCodingAgentIntegrationId: number | null | undefined; defaultRole: string; enhancedPrivacy: boolean; eventsMemberAdmin: boolean; diff --git a/static/app/utils/analytics/onboardingAnalyticsEvents.tsx b/static/app/utils/analytics/onboardingAnalyticsEvents.tsx index 1fb62636353b14..65ba4626bc59dc 100644 --- a/static/app/utils/analytics/onboardingAnalyticsEvents.tsx +++ b/static/app/utils/analytics/onboardingAnalyticsEvents.tsx @@ -55,6 +55,21 @@ export type OnboardingEventParameters = { platform: string; source: 'detected' | 'manual'; }; + 'onboarding.scm_project_details_alert_selected': { + option: string; + }; + 'onboarding.scm_project_details_create_clicked': Record; + 'onboarding.scm_project_details_create_failed': Record; + 'onboarding.scm_project_details_create_succeeded': { + project_slug: string; + }; + 'onboarding.scm_project_details_name_edited': { + custom: boolean; + }; + 'onboarding.scm_project_details_step_viewed': Record; + 'onboarding.scm_project_details_team_selected': { + team: string; + }; 'onboarding.select_framework_modal_close_button_clicked': { platform: string; }; @@ -125,4 +140,18 @@ export const onboardingEventMap: Record 'onboarding.scm_platform_features_step_viewed': 'Onboarding: SCM Platform Features Step Viewed', 'onboarding.scm_platform_selected': 'Onboarding: SCM Platform Selected', + 'onboarding.scm_project_details_alert_selected': + 'Onboarding: SCM Project Details Alert Selected', + 'onboarding.scm_project_details_create_clicked': + 'Onboarding: SCM Project Details Create Clicked', + 'onboarding.scm_project_details_create_failed': + 'Onboarding: SCM Project Details Create Failed', + 'onboarding.scm_project_details_create_succeeded': + 'Onboarding: SCM Project Details Create Succeeded', + 'onboarding.scm_project_details_name_edited': + 'Onboarding: SCM Project Details Name Edited', + 'onboarding.scm_project_details_step_viewed': + 'Onboarding: SCM Project Details Step Viewed', + 'onboarding.scm_project_details_team_selected': + 'Onboarding: SCM Project Details Team Selected', }; diff --git a/static/app/views/onboarding/components/scmAlertFrequency.tsx b/static/app/views/onboarding/components/scmAlertFrequency.tsx new file mode 100644 index 00000000000000..3df2a39ec3e154 --- /dev/null +++ b/static/app/views/onboarding/components/scmAlertFrequency.tsx @@ -0,0 +1,108 @@ +import {Input} from '@sentry/scraps/input'; +import {Container, Grid, Stack} from '@sentry/scraps/layout'; +import {Select} from '@sentry/scraps/select'; +import {Text} from '@sentry/scraps/text'; + +import {IconClock, IconFix, IconWarning} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {ScmAlertOptionCard} from 'sentry/views/onboarding/components/scmAlertOptionCard'; +import { + type AlertRuleOptions, + INTERVAL_CHOICES, + METRIC_CHOICES, + RuleAction, +} from 'sentry/views/projectInstall/issueAlertOptions'; + +interface ScmAlertFrequencyProps extends Partial { + onFieldChange: ( + key: K, + value: AlertRuleOptions[K] + ) => void; +} + +export function ScmAlertFrequency({ + alertSetting = RuleAction.DEFAULT_ALERT, + interval = '1m', + metric = 0, + threshold = '10', + onFieldChange, +}: ScmAlertFrequencyProps) { + const isDefaultSelected = alertSetting === RuleAction.DEFAULT_ALERT; + const isCustomSelected = alertSetting === RuleAction.CUSTOMIZED_ALERTS; + const isLaterSelected = alertSetting === RuleAction.CREATE_ALERT_LATER; + + return ( + + + } + isSelected={isDefaultSelected} + onSelect={() => onFieldChange('alertSetting', RuleAction.DEFAULT_ALERT)} + /> + + } + isSelected={isCustomSelected} + onSelect={() => onFieldChange('alertSetting', RuleAction.CUSTOMIZED_ALERTS)} + > + + + + + + {t('When there are more than')} + + + + onFieldChange('threshold', e.target.value)} + disabled={!isCustomSelected} + /> + onFieldChange('interval', option.value)} + disabled={!isCustomSelected} + /> + + + + + + } + isSelected={isLaterSelected} + onSelect={() => onFieldChange('alertSetting', RuleAction.CREATE_ALERT_LATER)} + /> + + ); +} diff --git a/static/app/views/onboarding/components/scmAlertOptionCard.tsx b/static/app/views/onboarding/components/scmAlertOptionCard.tsx new file mode 100644 index 00000000000000..19ed5dd4bbc47c --- /dev/null +++ b/static/app/views/onboarding/components/scmAlertOptionCard.tsx @@ -0,0 +1,39 @@ +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Radio} from '@sentry/scraps/radio'; +import {Text} from '@sentry/scraps/text'; + +import {ScmCardButton} from 'sentry/views/onboarding/components/scmCardButton'; +import {ScmSelectableContainer} from 'sentry/views/onboarding/components/scmSelectableContainer'; + +interface ScmAlertOptionCardProps { + icon: React.ReactNode; + isSelected: boolean; + label: string; + onSelect: () => void; + children?: React.ReactNode; +} + +export function ScmAlertOptionCard({ + label, + icon, + isSelected, + onSelect, + children, +}: ScmAlertOptionCardProps) { + return ( + + + + + + + {label} + + {icon} + + + + {children} + + ); +} diff --git a/static/app/views/onboarding/components/scmFeatureCard.tsx b/static/app/views/onboarding/components/scmFeatureCard.tsx index 6bb07db94f64e1..45806c7cf881d1 100644 --- a/static/app/views/onboarding/components/scmFeatureCard.tsx +++ b/static/app/views/onboarding/components/scmFeatureCard.tsx @@ -8,6 +8,7 @@ import {Tooltip} from '@sentry/scraps/tooltip'; import type {SVGIconProps} from 'sentry/icons/svgIcon'; import {ScmCardButton} from './scmCardButton'; +import {ScmSelectableContainer} from './scmSelectableContainer'; interface ScmFeatureCardProps { description: string; @@ -42,12 +43,11 @@ export function ScmFeatureCard({ disabled={disabled} style={{width: '100%', height: '100%'}} > - - + ); diff --git a/static/app/views/onboarding/components/scmPlatformCard.tsx b/static/app/views/onboarding/components/scmPlatformCard.tsx index 4760b925d15f32..4ce3f8435b2128 100644 --- a/static/app/views/onboarding/components/scmPlatformCard.tsx +++ b/static/app/views/onboarding/components/scmPlatformCard.tsx @@ -1,11 +1,12 @@ import {PlatformIcon} from 'platformicons'; -import {Container, Grid, Stack} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import type {PlatformKey} from 'sentry/types/project'; import {ScmCardButton} from './scmCardButton'; +import {ScmSelectableContainer} from './scmSelectableContainer'; interface ScmPlatformCardProps { isSelected: boolean; @@ -24,7 +25,7 @@ export function ScmPlatformCard({ }: ScmPlatformCardProps) { return ( - + @@ -36,7 +37,7 @@ export function ScmPlatformCard({ - + ); } diff --git a/static/app/views/onboarding/components/scmSelectableContainer.tsx b/static/app/views/onboarding/components/scmSelectableContainer.tsx new file mode 100644 index 00000000000000..8d926d990f3f4c --- /dev/null +++ b/static/app/views/onboarding/components/scmSelectableContainer.tsx @@ -0,0 +1,35 @@ +import type {ContainerProps} from '@sentry/scraps/layout'; +import {Container} from '@sentry/scraps/layout'; + +type ScmSelectableContainerProps = ContainerProps & { + isSelected: boolean; + /** + * Accent borders are thicker than secondary borders, causing a layout + * shift when toggling selection. This compensation value offsets the + * difference via marginBottom (selected) / borderBottomWidth (unselected). + * Will be unnecessary once the design system provides a stable-height + * selected border variant. + */ + borderCompensation?: number; +}; + +export function ScmSelectableContainer({ + isSelected, + borderCompensation = 2, + style, + ...props +}: ScmSelectableContainerProps) { + return ( + + ); +} diff --git a/static/app/views/onboarding/components/scmStepHeader.tsx b/static/app/views/onboarding/components/scmStepHeader.tsx index 9acdb6f4483876..57414268f7932e 100644 --- a/static/app/views/onboarding/components/scmStepHeader.tsx +++ b/static/app/views/onboarding/components/scmStepHeader.tsx @@ -31,7 +31,14 @@ export function ScmStepHeader({ {heading} - + {subtitle} diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index cb2e3abc04c1f6..279d3d2cc1cbec 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -430,7 +430,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { > {t('Select a platform')} - placeholder={t('Search 100+ SDKs by name, package, or description...')} + placeholder={t('Search SDKs...')} options={manualPickerOptions} value={currentPlatformKey ?? null} onChange={option => { diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index 3c931f52c19074..988f3983a513b7 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -11,6 +11,7 @@ import { } from 'sentry/components/onboarding/onboardingContext'; import {TeamStore} from 'sentry/stores/teamStore'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import * as analytics from 'sentry/utils/analytics'; import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {ScmProjectDetails} from './scmProjectDetails'; @@ -43,10 +44,63 @@ describe('ScmProjectDetails', () => { beforeEach(() => { sessionStorageWrapper.clear(); TeamStore.loadInitialData([teamWithAccess]); + + // useCreateNotificationAction queries messaging integrations on mount + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/`, + body: [], + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + }); + // SetupMessagingIntegrationButton queries integration config + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/config/integrations/`, + body: {providers: []}, + }); }); afterEach(() => { MockApiClient.clearMockResponses(); + jest.restoreAllMocks(); + }); + + it('renders step header with step counter and heading', async () => { + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + }), + } + ); + + expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument(); + expect(screen.getByText('Project details')).toBeInTheDocument(); + }); + + it('renders section headers with icons', async () => { + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + }), + } + ); + + expect(await screen.findByText('Give your project a name')).toBeInTheDocument(); + expect(screen.getByText('Assign a team')).toBeInTheDocument(); + expect(screen.getByText('Alert frequency')).toBeInTheDocument(); + expect(screen.getByText('Get notified when things go wrong')).toBeInTheDocument(); }); it('renders project name defaulted from platform key', async () => { @@ -88,7 +142,7 @@ describe('ScmProjectDetails', () => { expect(input).toHaveValue('javascript-nextjs'); }); - it('renders alert frequency options', async () => { + it('renders card-style alert frequency options', async () => { render( { } ); - expect( - await screen.findByText('Alert me on high priority issues') - ).toBeInTheDocument(); + expect(await screen.findByText('High priority issues')).toBeInTheDocument(); + expect(screen.getByText('Custom')).toBeInTheDocument(); expect(screen.getByText("I'll create my own alerts later")).toBeInTheDocument(); }); @@ -278,4 +331,77 @@ describe('ScmProjectDetails', () => { expect(onComplete).not.toHaveBeenCalled(); }); }); + + it('fires step viewed analytics on mount', async () => { + const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); + + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + }), + } + ); + + await screen.findByText('Project details'); + + expect(trackAnalyticsSpy).toHaveBeenCalledWith( + 'onboarding.scm_project_details_step_viewed', + expect.objectContaining({organization}) + ); + }); + + it('fires create analytics on successful project creation', async () => { + const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); + + MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, + method: 'POST', + body: ProjectFixture({slug: 'javascript-nextjs', name: 'javascript-nextjs'}), + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [teamWithAccess], + }); + + const onComplete = jest.fn(); + + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + }), + } + ); + + await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalled(); + }); + + const eventKeys = trackAnalyticsSpy.mock.calls.map(call => call[0]); + expect(eventKeys).toContain('onboarding.scm_project_details_create_clicked'); + expect(eventKeys).toContain('onboarding.scm_project_details_create_succeeded'); + }); }); diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index 2e3ea023d7c102..3b74d7069c1fd2 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -1,45 +1,45 @@ -import {useCallback, useState} from 'react'; +import {useEffect, useState} from 'react'; import * as Sentry from '@sentry/react'; import {Button} from '@sentry/scraps/button'; import {Input} from '@sentry/scraps/input'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {Heading, Text} from '@sentry/scraps/text'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules'; import {TeamSelector} from 'sentry/components/teamSelector'; -import {IconProject} from 'sentry/icons'; +import {IconGroup, IconProject, IconSiren} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Team} from 'sentry/types/organization'; +import {trackAnalytics} from 'sentry/utils/analytics'; import {slugify} from 'sentry/utils/slugify'; +import {useOrganization} from 'sentry/utils/useOrganization'; import {useTeams} from 'sentry/utils/useTeams'; -import type {useCreateNotificationAction} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; import { DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, getRequestDataFragment, - IssueAlertOptions, type AlertRuleOptions, + RuleAction, } from 'sentry/views/projectInstall/issueAlertOptions'; +import {ScmAlertFrequency} from './components/scmAlertFrequency'; +import {ScmStepFooter} from './components/scmStepFooter'; +import {ScmStepHeader} from './components/scmStepHeader'; import type {StepProps} from './types'; +const PROJECT_DETAILS_WIDTH = '285px'; + export function ScmProjectDetails({onComplete}: StepProps) { + const organization = useOrganization(); const {selectedPlatform, selectedFeatures, setCreatedProjectSlug} = useOnboardingContext(); const {teams} = useTeams(); const createProjectAndRules = useCreateProjectAndRules(); - - // Notification actions (Connect to messaging) deferred to VDY-28 UI polish pass. - // No-op avoids the API call that useCreateNotificationAction makes on mount. - const createNotificationAction = useCallback( - () => - undefined as ReturnType< - ReturnType['createNotificationAction'] - >, - [] - ); + useEffect(() => { + trackAnalytics('onboarding.scm_project_details_step_viewed', {organization}); + }, [organization]); const firstAdminTeam = teams.find((team: Team) => team.access.includes('team:admin')); const defaultName = slugify(selectedPlatform?.key ?? ''); @@ -55,12 +55,40 @@ export function ScmProjectDetails({onComplete}: StepProps) { DEFAULT_ISSUE_ALERT_OPTIONS_VALUES ); - const handleAlertChange = useCallback( - (key: K, value: AlertRuleOptions[K]) => { - setAlertRuleConfig(prev => ({...prev, [key]: value})); - }, - [] - ); + function handleAlertChange( + key: K, + value: AlertRuleOptions[K] + ) { + setAlertRuleConfig(prev => ({...prev, [key]: value})); + if (key === 'alertSetting') { + const optionMap: Record = { + [RuleAction.DEFAULT_ALERT]: 'high_priority', + [RuleAction.CUSTOMIZED_ALERTS]: 'custom', + [RuleAction.CREATE_ALERT_LATER]: 'create_later', + }; + trackAnalytics('onboarding.scm_project_details_alert_selected', { + organization, + option: optionMap[value as number] ?? String(value), + }); + } + } + + function handleProjectNameBlur() { + if (projectName !== null) { + trackAnalytics('onboarding.scm_project_details_name_edited', { + organization, + custom: projectName !== defaultName, + }); + } + } + + function handleTeamChange({value}: {value: string}) { + setTeamSlug(value); + trackAnalytics('onboarding.scm_project_details_team_selected', { + organization, + team: value, + }); + } const canSubmit = projectNameResolved.length > 0 && @@ -68,18 +96,20 @@ export function ScmProjectDetails({onComplete}: StepProps) { !!selectedPlatform && !createProjectAndRules.isPending; - const handleCreateProject = useCallback(async () => { + async function handleCreateProject() { if (!selectedPlatform || !canSubmit) { return; } + trackAnalytics('onboarding.scm_project_details_create_clicked', {organization}); + try { const {project} = await createProjectAndRules.mutateAsync({ projectName: projectNameResolved, platform: selectedPlatform, team: teamSlugResolved, alertRuleConfig: getRequestDataFragment(alertRuleConfig), - createNotificationAction, + createNotificationAction: () => undefined, }); // Store the project slug separately so onboarding.tsx can find @@ -87,52 +117,56 @@ export function ScmProjectDetails({onComplete}: StepProps) { // selectedPlatform.key (which the platform features step needs). setCreatedProjectSlug(project.slug); + trackAnalytics('onboarding.scm_project_details_create_succeeded', { + organization, + project_slug: project.slug, + }); + onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined); } catch (error) { + trackAnalytics('onboarding.scm_project_details_create_failed', {organization}); addErrorMessage(t('Failed to create project')); Sentry.captureException(error); } - }, [ - selectedPlatform, - canSubmit, - createProjectAndRules, - projectNameResolved, - teamSlugResolved, - alertRuleConfig, - createNotificationAction, - selectedFeatures, - setCreatedProjectSlug, - onComplete, - ]); + } return ( - - - {t('Project details')} - - {t( - 'Set the project name, assign a team, and configure how you want to receive issue alerts' - )} - - - - - - - - {t('Give your project a name')} + + + + + + + + + + {t('Give your project a name')} + + setProjectName(slugify(e.target.value))} + onBlur={handleProjectNameBlur} /> - - - {t('Assign a team')} + + + + + + {t('Assign a team')} + + tm.access.includes('team:admin')} value={teamSlugResolved} - onChange={({value}: {value: string}) => setTeamSlug(value)} + onChange={handleTeamChange} /> - - - {t('Alert frequency')} + + + + + + {t('Alert frequency')} + + - - {t('Get notified when things go wrong')} - - + + + {t('Get notified when things go wrong')} + + + - + - + ); } diff --git a/static/app/views/preprod/snapshots/header/snapshotHeaderContent.tsx b/static/app/views/preprod/snapshots/header/snapshotHeaderContent.tsx index a58dfbd1f9d1f3..5fd84eb77880a9 100644 --- a/static/app/views/preprod/snapshots/header/snapshotHeaderContent.tsx +++ b/static/app/views/preprod/snapshots/header/snapshotHeaderContent.tsx @@ -1,83 +1,129 @@ -import React from 'react'; +import {css} from '@emotion/react'; +import styled from '@emotion/styled'; -import {FeatureBadge} from '@sentry/scraps/badge'; +import {Button, LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; -import {Breadcrumbs, type Crumb} from 'sentry/components/breadcrumbs'; import * as Layout from 'sentry/components/layouts/thirds'; -import {IconBranch, IconCommit} from 'sentry/icons'; +import {IconCommit, IconPullRequest, IconShow, IconStack} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; import {t} from 'sentry/locale'; -import {useOrganization} from 'sentry/utils/useOrganization'; +import type {Theme} from 'sentry/utils/theme'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import type {SnapshotDetailsApiResponse} from 'sentry/views/preprod/types/snapshotTypes'; -import {makeReleasesUrl} from 'sentry/views/preprod/utils/releasesUrl'; import {getBranchUrl, getPrUrl, getShaUrl} from 'sentry/views/preprod/utils/vcsLinkUtils'; +interface VcsItemConfig { + icon: React.ComponentType; + key: string; + label: string; + href?: string | null; + monospace?: boolean; + requiresHref?: boolean; +} + interface SnapshotHeaderContentProps { data: SnapshotDetailsApiResponse; - projectId: string; + isSoloView: boolean; + onToggleView: () => void; } -export function SnapshotHeaderContent({projectId, data}: SnapshotHeaderContentProps) { - const organization = useOrganization(); +export function SnapshotHeaderContent({ + data, + isSoloView, + onToggleView, +}: SnapshotHeaderContentProps) { const {vcs_info} = data; - const shaUrl = getShaUrl(vcs_info, vcs_info.head_sha); - const prUrl = getPrUrl(vcs_info); - const branchUrl = getBranchUrl(vcs_info, vcs_info.head_ref); const shortSha = vcs_info.head_sha?.slice(0, 7); - const breadcrumbs: Crumb[] = [ + const vcsItems: VcsItemConfig[] = [ + { + key: 'pr', + icon: IconPullRequest, + label: vcs_info.pr_number ? `#${vcs_info.pr_number}` : '', + href: getPrUrl(vcs_info), + requiresHref: true, + }, { - to: makeReleasesUrl(organization.slug, projectId, {tab: 'mobile-builds'}), - label: t('Releases'), + key: 'sha', + icon: IconCommit, + label: shortSha ?? '', + href: getShaUrl(vcs_info, vcs_info.head_sha), + monospace: true, + requiresHref: true, }, - ]; + { + key: 'branch', + icon: IconStack, + label: vcs_info.head_ref ?? '', + href: getBranchUrl(vcs_info, vcs_info.head_ref), + }, + ].filter(item => item.label && (!item.requiresHref || item.href)); return ( - - - - - - - - {/* TODO: Replace with app-id/version when available */} - {t('Snapshots')} - - {prUrl && vcs_info.pr_number && ( - - - - #{vcs_info.pr_number} - - - )} - {shaUrl && shortSha && ( - - - - - {shortSha} + + {t('Snapshot')} + + {vcsItems.length > 0 && ( + + {vcsItems.map(item => ( + + + {item.href ? ( + + + {item.label} - - - )} - {vcs_info.head_ref && ( - - - {branchUrl ? ( - - {vcs_info.head_ref} - - ) : ( - {vcs_info.head_ref} - )} - - )} - - - - + + ) : ( + {item.label} + )} + + ))} + + )} + + {data.comparison_type === 'diff' && data.base_artifact_id && ( + + + {t('Comparing:')} + + } + priority={isSoloView ? 'primary' : 'default'} + onClick={onToggleView} + aria-pressed={isSoloView} + > + {t('Head')} + + + {t('vs')} + + } + priority="default" + to={normalizeUrl(`/preprod/snapshots/${data.base_artifact_id}/`)} + > + {t('Base')} + + + )} + ); } + +const pillStyle = (p: {theme: Theme}) => css` + border-radius: ${p.theme.radius.full}; +`; + +const PillButton = styled(Button)` + ${pillStyle} +`; + +const PillLinkButton = styled(LinkButton)` + ${pillStyle} +`; diff --git a/static/app/views/preprod/snapshots/snapshots.tsx b/static/app/views/preprod/snapshots/snapshots.tsx index 5be68a236efa9f..77c15e8fa05202 100644 --- a/static/app/views/preprod/snapshots/snapshots.tsx +++ b/static/app/views/preprod/snapshots/snapshots.tsx @@ -393,7 +393,11 @@ export default function SnapshotsPage() { - + { + const autofixItems = pages.flatMap(page => page.json).filter(s => s !== null); + const projectsWithRepos = autofixItems.filter(settings => settings.reposCount > 0); + const projectsWithPreferredAgent = + organization.defaultCodingAgent === 'seer' + ? autofixItems.filter(settings => !settings.automationHandoff) + : autofixItems.filter( + settings => + String(settings.automationHandoff?.integration_id ?? '') === + String(organization.defaultCodingAgentIntegrationId ?? '') + ); + + const projectsWithCreatePr = organization.autoOpenPrs + ? autofixItems.filter( + settings => + (settings.automationHandoff === null && + settings.automatedRunStoppingPoint === 'open_pr') || + settings.automationHandoff?.auto_create_pr + ) + : autofixItems.filter( + settings => + settings.automatedRunStoppingPoint !== 'open_pr' && + !settings.automationHandoff?.auto_create_pr + ); + + return { + projectsWithRepos, + projectsWithPreferredAgent, + projectsWithCreatePr, + }; + }, + }); + useFetchAllPages({result: autofixSettingsResult}); + return autofixSettingsResult; +} + +type Props = ReturnType & { + canWrite: boolean; + organization: Organization; +}; + +export function AutofixOverviewSection({canWrite, data, isPending, organization}: Props) { + const {projects} = useProjects(); + + const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {}; + + return ( + + {t('Autofix')} + + + + {t('Configure')} + + + + + } + > + + + + + ); +} + +function AgentNameForm({ + canWrite, + organization, + projects, + projectsWithPreferredAgentCount, +}: { + canWrite: boolean; + isPending: boolean; + organization: Organization; + projects: Project[]; + projectsWithPreferredAgentCount: number; +}) { + const {data: integrations} = useQuery( + organizationIntegrationsCodingAgents(organization) + ); + const rawAgentOptions = useAgentOptions({ + integrations: integrations?.integrations ?? [], + }).filter(option => option.value !== 'none'); + const codingAgentOptions = rawAgentOptions.map(option => ({ + value: option.value === 'seer' ? 'seer' : String(option.value.id), + label: option.label, + })); + + const codingAgentMutationOpts = mutationOptions({ + mutationFn: ({agentId}: {agentId: string}) => { + return fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: + agentId === 'seer' + ? { + defaultCodingAgent: agentId, + defaultCodingAgentIntegrationId: null, + } + : { + defaultCodingAgent: rawAgentOptions + .filter(option => option.value !== 'seer') + .find(option => option.value.id === agentId)?.value.provider, + defaultCodingAgentIntegrationId: agentId, + }, + }); + }, + onSuccess: updateOrganization, + }); + + const preferredAgentValue = organization.defaultCodingAgentIntegrationId + ? String(organization.defaultCodingAgentIntegrationId) + : organization.defaultCodingAgent + ? organization.defaultCodingAgent + : 'seer'; + + const preferredAgentLabel = codingAgentOptions.find( + option => option.value === preferredAgentValue + )?.label; + + return ( + + {field => ( + + + + + + + + + + {projects.length === 0 + ? t('No projects found') + : projects.length === 1 + ? projectsWithPreferredAgentCount === 1 + ? t('Your existing project uses %s', preferredAgentLabel) + : t('Your existing project does not use %s', preferredAgentLabel) + : projects.length === projectsWithPreferredAgentCount + ? t('All existing projects use %s', preferredAgentLabel) + : t( + '%s of %s existing projects use %s', + projectsWithPreferredAgentCount, + projects.length, + preferredAgentLabel + )} + + + + )} + + ); +} + +function CreatePrForm({ + canWrite, + organization, + projects, + projectsWithCreatePrCount, +}: { + canWrite: boolean; + isPending: boolean; + organization: Organization; + projects: Project[]; + projectsWithCreatePrCount: number; +}) { + const orgMutationOpts = mutationOptions({ + mutationFn: (updateData: Partial) => + fetchMutation({ + method: 'PUT', + url: `/organizations/${organization.slug}/`, + data: updateData, + }), + onSuccess: updateOrganization, + }); + + return ( + + {field => ( + + + ), + })} + > + + + + + + + + {projects.length === 0 + ? t('No projects found') + : projects.length === 1 + ? projectsWithCreatePrCount === 1 + ? t('Your existing project has Create PR enabled') + : t('Your existing project does not have Create PR enabled') + : field.state.value + ? projects.length === projectsWithCreatePrCount + ? t('All existing projects have Create PR enabled') + : t( + '%s of %s existing projects have Create PR enabled', + projectsWithCreatePrCount, + projects.length + ) + : projects.length === projectsWithCreatePrCount + ? t('All existing projects have Create PR disabled') + : t( + '%s of %s existing projects have Create PR disabled', + projectsWithCreatePrCount, + projects.length + )} + + + + {organization.enableSeerCoding === false && ( + + {tct( + '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.', + { + settings: ( + + ), + } + )} + + )} + + )} + + ); +} diff --git a/static/gsApp/views/seerAutomation/seerAutomation.spec.tsx b/static/gsApp/views/seerAutomation/seerAutomation.spec.tsx index 74dac8dca4b967..c6032cb4c33c5d 100644 --- a/static/gsApp/views/seerAutomation/seerAutomation.spec.tsx +++ b/static/gsApp/views/seerAutomation/seerAutomation.spec.tsx @@ -31,6 +31,11 @@ describe('SeerAutomation', () => { method: 'GET', body: [], }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/autofix/automation-settings/`, + method: 'GET', + body: [], + }); }); it('shows no-active-subscription banner inline for legacy Seer cohorts', () => { diff --git a/static/gsApp/views/seerAutomation/settings.tsx b/static/gsApp/views/seerAutomation/settings.tsx index d2ba78a3f63d42..f189c27c4f03dc 100644 --- a/static/gsApp/views/seerAutomation/settings.tsx +++ b/static/gsApp/views/seerAutomation/settings.tsx @@ -1,3 +1,4 @@ +import {Fragment} from 'react'; import {mutationOptions} from '@tanstack/react-query'; import {z} from 'zod'; @@ -15,6 +16,10 @@ import type {Organization} from 'sentry/types/organization'; import {fetchMutation} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; +import { + AutofixOverviewSection, + useAutofixOverviewData, +} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; import { CodeReviewOverviewSection, useCodeReviewOverviewSection, @@ -44,6 +49,7 @@ export function SeerAutomationSettings() { const showSeerOverview = organization.features.includes('seer-overview'); const scmOverviewData = useSCMOverviewSection(); + const autofixOverviewData = useAutofixOverviewData(); const codeReviewOverviewData = useCodeReviewOverviewSection(); const orgEndpoint = `/organizations/${organization.slug}/`; @@ -93,183 +99,197 @@ export function SeerAutomationSettings() { canWrite={canWrite} organizationSlug={organization.slug} /> - - {t('Default automations for new projects')} - , - } - )} - size="xs" - icon="info" - /> - - } - > - - {field => ( - - ), - } + + {showSeerOverview ? ( + + + + + ) : ( + + + {t('Default automations for new projects')} + + ), + } + )} + size="xs" + icon="info" + /> + + } + > + - - - )} - - - {field => ( - - - {tct( - 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.', - { - docs: ( - - ), - } - )} - - {organization.enableSeerCoding === false && ( - - {tct( - '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.', - { - settings: ( - - ), - } - )} - + {field => ( + + ), + } )} - - } + > + + + )} + + - - - )} - - - - {showSeerOverview ? ( - - ) : ( - - {t('Default Code Review for New Repos')} - , + {field => ( + + + {tct( + 'For all new projects with connected repos, Seer will be able to make pull requests for [docs:highly actionable] issues.', + { + docs: ( + + ), + } + )} + + {organization.enableSeerCoding === false && ( + + {tct( + '[settings:"Enable Code Generation"] must be enabled for Seer to create pull requests.', + { + settings: ( + + ), + } + )} + + )} + } - )} - size="xs" - icon="info" - /> - - } - > - - {field => ( - - + + + )} + + + + + {t('Default Code Review for New Repos')} + , + } + )} + size="xs" + icon="info" /> - - )} - - } - mutationOptions={orgMutationOpts} > - {field => ( - } - )} - > - - - )} - - + + {field => ( + + + + )} + + + {field => ( + } + )} + > + + + )} + + + )} diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index 606d8213ae8f72..3e386b6b06f494 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -55,6 +55,8 @@ export function OrganizationFixture(params: Partial = {}): Organiz dateCreated: new Date().toISOString(), debugFilesRole: '', defaultCodeReviewTriggers: [], + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: undefined, defaultRole: '', enhancedPrivacy: false, eventsMemberAdmin: false, diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 79bf32658751fb..4133dbea7ae465 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -26,9 +26,16 @@ ("email", "test@email.com", ""), ("url", "http://some.email.com", ""), ("url - existing behavior", "tcp://user:pass@email.com:10", "tcp://user::"), + ("url - ipv4", "http://11.21.12.31", ""), + ("url - ipv4 with port", "http://11.21.12.31:12", ""), + ("url - ipv6", "http://2001:db8::1", ""), + ("url - ipv6 with port", "http://[2001:db8::1]:80", ""), ("hostname - tld", "example.com", ""), ("hostname - subdomain", "www.example.net", ""), ("ip", "0.0.0.0", ""), + ("ip - v6 unspecified", "::", ""), + ("ip - v6 loopback", "::1", ""), + ("ip - v6 full", "1121:0c03:1231:130d:0000:16da:0908:da07", ""), ("ip - double colon object property", "Option::unwrap()", "Option::unwrap()"), ("ip - double colon object property including hex", "Bee::buzz()", "Bee::buzz()"), ( @@ -36,6 +43,8 @@ "traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", "traceparent: ", ), + ("ip - too many initial characters", "12345:6:789", "::"), + ("ip - too many final characters", "123:4:56789", "::"), ("traceparent - aws", "1-67891233-abcdef012345678912345678", ""), ( "traceparent - aws, but not word boundary", @@ -81,11 +90,25 @@ ("date - datetime compressed T-separated", "20060102T150405", ""), ("date - datetime compressed T-separated UTC", "20060102T150405Z", ""), ("date - datetime compressed T-separated w offset", "20060102T150405+0100", ""), + ("date - kitchen", "11:21", ""), + ("date - kitchen with seconds", "12:31:12", ""), + ("date - kitchen with seconds uppercase", "11:21:12 AM", ""), + ("date - kitchen with seconds lowercase", "12:31:12 pm", ""), ("date - kitchen uppercase without space", "11:21PM", ""), ("date - kitchen uppercase with space", "12:31 PM", ""), ("date - kitchen lowercase without space", "11:21pm", ""), ("date - kitchen lowercase with space", "12:31 pm", ""), ("date - kitchen 24-hour", "23:21", ""), + ("date - kitchen 24-hour with seconds", "23:21:12", ""), + ("date - kitchen 24-hour with leading zero", "09:08", ""), + ("date - kitchen 24-hour no leading zero", "9:08", ""), + ("date - kitchen 24-hour midnight with leading zero", "00:31", ""), + ("date - kitchen 24-hour midnight no leading zero", "0:12", ""), + ("date - kitchen too many initial digits", "908:31", ":"), + ("date - kitchen too many final digits", "11:2112", ":"), + ("date - kitchen hour too big", "31:21", ":"), + ("date - kitchen minute too big", "12:99", ":"), + ("date - kitchen second too big", "12:31:99", "::"), ("date - time", "15:04:05", ""), ("date - basic", "Mon Jan 02, 1999", ""), ("date - datetime compressed date", "20240220 11:55:33.546593", ""), @@ -136,14 +159,18 @@ ("hex without prefix - uppercase, no numbers", "DEADBEEF", "DEADBEEF"), ("hex without prefix - lowercase, no numbers until later", "deadbeef 123", "deadbeef "), ("hex without prefix - uppercase, no numbers until later", "DEADBEEF 123", "DEADBEEF "), - ("hex without prefix - no letters, < 8 digits", "1234567", ""), - ("hex without prefix - no letters, 8+ digits", "12345678", ""), + ("hex without prefix - no letters, < 8 digits, positive", "1234567", ""), + ("hex without prefix - no letters, < 8 digits, negative", "-1234567", ""), + ("hex without prefix - no letters, 8+ digits, positive", "12345678", ""), ("git sha", "commit a93c7d2", "commit "), ("git sha - all letters", "commit deadbeef", "commit deadbeef"), ("git sha - all numbers", "commit 4150908", "commit "), ("float", "0.23", ""), ("int", "23", ""), + ("int - negative", "-23", ""), ("int - separator", "0:17502", ":"), + ("int - separator negative no space", "value:-17502", "value:"), + ("int - separator negative with space", "value: -17502", "value: "), ("int - parens", '{"msg" => "(#239323)', '{"msg" => "(#)'), ("int - date - invalid day", "2006-01-40", ""), ("int - date - invalid month", "2006-20-02", ""), @@ -214,6 +241,24 @@ def test_experimental_parameterization(name: str, input: str, expected: str) -> # parameterization. (Remember to remove the last item in each tuple for the cases you fix.) incorrect_cases = [ # ("name", "input", "desired", "actual") + ( + "hex without prefix - no letters, 8+ digits, negative", + "-12345678", + "", + "", + ), + ( + "int - dashed string with numbers", + "415-908", + "-", + "", + ), + ( + "int - dashed string with letters", + "maisey-908", + "maisey-", + "maisey", + ), ( "int - number in word", "Encoding: utf-8", @@ -226,6 +271,30 @@ def test_experimental_parameterization(name: str, input: str, expected: str) -> "", ",,", ), + ( + "ip - short double colon object property including only hex", + "Fee::add() called too early", + "Fee::add() called too early", + "() called too early", + ), + ( + "ip - v4 mapped to v6", + "::ffff:192.168.1.1", + "", + "..", + ), + ( + "ip - v6 compressed", + "2012:d157::cbe:908:2013", + "", + "::", + ), + ( + "ip - v6 ULA", + "fc00::/7", + "", + "/", + ), ( "json - double quotes", '{"dogs are great": true, "dog_id": "greatdog1231"}', diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index ab7a4aa9c1b652..188c245b5c80a3 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -262,7 +262,7 @@ def assert_open_period_context( date_started: datetime, date_closed: datetime | None, ): - assert asdict(open_period_context) == { + assert open_period_context.dict() == { "id": id, "date_started": date_started, "date_closed": date_closed, diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py new file mode 100644 index 00000000000000..211431fb4aa22e --- /dev/null +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.models.activity import Activity +from sentry.notifications.platform.slack.provider import SlackNotificationProvider +from sentry.notifications.platform.slack.renderers.metric_alert import ( + SlackMetricAlertRenderer, + _build_metric_issue_context_from_activity, +) +from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, + MetricAlertNotificationData, + SerializableAlertContext, +) +from sentry.notifications.platform.templates.seer import SeerAutofixError +from sentry.notifications.platform.types import ( + NotificationCategory, + NotificationRenderedTemplate, +) +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature +from sentry.types.activity import ActivityType +from sentry.workflow_engine.types import DetectorPriorityLevel +from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( + MetricAlertHandlerBase, +) + +MOCK_CHART_URL = "https://chart.example.com/metric.png" + + +def _make_notification_data(**overrides: object) -> MetricAlertNotificationData: + defaults: dict[str, object] = dict( + event_id="abc123", + project_id=1, + group_id=1, + organization_id=1, + detector_id=1, + alert_context=SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + open_period_context=OpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + notification_uuid="test-uuid", + ) + defaults.update(overrides) + return MetricAlertNotificationData(**defaults) + + +class SlackMetricAlertRendererInvalidDataTest(TestCase): + def test_render_raises_on_invalid_data_type(self) -> None: + invalid_data = SeerAutofixError(error_message="not a metric alert") + rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + with pytest.raises(ValueError, match="does not support"): + SlackMetricAlertRenderer.render( + data=invalid_data, + rendered_template=rendered_template, + ) + + +class SlackMetricAlertProviderDispatchTest(TestCase): + def test_provider_returns_metric_alert_renderer(self) -> None: + data = _make_notification_data() + renderer = SlackNotificationProvider.get_renderer( + data=data, + category=NotificationCategory.METRIC_ALERT, + ) + assert renderer is SlackMetricAlertRenderer + + def test_provider_returns_default_for_unknown_category(self) -> None: + data = _make_notification_data() + renderer = SlackNotificationProvider.get_renderer( + data=data, + category=NotificationCategory.DEBUG, + ) + assert renderer is SlackNotificationProvider.default_renderer + + +class SlackMetricAlertRendererTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + open_period_context = OpenPeriodContext.from_group(self.group) + + self.notification_data = MetricAlertNotificationData( + event_id=self.group_event.event_id, + project_id=self.project.id, + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context(alert_context), + open_period_context=open_period_context, + notification_uuid="test-uuid", + ) + self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=None, + ) + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" + ) + def test_render_produces_blocks(self, mock_get_event: MagicMock, mock_chart: MagicMock) -> None: + mock_get_event.return_value = self.group_event + + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + # Without a chart: exactly one section block with the metric text + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["type"] == "mrkdwn" + assert "123.45 events in the last minute" in blocks[0]["text"]["text"] + # Fallback text should reference the detector/alert name + assert self.detector.name in result["text"] + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=MOCK_CHART_URL, + ) + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" + ) + @with_feature({"organizations:metric-alert-chartcuterie": True}) + def test_render_includes_image_block_when_chart_enabled( + self, mock_get_event: MagicMock, mock_chart: MagicMock + ) -> None: + mock_get_event.return_value = self.group_event + + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + # With a chart: section block + image block + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + assert "123.45 events in the last minute" in blocks[0]["text"]["text"] + assert blocks[1]["type"] == "image" + assert blocks[1]["image_url"] == MOCK_CHART_URL + assert blocks[1]["alt_text"] == "Metric Alert Chart" + + @patch("sentry.notifications.platform.slack.renderers.metric_alert.sentry_sdk") + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + side_effect=Exception("chart service unavailable"), + ) + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.eventstore.backend.get_event_by_id" + ) + def test_render_continues_when_chart_fails( + self, mock_get_event: MagicMock, mock_chart: MagicMock, mock_sdk: MagicMock + ) -> None: + mock_get_event.return_value = self.group_event + + with self.feature("organizations:metric-alert-chartcuterie"): + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + mock_sdk.capture_exception.assert_called_once() + # Render completes without the chart — just the section block + # This is annoying but since Block is not indexable and we want to test the structure we need to say Any + blocks: list[Any] = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" + + +class SlackActivityMetricAlertRendererTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + activity = Activity( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + data=asdict(self.evidence_data), + ) + activity.save() + + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + open_period_context = OpenPeriodContext.from_group(self.group) + + self.notification_data = ActivityMetricAlertNotificationData( + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context(alert_context), + open_period_context=open_period_context, + activity_id=activity.id, + notification_uuid="test-uuid", + ) + self.rendered_template = NotificationRenderedTemplate(subject="Metric Alert", body=[]) + + def test_render_produces_blocks_without_snuba(self) -> None: + # The Activity path re-fetches from Postgres only (no Snuba) — no eventstore mock needed + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + blocks: list[Any] = result["blocks"] + assert len(blocks) == 1 + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["type"] == "mrkdwn" + # "Resolved" appears in the fallback title (result["text"]), not the metric body block + assert "Resolved" in result["text"] + assert self.detector.name in result["text"] + + @patch( + "sentry.notifications.platform.slack.renderers.metric_alert.build_metric_alert_chart", + return_value=MOCK_CHART_URL, + ) + @with_feature({"organizations:metric-alert-chartcuterie": True}) + def test_render_includes_image_block_when_chart_enabled(self, mock_chart: MagicMock) -> None: + result = SlackMetricAlertRenderer.render( + data=self.notification_data, + rendered_template=self.rendered_template, + ) + + blocks: list[Any] = result["blocks"] + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + assert "Resolved" in result["text"] + assert blocks[1]["type"] == "image" + assert blocks[1]["image_url"] == MOCK_CHART_URL + assert blocks[1]["alt_text"] == "Metric Alert Chart" + + +class MetricIssueContextBuildersTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + def test_build_from_activity_uses_ok_priority(self) -> None: + from sentry.incidents.models.incident import IncidentStatus + + activity = Activity( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + data=asdict(self.evidence_data), + ) + activity.save() + + data = ActivityMetricAlertNotificationData( + group_id=self.group.id, + organization_id=self.organization.id, + detector_id=self.detector.id, + alert_context=SerializableAlertContext.from_alert_context( + AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + ), + open_period_context=OpenPeriodContext.from_group(self.group), + activity_id=activity.id, + notification_uuid="test-uuid", + ) + + context = _build_metric_issue_context_from_activity(data) + + # Activity path always resolves with DetectorPriorityLevel.OK → IncidentStatus.CLOSED + assert context.new_status == IncidentStatus.CLOSED + assert context.metric_value == self.evidence_data.value diff --git a/tests/sentry/notifications/platform/templates/test_metric_alert.py b/tests/sentry/notifications/platform/templates/test_metric_alert.py new file mode 100644 index 00000000000000..b7cdca7dcfd863 --- /dev/null +++ b/tests/sentry/notifications/platform/templates/test_metric_alert.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType +from sentry.incidents.typings.metric_detector import AlertContext, OpenPeriodContext +from sentry.notifications.platform.templates.metric_alert import ( + ActivityMetricAlertNotificationData, + ActivityMetricAlertNotificationTemplate, + MetricAlertNotificationData, + MetricAlertNotificationTemplate, + SerializableAlertContext, +) +from sentry.notifications.platform.types import NotificationRenderedTemplate, NotificationSource +from sentry.seer.anomaly_detection.types import AnomalyDetectionThresholdType +from sentry.testutils.cases import TestCase +from sentry.workflow_engine.types import DetectorPriorityLevel +from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( + MetricAlertHandlerBase, +) + + +def _make_notification_data(**overrides: Any) -> MetricAlertNotificationData: + defaults = { + "event_id": "abc123", + "project_id": 1, + "group_id": 1, + "organization_id": 1, + "detector_id": 1, + "alert_context": SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + "open_period_context": OpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + "notification_uuid": "test-uuid", + } + defaults.update(overrides) + return MetricAlertNotificationData(**defaults) + + +class SerializableAlertContextTest(TestCase): + def test_round_trip_alert_rule_threshold_type(self) -> None: + original = AlertContext( + name="My Alert", + action_identifier_id=42, + threshold_type=AlertRuleThresholdType.ABOVE, + detection_type=AlertRuleDetectionType.STATIC, + comparison_delta=3600, + sensitivity=None, + resolve_threshold=5.0, + alert_threshold=10.0, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert round_tripped.threshold_type == AlertRuleThresholdType.ABOVE + assert round_tripped.detection_type == AlertRuleDetectionType.STATIC + assert round_tripped.comparison_delta == original.comparison_delta + assert round_tripped.resolve_threshold == original.resolve_threshold + assert round_tripped.alert_threshold == original.alert_threshold + + def test_round_trip_anomaly_detection_threshold_type(self) -> None: + original = AlertContext( + name="Anomaly Alert", + action_identifier_id=99, + threshold_type=AnomalyDetectionThresholdType.ABOVE_AND_BELOW, + detection_type=AlertRuleDetectionType.DYNAMIC, + comparison_delta=None, + sensitivity="medium", + resolve_threshold=0.0, + alert_threshold=0.0, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert isinstance(round_tripped.threshold_type, AnomalyDetectionThresholdType) + assert round_tripped.threshold_type == AnomalyDetectionThresholdType.ABOVE_AND_BELOW + assert round_tripped.detection_type == AlertRuleDetectionType.DYNAMIC + assert round_tripped.sensitivity == original.sensitivity + + def test_round_trip_none_threshold_type(self) -> None: + original = AlertContext( + name="No Threshold", + action_identifier_id=3, + threshold_type=None, + detection_type=AlertRuleDetectionType.STATIC, + comparison_delta=None, + sensitivity=None, + resolve_threshold=None, + alert_threshold=None, + ) + round_tripped = SerializableAlertContext.from_alert_context(original).to_alert_context() + + assert round_tripped.threshold_type is None + + +class OpenPeriodContextTest(TestCase): + def test_fields_with_date_closed(self) -> None: + date_started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + date_closed = datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc) + ctx = OpenPeriodContext(id=100, date_started=date_started, date_closed=date_closed) + + assert ctx.id == 100 + assert ctx.date_started == date_started + assert ctx.date_closed == date_closed + + def test_fields_without_date_closed(self) -> None: + date_started = datetime(2024, 6, 15, 9, 30, 0, tzinfo=timezone.utc) + ctx = OpenPeriodContext(id=200, date_started=date_started) + + assert ctx.id == 200 + assert ctx.date_closed is None + + def test_pydantic_round_trip(self) -> None: + date_started = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + original = OpenPeriodContext(id=100, date_started=date_started) + restored = OpenPeriodContext.parse_obj(original.dict()) + + assert restored.id == original.id + assert restored.date_started == original.date_started + assert restored.date_closed is None + + +class MetricAlertNotificationDataTest(TestCase): + def test_source(self) -> None: + data = _make_notification_data() + assert data.source == NotificationSource.METRIC_ALERT + + def test_pydantic_serialization_round_trip(self) -> None: + alert_ctx = SerializableAlertContext( + name="Round Trip Alert", + action_identifier_id=5, + threshold_type=int(AlertRuleThresholdType.ABOVE.value), + detection_type="static", + comparison_delta=1800, + sensitivity=None, + resolve_threshold=1.0, + alert_threshold=10.0, + ) + open_period_ctx = OpenPeriodContext( + id=77, + date_started=datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + date_closed=datetime(2024, 3, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + original = MetricAlertNotificationData( + event_id="evt-999", + project_id=10, + group_id=20, + organization_id=30, + detector_id=40, + alert_context=alert_ctx, + open_period_context=open_period_ctx, + notification_uuid="round-trip-uuid", + ) + + as_dict = original.dict() + restored = MetricAlertNotificationData.validate(as_dict) + + assert restored.event_id == original.event_id + assert restored.project_id == original.project_id + assert restored.group_id == original.group_id + assert restored.organization_id == original.organization_id + assert restored.detector_id == original.detector_id + assert restored.notification_uuid == original.notification_uuid + assert restored.alert_context == original.alert_context + assert restored.open_period_context == original.open_period_context + assert restored.source == NotificationSource.METRIC_ALERT + + +def _make_activity_notification_data(**overrides: Any) -> ActivityMetricAlertNotificationData: + defaults = { + "group_id": 1, + "organization_id": 1, + "detector_id": 1, + "alert_context": SerializableAlertContext( + name="Test Alert", + action_identifier_id=1, + detection_type="static", + ), + "open_period_context": OpenPeriodContext( + id=1, + date_started=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + "activity_id": 1, + "notification_uuid": "test-uuid", + } + defaults.update(overrides) + return ActivityMetricAlertNotificationData(**defaults) + + +class ActivityMetricAlertNotificationDataTest(TestCase): + def test_source(self) -> None: + data = _make_activity_notification_data() + assert data.source == NotificationSource.ACTIVITY_METRIC_ALERT + + def test_pydantic_serialization_round_trip(self) -> None: + original = _make_activity_notification_data( + group_id=10, + organization_id=20, + detector_id=30, + notification_uuid="activity-uuid", + ) + + as_dict = original.dict() + restored = ActivityMetricAlertNotificationData.validate(as_dict) + + assert restored.group_id == original.group_id + assert restored.organization_id == original.organization_id + assert restored.detector_id == original.detector_id + assert restored.notification_uuid == original.notification_uuid + assert restored.activity_id == original.activity_id + assert restored.source == NotificationSource.ACTIVITY_METRIC_ALERT + + +class ActivityMetricAlertNotificationTemplateTest(TestCase): + def test_render_returns_minimal_rendered_template(self) -> None: + template = ActivityMetricAlertNotificationTemplate() + data = _make_activity_notification_data() + + result = template.render(data) + + assert isinstance(result, NotificationRenderedTemplate) + assert result.subject == "Metric Alert" + assert result.body == [] + + +class MetricAlertNotificationDataContextsTest(MetricAlertHandlerBase): + def setUp(self) -> None: + super().setUp() + self.create_models() + + def test_alert_context_round_trips_from_workflow_engine_models(self) -> None: + alert_context = AlertContext.from_workflow_engine_models( + self.detector, + self.evidence_data, + self.group.status, + DetectorPriorityLevel.HIGH, + ) + serialized = SerializableAlertContext.from_alert_context(alert_context) + restored = serialized.to_alert_context() + + assert restored.name == alert_context.name + assert restored.action_identifier_id == alert_context.action_identifier_id + assert restored.detection_type == alert_context.detection_type + assert restored.threshold_type == alert_context.threshold_type + assert restored.comparison_delta == alert_context.comparison_delta + + def test_open_period_context_round_trips_from_real_group(self) -> None: + open_period_context = OpenPeriodContext.from_group(self.group) + restored = OpenPeriodContext.parse_obj(open_period_context.dict()) + + assert restored.id == open_period_context.id + assert restored.date_started == open_period_context.date_started + assert restored.date_closed == open_period_context.date_closed + + +class MetricAlertNotificationTemplateTest(TestCase): + def test_render_returns_minimal_rendered_template(self) -> None: + template = MetricAlertNotificationTemplate() + data = _make_notification_data() + + result = template.render(data) + + assert isinstance(result, NotificationRenderedTemplate) + assert result.subject == "Metric Alert" + assert result.body == [] diff --git a/tests/sentry/notifications/platform/test_service.py b/tests/sentry/notifications/platform/test_service.py index e8a79d2c6c0e1f..42374385a7ee3b 100644 --- a/tests/sentry/notifications/platform/test_service.py +++ b/tests/sentry/notifications/platform/test_service.py @@ -8,6 +8,7 @@ from sentry.notifications.platform.email.provider import EmailNotificationProvider from sentry.notifications.platform.provider import SendFailure, SendFailureStatus from sentry.notifications.platform.service import ( + NotificationRenderError, NotificationService, NotificationServiceError, deserialize_notification_data, @@ -193,6 +194,21 @@ def test_notify_mixed_targets_async( assert_count_of_metric(mock_record, EventLifecycleOutcome.STARTED, 2) assert_count_of_metric(mock_record, EventLifecycleOutcome.SUCCESS, 2) + @mock.patch("sentry.notifications.platform.service.NotificationService.render_template") + @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_render_error_records_failure_and_raises( + self, mock_record: mock.MagicMock, mock_render: mock.MagicMock + ) -> None: + mock_render.side_effect = ValueError("missing occurrence") + service = NotificationService(data=MockNotification(message="test")) + + with pytest.raises(NotificationRenderError) as exc_info: + with self.tasks(): + service.notify_async(targets=[self.target]) + + assert "missing occurrence" in str(exc_info.value.__cause__) + assert_count_of_metric(mock_record, EventLifecycleOutcome.FAILURE, 1) + class NotificationDataSerializationTest(TestCase): def test_deserialize_raises_error_without_source(self) -> None: diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 51c7871910c8b4..aa55052f2253d7 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -39,13 +39,16 @@ def run_state(run_id=123, blocks: list[MemoryBlock] | None = None, metadata=None ) -def root_cause_memory_block() -> MemoryBlock: +def root_cause_memory_block(referrer: str | None = None) -> MemoryBlock: + metadata: dict[str, str] = {"step": "root_cause"} + if referrer is not None: + metadata["referrer"] = referrer return MemoryBlock( id="block-root-cause", message=Message( role="assistant", content="message root cause", - metadata={"step": "root_cause"}, + metadata=metadata, ), timestamp="2026-02-10T00:00:00Z", artifacts=[ @@ -58,13 +61,16 @@ def root_cause_memory_block() -> MemoryBlock: ) -def solution_memory_block() -> MemoryBlock: +def solution_memory_block(referrer: str | None = None) -> MemoryBlock: + metadata: dict[str, str] = {"step": "solution"} + if referrer is not None: + metadata["referrer"] = referrer return MemoryBlock( id="block-solution", message=Message( role="assistant", content="message solution", - metadata={"step": "solution"}, + metadata=metadata, ), timestamp="2026-02-10T00:00:00Z", artifacts=[ @@ -77,13 +83,16 @@ def solution_memory_block() -> MemoryBlock: ) -def code_changes_memory_block() -> MemoryBlock: +def code_changes_memory_block(referrer: str | None = None) -> MemoryBlock: + metadata: dict[str, str] = {"step": "code_changes"} + if referrer is not None: + metadata["referrer"] = referrer return MemoryBlock( id="block-code-changes", message=Message( role="assistant", content="message code changes", - metadata={"step": "code_changes"}, + metadata=metadata, ), timestamp="2026-02-10T00:00:00Z", merged_file_patches=[ @@ -95,13 +104,16 @@ def code_changes_memory_block() -> MemoryBlock: ) -def triage_memory_block() -> MemoryBlock: +def triage_memory_block(referrer: str | None = None) -> MemoryBlock: + metadata: dict[str, str] = {"step": "triage"} + if referrer is not None: + metadata["referrer"] = referrer return MemoryBlock( id="block-triage", message=Message( role="assistant", content="message triage", - metadata={"step": "triage"}, + metadata=metadata, ), timestamp="2026-02-10T00:00:00Z", artifacts=[ @@ -114,13 +126,16 @@ def triage_memory_block() -> MemoryBlock: ) -def impact_assessment_memory_block() -> MemoryBlock: +def impact_assessment_memory_block(referrer: str | None = None) -> MemoryBlock: + metadata: dict[str, str] = {"step": "impact_assessment"} + if referrer is not None: + metadata["referrer"] = referrer return MemoryBlock( id="block-impact-assessment", message=Message( role="assistant", content="message impact assessment", - metadata={"step": "impact_assessment"}, + metadata=metadata, ), timestamp="2026-02-10T00:00:00Z", artifacts=[ @@ -139,14 +154,16 @@ class TestAutofixOnCompletionHookHelpers(TestCase): def test_get_current_step_root_cause(self) -> None: """Returns ROOT_CAUSE when root_cause artifact exists.""" state = run_state(blocks=[root_cause_memory_block()]) - result = AutofixOnCompletionHook._get_current_step(state) - assert result == AutofixStep.ROOT_CAUSE + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.ROOT_CAUSE + assert referrer is None def test_get_current_step_solution(self) -> None: """Returns SOLUTION when solution artifact exists.""" state = run_state(blocks=[root_cause_memory_block(), solution_memory_block()]) - result = AutofixOnCompletionHook._get_current_step(state) - assert result == AutofixStep.SOLUTION + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.SOLUTION + assert referrer is None def test_get_current_step_code_changes(self) -> None: """Returns CODE_CHANGES when code changes exist.""" @@ -157,14 +174,44 @@ def test_get_current_step_code_changes(self) -> None: code_changes_memory_block(), ] ) - result = AutofixOnCompletionHook._get_current_step(state) - assert result == AutofixStep.CODE_CHANGES + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.CODE_CHANGES + assert referrer is None def test_get_current_step_none(self) -> None: """Returns None when no artifacts or code changes exist.""" state = run_state() - result = AutofixOnCompletionHook._get_current_step(state) - assert result is None + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step is None + assert referrer is None + + def test_get_current_step_extracts_referrer(self): + """Returns the referrer from message metadata.""" + state = run_state( + blocks=[root_cause_memory_block(referrer=AutofixReferrer.ON_COMPLETION_HOOK.value)] + ) + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.ROOT_CAUSE + assert referrer == AutofixReferrer.ON_COMPLETION_HOOK + + def test_get_current_step_extracts_referrer_from_latest_block(self): + """Returns the referrer from the most recent block with step metadata.""" + state = run_state( + blocks=[ + root_cause_memory_block(referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT.value), + solution_memory_block(referrer=AutofixReferrer.ON_COMPLETION_HOOK.value), + ] + ) + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.SOLUTION + assert referrer == AutofixReferrer.ON_COMPLETION_HOOK + + def test_get_current_step_invalid_referrer_returns_none(self): + """Returns None referrer when referrer value is not a valid AutofixReferrer.""" + state = run_state(blocks=[root_cause_memory_block(referrer="not_a_valid_referrer")]) + step, referrer = AutofixOnCompletionHook._get_current_step(state) + assert step == AutofixStep.ROOT_CAUSE + assert referrer is None def test_get_next_step_root_cause_to_solution(self) -> None: """Returns SOLUTION after ROOT_CAUSE."""