diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index 9e40c9864a92df..2ddb6413f5d9a9 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -111,7 +111,7 @@ class BaseGroupResponseOptional(TypedDict, total=False): class BaseGroupSerializerResponse(BaseGroupResponseOptional): id: str - shareId: str + shareId: str | None shortId: str title: str culprit: str | None @@ -134,7 +134,7 @@ class BaseGroupSerializerResponse(BaseGroupResponseOptional): issueCategory: str metadata: dict[str, Any] numComments: int - assignedTo: ActorSerializerResponse + assignedTo: ActorSerializerResponse | None isBookmarked: bool isSubscribed: bool subscriptionDetails: SubscriptionDetails | None diff --git a/src/sentry/api/serializers/models/group_stream.py b/src/sentry/api/serializers/models/group_stream.py index 5ba1f3983eb515..befe4a8cf2ef41 100644 --- a/src/sentry/api/serializers/models/group_stream.py +++ b/src/sentry/api/serializers/models/group_stream.py @@ -281,7 +281,7 @@ class _Filtered(TypedDict): class StreamGroupSerializerSnubaResponse(TypedDict): id: str # from base response - shareId: NotRequired[str] + shareId: NotRequired[str | None] shortId: NotRequired[str] title: NotRequired[str] culprit: NotRequired[str | None] @@ -304,7 +304,7 @@ class StreamGroupSerializerSnubaResponse(TypedDict): issueCategory: NotRequired[str] metadata: NotRequired[dict[str, Any]] numComments: NotRequired[int] - assignedTo: NotRequired[ActorSerializerResponse] + assignedTo: NotRequired[ActorSerializerResponse | None] isBookmarked: NotRequired[bool] isSubscribed: NotRequired[bool] subscriptionDetails: NotRequired[SubscriptionDetails | None] diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index db6930c4c259e1..874c9686f8faf9 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -105,6 +105,77 @@ def is_table_display_type(display_type): MAX_WIDGET_COLS = 6 +_DEFAULT_CHART_AND_TABLE_TYPES: frozenset[int] = frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } +) + + +class DatasetConfig(TypedDict): + supported_display_types: frozenset[int] + + +# Per-dataset config mirroring the frontend dataset configs +# (``static/app/views/dashboards/datasetConfig/*.tsx``). A display type is +# allowed for a widget_type iff it appears in ``supported_display_types`` here. +DATASET_CONFIG: dict[int, DatasetConfig] = { + DashboardWidgetTypes.DISCOVER: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + # ERROR_EVENTS is intentionally omitted: it's the ``create_widget`` default + # when a request omits widget_type, so any system display type a prebuilt + # config doesn't tag will land here. Without a config entry the validation + # falls through and lets the request pass. + DashboardWidgetTypes.TRANSACTION_LIKE: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.RELEASE_HEALTH: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.METRICS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.LOGS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.ISSUE: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + } + ) + }, + DashboardWidgetTypes.SPANS: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + | frozenset( + { + DashboardWidgetDisplayTypes.DETAILS, + DashboardWidgetDisplayTypes.SERVER_TREE, + # WHEEL is used by built-in performance-score widgets. + DashboardWidgetDisplayTypes.WHEEL, + } + ) + }, + DashboardWidgetTypes.TRACEMETRICS: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } + ) + }, + DashboardWidgetTypes.PREPROD_APP_SIZE: { + "supported_display_types": frozenset({DashboardWidgetDisplayTypes.LINE_CHART}) + }, +} + + class WidgetLayoutSerializer(CamelSnakeSerializer[Dashboard]): """Widget grid layout position and dimensions. @@ -326,7 +397,24 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]): ) def validate_display_type(self, display_type): - return DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + + widget_type_name = self.context.get("widget_type") + if widget_type_name is not None and display_type_id is not None: + widget_type_id = DashboardWidgetTypes.get_id_for_type_name(widget_type_name) + config = DATASET_CONFIG.get(widget_type_id) + if config is not None and display_type_id not in config["supported_display_types"]: + supported_names = sorted( + DashboardWidgetDisplayTypes.get_type_name(d) or str(d) + for d in config["supported_display_types"] + ) + raise serializers.ValidationError( + f"Display type '{display_type}' is not supported for the " + f"'{widget_type_name}' dataset. Supported display types: " + f"{', '.join(supported_names)}." + ) + + return display_type_id def _validate_widget_type(self, data): widget_type = DashboardWidgetTypes.get_id_for_type_name(data.get("widget_type")) @@ -358,6 +446,11 @@ def to_internal_value(self, data): queries_serializer = self.fields["queries"] additional_context = {} + # Always reset; with ``many=True`` DRF reuses one child serializer + # instance across items, so stale values would otherwise leak between + # widgets in the same request. + self.context["widget_type"] = data.get("widget_type") + if data.get("display_type"): additional_context["display_type"] = data.get("display_type") if data.get("widget_type"): diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py index ad7ad69302a788..3d8a68d5cc4de4 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py +++ b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py @@ -27,13 +27,17 @@ logger = logging.getLogger(__name__) TRACE_METRICS_GUIDANCE = """When generating widgets with `widget_type: "tracemetrics"`: -- Aggregates use a multi-argument form: `func(attribute, metric_name, metric_type)`. +- Aggregates use a required 4-argument form: `func(attribute, metric_name, metric_type, metric_unit)`. - `attribute` must be `value` (the numeric value of the metric); no other attributes are supported at this time. - `metric_name` is the metric's name as ingested (e.g. `my.app.latency`). - `metric_type` is exactly one of `counter`, `gauge`, or `distribution`. -- Examples: `count(value, my.app.requests, counter)`, `avg(value, my.app.cpu, gauge)`, `p95(value, my.app.latency, distribution)`. -- Single-argument forms like `p50(my.metric)` are INVALID for tracemetrics. -- Before emitting a tracemetrics widget you MUST look up the metric's `metric_type` using available tools (e.g. by querying the tracemetrics dataset for distinct `metric.name`/`metric.type` values, or fetching trace-item attributes). Do NOT guess the type — if you cannot confirm it, pick a different dataset or omit the widget.""" + - `metric_unit` is the metric's unit as ingested (e.g. `milliseconds`, `bytes`). Use `none` only when the metric has no unit. +- Each `metric_type` only accepts a specific set of aggregate functions. Using a function not listed for the metric's type will fail: + - `counter`: `sum`, `per_second`, `per_minute`. + - `gauge`: `avg`, `min`, `max`, `per_second`, `per_minute`. + - `distribution`: `p50`, `p75`, `p90`, `p95`, `p99`, `avg`, `min`, `max`, `sum`, `count`, `per_second`, `per_minute`. +- Examples: `sum(value, my.app.requests, counter, none)`, `avg(value, my.app.cpu, gauge, percent)`, `p95(value, my.app.latency, distribution, milliseconds)`. +- Before emitting a tracemetrics widget you MUST look up the metric's `metric_type` AND `metric_unit` using available tools (e.g. by querying the tracemetrics dataset for distinct `metric.name`/`metric.type`/`metric.unit` values, or fetching trace-item attributes). Do NOT guess the type or unit — if you cannot confirm both, pick a different dataset or omit the widget.""" CREATE_ON_PAGE_CONTEXT = ( "The user is on the dashboard generation page. This session must ONLY generate a dashboard " diff --git a/src/sentry/dashboards/models/generate_dashboard_artifact.py b/src/sentry/dashboards/models/generate_dashboard_artifact.py index 26836ec51f89e7..af46ff349d33ad 100644 --- a/src/sentry/dashboards/models/generate_dashboard_artifact.py +++ b/src/sentry/dashboards/models/generate_dashboard_artifact.py @@ -54,20 +54,25 @@ class GeneratedWidgetQuery(BaseModel): "values; for table widgets they become data columns alongside columns[]. Valid " "aggregate function values vary by dataset type. Do not make up functions or use " "unsupported functions.\n\n" - "For the 'tracemetrics' widget_type, aggregates have a SPECIAL multi-argument form: " - "`func(attribute, metric_name, metric_type)` where attribute must be `value` " - "(the numeric value of the metric; no other attributes are supported at this time), " - "metric_name is the metric's name as ingested, and " - "metric_type is one of 'counter', 'gauge', or 'distribution'. Examples: " - "`count(value, my.app.requests, counter)`, " - "`avg(value, my.app.cpu, gauge)`, " - "`p95(value, my.app.latency, distribution)`. " - "Allowed functions for tracemetrics: count, count_unique, sum, avg, max, min, " - "p50, p75, p90, p95, p99. The single-argument form like `p50(my.metric)` is INVALID " - "for tracemetrics — the metric_name and metric_type MUST be passed as separate " - "positional arguments. You MUST NOT guess the metric_name or metric_type; look them " - "up first using the available tools (e.g. by querying the tracemetrics dataset for " - "distinct `metric.name` and `metric.type` values, or fetching trace-item attributes)." + "For the 'tracemetrics' widget_type, aggregates use a required 4-argument form: " + "`func(attribute, metric_name, metric_type, metric_unit)` where attribute must be " + "`value` (the numeric value of the metric; no other attributes are supported at this " + "time), metric_name is the metric's name as ingested, metric_type is one of " + "'counter', 'gauge', or 'distribution', and metric_unit is the metric's unit as " + "ingested (e.g. 'milliseconds', 'bytes'); use 'none' only when the metric has no " + "unit. Examples: `sum(value, my.app.requests, counter, none)`, " + "`avg(value, my.app.cpu, gauge, percent)`, " + "`p95(value, my.app.latency, distribution, milliseconds)`. " + "Each metric_type only accepts a specific set of aggregate functions, and using a " + "function outside that set will fail:\n" + "- counter: sum, per_second, per_minute.\n" + "- gauge: avg, min, max, per_second, per_minute.\n" + "- distribution: p50, p75, p90, p95, p99, avg, min, max, sum, count, per_second, " + "per_minute.\n" + "You MUST NOT guess metric_name, metric_type, or metric_unit; look them up first " + "using the available tools (e.g. by querying the tracemetrics dataset for distinct " + "`metric.name`, `metric.type`, and `metric.unit` values, or fetching trace-item " + "attributes)." ), ) columns: list[str] = Field( diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b07b19671fe7db..b184dd0aaea14f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -66,8 +66,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-basic", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) # Enables custom editable dashboards manager.add("organizations:dashboards-edit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) - # Enable unfurling of dashboard widgets in Slack - manager.add("organizations:dashboards-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable metrics enhanced performance for AM2+ customers as they transition from AM2 to AM3 manager.add("organizations:dashboards-metrics-transition", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable drilldown flow for dashboards @@ -272,6 +270,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-context-engine-fe-override-ui-flag", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code editing tools in Seer Agent chat manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable sentry source code search tool + manager.add("organizations:seer-agent-source-code-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code mode tools (sentry_api_search/execute) in Seer Agent manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code mode tools for Slack-initiated Explorer sessions @@ -346,8 +346,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable data browsing widget unfurl - manager.add("organizations:data-browsing-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan diff --git a/src/sentry/integrations/slack/unfurl/dashboards.py b/src/sentry/integrations/slack/unfurl/dashboards.py index ae2054773b9b56..6873b0796b5fc2 100644 --- a/src/sentry/integrations/slack/unfurl/dashboards.py +++ b/src/sentry/integrations/slack/unfurl/dashboards.py @@ -129,7 +129,7 @@ def _unfurl_dashboards( enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:dashboards-widget-unfurl", org, actor=user) + if features.has("organizations:dashboards-basic", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/unfurl/explore.py b/src/sentry/integrations/slack/unfurl/explore.py index 841719b11c8aae..ced91ca3864d8e 100644 --- a/src/sentry/integrations/slack/unfurl/explore.py +++ b/src/sentry/integrations/slack/unfurl/explore.py @@ -375,11 +375,10 @@ def _unfurl_explore( ) orgs_by_slug = {org.slug: org for org in organizations} - # Check if any org has the feature flag enabled before doing any work enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:data-browsing-widget-unfurl", org, actor=user) + if features.has("organizations:visibility-explore-view", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index eee70333cd08e1..504b0e0b4c044f 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -229,8 +229,8 @@ def _get_unfurlable_links( feature_flag = { LinkType.DISCOVER: "organizations:discover-basic", - LinkType.EXPLORE: "organizations:data-browsing-widget-unfurl", - LinkType.DASHBOARDS: "organizations:dashboards-widget-unfurl", + LinkType.EXPLORE: "organizations:visibility-explore-view", + LinkType.DASHBOARDS: "organizations:dashboards-basic", }.get(link_type) if ( diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 0cb40d86814249..5b9f2f8932de40 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -127,7 +127,7 @@ def reply_with_export( logger.info("SaaS -> SaaS relocation RelocationFile saved", extra=logger_data) - uploading_complete.apply_async(args=[relocation.uuid]) + uploading_complete.apply_async(args=[str(relocation.uuid)]) logger.info("SaaS -> SaaS relocation next task scheduled", extra=logger_data) diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 0cb121ec587206..4236f93c720d52 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -364,6 +364,13 @@ def start_run( ): chat_body["is_context_engine_enabled"] = override_ce_enable + if features.has( + "organizations:seer-agent-source-code-search", + self.organization, + actor=self.user, + ): + chat_body["enable_frontend_code_search"] = True + if features.has("organizations:seer-run-mirror-explorer", self.organization): user_id = ( self.user.id @@ -542,6 +549,13 @@ def continue_run( if _has_context_engine(self.organization, self.user): chat_body["is_context_engine_enabled"] = True + if features.has( + "organizations:seer-agent-source-code-search", + self.organization, + actor=self.user, + ): + chat_body["enable_frontend_code_search"] = True + response = make_agent_chat_request(chat_body, viewer_context=self.viewer_context) if response.status >= 400: diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index 2faa0c919307d9..07b9e3d2eb584d 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -73,6 +73,7 @@ class AgentChatRequest(TypedDict): category_value: NotRequired[str] metadata: NotRequired[dict[str, Any]] is_context_engine_enabled: NotRequired[bool] + enable_frontend_code_search: NotRequired[bool] max_iterations: NotRequired[int] proxy_headers: NotRequired[dict[str, str] | None] ui_tools: NotRequired[str | None] diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 9f6d198e95a0ab..67cb156ae63213 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -361,6 +361,19 @@ def _maybe_continue_pipeline( reached_stopping_point=reached_stopping_point, ) ) + logger.info( + "autofix.on_completion_hook.introspection", + extra={ + "organization_id": organization.id, + "project_id": group.project_id, + "group_id": group.id, + "referrer": referrer.value, + "step": current_step.value, + "action": decision.action.value, + "reason": decision.reason, + "reached_stopping_point": reached_stopping_point, + }, + ) if stopping_point is None or reached_stopping_point: # We've reached the stopping point diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index b305f10f4403d8..f6bd2124ae198d 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -134,12 +134,6 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): required=False, help_text="Coding agent provider (e.g., 'github_copilot'). Alternative to integration_id for user-authenticated providers.", ) - intelligence_level = serializers.ChoiceField( - required=False, - choices=["low", "medium", "high"], - default="medium", - help_text="The intelligence level to use.", - ) user_context = serializers.CharField( required=False, max_length=1000, @@ -328,7 +322,7 @@ def _post_agent(self, request: Request, group: Group) -> Response: referrer=_parse_autofix_referrer(data.get("referrer")), stopping_point=AutofixStoppingPoint(stopping_point) if stopping_point else None, run_id=run_id, - intelligence_level=data["intelligence_level"], + intelligence_level="medium", user_context=data.get("user_context"), insert_index=data.get("insert_index"), ) diff --git a/src/sentry/seer/endpoints/organization_seer_rpc.py b/src/sentry/seer/endpoints/organization_seer_rpc.py index c67aad92c4e82d..004ec836a38c21 100644 --- a/src/sentry/seer/endpoints/organization_seer_rpc.py +++ b/src/sentry/seer/endpoints/organization_seer_rpc.py @@ -64,6 +64,7 @@ get_attributes_and_values, get_attributes_for_span, get_github_enterprise_integration_config, + get_organization_features, get_organization_project_ids, get_organization_slug, has_repo_code_mappings, @@ -87,6 +88,7 @@ # Common to Seer features "get_organization_project_ids": map_org_id_param(get_organization_project_ids), "get_organization_slug": map_org_id_param(get_organization_slug), + "get_organization_features": map_org_id_param(get_organization_features), "validate_repo": validate_repo, "get_github_enterprise_integration_config": get_github_enterprise_integration_config, # diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 505cf436cc24cc..54133bedb7843b 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -36,6 +36,7 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication @@ -44,6 +45,7 @@ from sentry.api.utils import get_date_range_from_params from sentry.constants import ObjectStatus from sentry.exceptions import InvalidSearchQuery +from sentry.features.base import OrganizationFeature from sentry.hybridcloud.rpc.service import RpcAuthenticationSetupException, RpcResolutionException from sentry.hybridcloud.rpc.sig import SerializableFunctionValueException from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration @@ -124,6 +126,7 @@ from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization from sentry.silo.base import SiloMode from sentry.snuba.referrer import Referrer +from sentry.users.services.user.service import user_service from sentry.utils import snuba_rpc from sentry.utils.env import in_test_environment from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded @@ -317,6 +320,47 @@ def get_organization_project_ids(*, org_id: int) -> dict: return {"projects": projects} +_ORGANIZATION_SCOPE_PREFIX = "organizations:" + + +def get_organization_features(*, org_id: int, user_id: int | None = None) -> dict[str, list[str]]: + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + return {"features": []} + + actor = user_service.get_user(user_id=user_id) if user_id is not None else None + + features_to_check = { + feature + for feature in features.all(feature_type=OrganizationFeature, api_expose_only=True).keys() + if feature.startswith(_ORGANIZATION_SCOPE_PREFIX) + } + + feature_set: set[str] = set() + + with sentry_sdk.start_span(op="features.check", name="check batch features"): + batch = features.batch_has( + list(features_to_check), + actor=actor, + organization=organization, + skip_experiment_exposure=True, + ) + + if batch: + for name, active in batch.get(f"organization:{organization.id}", {}).items(): + if active: + feature_set.add(name[len(_ORGANIZATION_SCOPE_PREFIX) :]) + features_to_check.discard(name) + + with sentry_sdk.start_span(op="features.check", name="check individual features"): + for name in features_to_check: + if features.has(name, organization, actor=actor, skip_entity=True): + feature_set.add(name[len(_ORGANIZATION_SCOPE_PREFIX) :]) + + return {"features": list(sorted(feature_set))} + + class SentryOrganizaionIdsAndSlugs(TypedDict): org_ids: list[int] org_slugs: list[str] @@ -917,6 +961,7 @@ def bulk_get_project_preferences( # Common to Seer features "get_github_enterprise_integration_config": get_github_enterprise_integration_config, "get_organization_project_ids": get_organization_project_ids, + "get_organization_features": get_organization_features, "check_repository_integrations_status": check_repository_integrations_status, "validate_repo": validate_repo, "get_repo_installation_id": get_repo_installation_id, diff --git a/src/sentry/testutils/pytest/fixtures.py b/src/sentry/testutils/pytest/fixtures.py index c630a4451d48a2..e40d767a5cf24a 100644 --- a/src/sentry/testutils/pytest/fixtures.py +++ b/src/sentry/testutils/pytest/fixtures.py @@ -337,7 +337,6 @@ def inner(endpoint): def reset_snuba(call_snuba): init_endpoints = [ "/tests/events_analytics_platform/drop", - "/tests/spans/drop", "/tests/events/drop", "/tests/functions/drop", "/tests/groupedmessage/drop", diff --git a/static/app/components/charts/baseChart.tsx b/static/app/components/charts/baseChart.tsx index 84b61984d24f19..9d41ca139851f8 100644 --- a/static/app/components/charts/baseChart.tsx +++ b/static/app/components/charts/baseChart.tsx @@ -764,6 +764,11 @@ const getTooltipStyles = (p: {theme: Theme}) => css` justify-content: flex-start; align-items: baseline; } + .tooltip-label-centered { + display: flex; + justify-content: center; + align-items: center; + } .tooltip-code-no-margin { padding-left: 0; margin-left: 0; diff --git a/static/app/components/charts/components/tooltip.tsx b/static/app/components/charts/components/tooltip.tsx index b90d8c92a2d2b6..030b14e35468a4 100644 --- a/static/app/components/charts/components/tooltip.tsx +++ b/static/app/components/charts/components/tooltip.tsx @@ -243,21 +243,6 @@ export function getFormatter({ serie ); - if (serie.seriesType === 'heatmap') { - const zAxisCountValue = (getSeriesValue(serie, 2) ?? 0).toString(); - const yAxisValue = valueFormatter( - getSeriesValue(serie, 1), - serie.seriesName, - serie - ); - - acc.series.push( - `
${yAxisValue} ${zAxisCountValue}
` - ); - - return acc; - } - const value = valueFormatter(getSeriesValue(serie, 1), serie.seriesName, serie); const marker = markerFormatter(serie.marker ?? '', serie.seriesName); diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index f14541a03d140b..8c597a30dd056c 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -333,14 +333,12 @@ export function GlobalCommandPaletteActions() { to={`${prefix}/issues/views/${starredView.id}/`} /> ))} - {organization.features.includes('autofix-on-explorer') && ( - - - - )} + + + }} limit={4}> diff --git a/static/app/components/events/autofix/FlyingLinesEffect.tsx b/static/app/components/events/autofix/FlyingLinesEffect.tsx deleted file mode 100644 index 4f9dcb60179983..00000000000000 --- a/static/app/components/events/autofix/FlyingLinesEffect.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import {useLayoutEffect, useRef, useState} from 'react'; -import {createPortal} from 'react-dom'; -import {keyframes} from '@emotion/react'; -import styled from '@emotion/styled'; - -export function FlyingLinesEffect({targetElement}: {targetElement: HTMLElement | null}) { - const [position, setPosition] = useState({left: 0, top: 0}); - const portalContainerRef = useRef(null); - const rafRef = useRef(null); - const lastUpdateRef = useRef(0); - const THROTTLE_MS = 16; - - useLayoutEffect(() => { - if (!targetElement) { - return; - } - - function getScrollParents(element: HTMLElement): Element[] { - const scrollParents: Element[] = []; - let currentElement = element.parentElement; - - while (currentElement) { - const overflow = window.getComputedStyle(currentElement).overflow; - if (overflow.includes('scroll') || overflow.includes('auto')) { - scrollParents.push(currentElement); - } - currentElement = currentElement.parentElement; - } - - return scrollParents; - } - - const updatePosition = () => { - const now = Date.now(); - if (now - lastUpdateRef.current < THROTTLE_MS) { - rafRef.current = requestAnimationFrame(updatePosition); - return; - } - - const rect = targetElement.getBoundingClientRect(); - const left = rect.left + rect.width / 2; - const top = rect.top + rect.height / 2; - setPosition({left, top}); - lastUpdateRef.current = now; - rafRef.current = requestAnimationFrame(updatePosition); - }; - - // Create portal container if it doesn't exist - if (!portalContainerRef.current) { - portalContainerRef.current = document.createElement('div'); - document.body.appendChild(portalContainerRef.current); - } - - rafRef.current = requestAnimationFrame(updatePosition); - - const scrollElements = [window, ...getScrollParents(targetElement)]; - scrollElements.forEach(element => { - element.addEventListener('scroll', updatePosition, {passive: true}); - }); - - window.addEventListener('resize', updatePosition, {passive: true}); - - const resizeObserver = new ResizeObserver(updatePosition); - resizeObserver.observe(targetElement); - - return () => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } - scrollElements.forEach(element => { - element.removeEventListener('scroll', updatePosition); - }); - window.removeEventListener('resize', updatePosition); - resizeObserver.disconnect(); - - // Clean up portal container - if (portalContainerRef.current) { - document.body.removeChild(portalContainerRef.current); - portalContainerRef.current = null; - } - }; - }, [targetElement]); - - if (!targetElement || !portalContainerRef.current) { - return null; - } - - return createPortal( - - - - - , - portalContainerRef.current - ); -} - -const flyingLines = keyframes` - 0% { - transform: scale(1.5); - opacity: 0; - } - 50% { - opacity: 0.7; - } - 100% { - transform: scale(0); - opacity: 0; - } -`; - -const AdditionalLine = styled('div')<{ - delay: number; - rotation?: number; - variant?: 'leftColored' | 'rightColored'; -}>` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 50%; - border: 2px solid transparent; - border-top-color: ${p => p.theme.tokens.border.secondary}; - border-bottom-color: ${p => p.theme.tokens.border.secondary}; - border-left-color: ${p => - p.variant === 'leftColored' ? p.theme.tokens.border.secondary : 'transparent'}; - border-right-color: ${p => - p.variant === 'rightColored' ? p.theme.tokens.border.secondary : 'transparent'}; - animation: ${flyingLines} 1s linear infinite; - animation-delay: ${p => p.delay}s; - transform: ${p => (p.rotation ? `rotate(${p.rotation}deg)` : 'none')}; -`; - -const FlyingLinesContainer = styled('div')` - position: fixed; - width: 50px; - height: 50px; - transform: translate(-50%, -50%); - z-index: ${p => p.theme.zIndex.tooltip}; - opacity: 0.5; - pointer-events: none; - - &:before, - &:after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 50%; - border: 2px solid transparent; - border-top-color: ${p => p.theme.tokens.border.secondary}; - border-bottom-color: ${p => p.theme.tokens.border.secondary}; - animation: ${flyingLines} 1s linear infinite; - } - - &:before { - border-left-color: ${p => p.theme.tokens.border.secondary}; - border-right-color: transparent; - animation-delay: -0.4s; - } - - &:after { - border-left-color: transparent; - border-right-color: ${p => p.theme.tokens.border.secondary}; - animation-delay: -0.2s; - } -`; diff --git a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx b/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx deleted file mode 100644 index 9c2ac387b69d60..00000000000000 --- a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import {AutofixCodebaseChangeData} from 'sentry-fixture/autofixCodebaseChangeData'; -import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; -import {AutofixStepFixture} from 'sentry-fixture/autofixStep'; - -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {Button} from '@sentry/scraps/button'; - -import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges'; -import { - AutofixStatus, - AutofixStepType, - type AutofixChangesStep, -} from 'sentry/components/events/autofix/types'; -import { - useAutofixData, - useAutofixRepos, -} from 'sentry/components/events/autofix/useAutofix'; - -jest.mock('@sentry/scraps/button', () => ({ - Button: jest.fn(props => { - // Forward the click handler while allowing us to inspect props - return ; - }), - LinkButton: jest.fn(props => { - return {props.children}; - }), - ButtonBar: jest.fn(props => { - return
{props.children}
; - }), -})); - -jest.mock('sentry/components/events/autofix/useAutofix'); - -const mockButton = jest.mocked(Button); - -describe('AutofixChanges', () => { - const defaultProps = { - groupId: '123', - runId: '456', - step: AutofixStepFixture({ - type: AutofixStepType.CHANGES, - changes: [AutofixCodebaseChangeData()], - }) as AutofixChangesStep, - } satisfies React.ComponentProps; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - mockButton.mockClear(); - jest.mocked(useAutofixRepos).mockReset(); - jest.mocked(useAutofixData).mockReset(); - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [], - codebases: {}, - }); - jest.mocked(useAutofixData).mockReturnValue({ - data: { - request: { - repos: [], - }, - codebases: {}, - last_triggered_at: '2024-01-01T00:00:00Z', - run_id: '456', - status: AutofixStatus.COMPLETED, - }, - isPending: false, - }); - }); - - it('passes correct analytics props for Create PR button when write access is enabled', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', - method: 'GET', - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - body: {ok: true}, - }); - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'org/repo', - owner: 'org', - provider: 'github', - provider_raw: 'github', - external_id: '100', - is_readable: true, - is_writeable: true, - }, - ], - codebases: { - '100': { - repo_external_id: '100', - is_readable: true, - is_writeable: true, - }, - }, - }); - - render(); - await userEvent.click(screen.getByRole('button', {name: 'Draft PR'})); - - const createPRButtonCall = mockButton.mock.calls.find( - call => call[0]?.analyticsEventKey === 'autofix.create_pr_clicked' - ); - expect(createPRButtonCall?.[0]).toEqual( - expect.objectContaining({ - analyticsEventKey: 'autofix.create_pr_clicked', - analyticsEventName: 'Autofix: Create PR Clicked', - analyticsParams: {group_id: '123'}, - }) - ); - }); - - it('passes correct analytics props for Create PR Setup button when write access is not enabled', () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', - method: 'GET', - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: true, - repos: [{ok: false, owner: 'owner', name: 'hello-world', provider: 'github'}], - }, - }), - }); - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'org/repo', - owner: 'org', - provider: 'github', - provider_raw: 'github', - external_id: 'repo-123', - is_readable: true, - is_writeable: false, - }, - ], - codebases: { - 'repo-123': { - repo_external_id: 'repo-123', - is_readable: true, - is_writeable: false, - }, - }, - }); - - render(); - - // Find the last call to Button that matches our Setup button - const setupButtonCall = mockButton.mock.calls.find( - call => call[0].children === 'Draft PR' - ); - expect(setupButtonCall?.[0]).toEqual( - expect.objectContaining({ - analyticsEventKey: 'autofix.create_pr_setup_clicked', - analyticsEventName: 'Autofix: Create PR Setup Clicked', - analyticsParams: { - group_id: '123', - }, - }) - ); - }); - - it('passes correct analytics props for Create Branch button when write access is enabled', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', - method: 'GET', - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: true, - repos: [{ok: true, owner: 'owner', name: 'hello-world', provider: 'github'}], - }, - }), - }); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - body: {ok: true}, - }); - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'org/repo', - owner: 'org', - provider: 'github', - provider_raw: 'github', - external_id: '100', - is_readable: true, - is_writeable: true, - }, - ], - codebases: { - '100': { - repo_external_id: '100', - is_readable: true, - is_writeable: true, - }, - }, - }); - - render(); - - await userEvent.click(screen.getByRole('button', {name: 'Check Out Locally'})); - - const createBranchButtonCall = mockButton.mock.calls.find( - call => call[0]?.analyticsEventKey === 'autofix.push_to_branch_clicked' - ); - expect(createBranchButtonCall?.[0]).toEqual( - expect.objectContaining({ - analyticsEventKey: 'autofix.push_to_branch_clicked', - analyticsEventName: 'Autofix: Push to Branch Clicked', - analyticsParams: {group_id: '123'}, - }) - ); - }); - - it('passes correct analytics props for Create Branch Setup button when write access is not enabled', () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', - method: 'GET', - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: true, - repos: [{ok: false, owner: 'owner', name: 'hello-world', provider: 'github'}], - }, - }), - }); - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'org/repo', - owner: 'org', - provider: 'github', - provider_raw: 'github', - external_id: 'repo-123', - is_readable: true, - is_writeable: false, - }, - ], - codebases: { - 'repo-123': { - repo_external_id: 'repo-123', - is_readable: true, - is_writeable: false, - }, - }, - }); - - render(); - - const setupButtonCall = mockButton.mock.calls.find( - call => call[0].children === 'Check Out Locally' - ); - expect(setupButtonCall?.[0]).toEqual( - expect.objectContaining({ - analyticsEventKey: 'autofix.create_branch_setup_clicked', - analyticsEventName: 'Autofix: Create Branch Setup Clicked', - analyticsParams: { - group_id: '123', - }, - }) - ); - }); -}); diff --git a/static/app/components/events/autofix/autofixChanges.spec.tsx b/static/app/components/events/autofix/autofixChanges.spec.tsx deleted file mode 100644 index f9591f372c9090..00000000000000 --- a/static/app/components/events/autofix/autofixChanges.spec.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import {AutofixCodebaseChangeData} from 'sentry-fixture/autofixCodebaseChangeData'; - -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {AutofixChanges} from './autofixChanges'; -import {AutofixStatus, AutofixStepType} from './types'; - -const mockUseAutofix = jest.fn(); -jest.mock('sentry/components/events/autofix/useAutofix', () => ({ - ...jest.requireActual('sentry/components/events/autofix/useAutofix'), - useAutofixData: () => mockUseAutofix(), -})); - -const mockUseAutofixSetup = jest.fn(); -jest.mock('sentry/components/events/autofix/useAutofixSetup', () => ({ - useAutofixSetup: () => mockUseAutofixSetup(), -})); - -const mockUpdateInsightCard = jest.fn(); -jest.mock('sentry/components/events/autofix/hooks/useUpdateInsightCard', () => ({ - useUpdateInsightCard: () => ({ - mutate: mockUpdateInsightCard, - }), -})); - -describe('AutofixChanges', () => { - const defaultProps = { - groupId: '123', - runId: 'run-123', - step: { - id: 'step-123', - progress: [], - title: 'Changes', - type: AutofixStepType.CHANGES as const, - index: 0, - status: AutofixStatus.COMPLETED, - changes: [AutofixCodebaseChangeData({pull_request: undefined})], - }, - } satisfies React.ComponentProps; - - beforeEach(() => { - mockUseAutofix.mockReturnValue({ - status: 'COMPLETED', - steps: [ - { - type: AutofixStepType.DEFAULT, - index: 0, - insights: [], - }, - ], - }); - - mockUseAutofixSetup.mockReturnValue({ - data: { - githubWriteIntegration: { - repos: [ - { - owner: 'getsentry', - name: 'sentry', - ok: true, - }, - ], - }, - }, - }); - }); - - it('renders error state when step has error', () => { - render( - - ); - - expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); - }); - - it('renders empty state when no changes', () => { - render( - - ); - - expect( - screen.getByText('Seer had trouble applying its code changes.') - ).toBeInTheDocument(); - }); - - it('renders changes with action buttons', () => { - render(); - - expect(screen.getByText('Code Changes')).toBeInTheDocument(); - expect(screen.getByText('Add error handling')).toBeInTheDocument(); - expect(screen.getByText('owner/hello-world')).toBeInTheDocument(); - - expect(screen.getByRole('button', {name: 'Check Out Locally'})).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Draft PR'})).toBeInTheDocument(); - }); - - it('shows PR links when PRs are created', () => { - const changeWithPR = AutofixCodebaseChangeData({ - pull_request: { - pr_number: 123, - pr_url: 'https://github.com/owner/hello-world/pull/123', - }, - }); - - render( - - ); - - expect( - screen.getByRole('button', {name: 'View PR in owner/hello-world'}) - ).toBeInTheDocument(); - }); - - it('shows branch checkout buttons when branches are created', () => { - const changeWithBranch = AutofixCodebaseChangeData({ - branch_name: 'fix/issue-123', - pull_request: undefined, - }); - - render( - - ); - - expect( - screen.getByRole('button', {name: 'Copy branch in owner/hello-world'}) - ).toBeInTheDocument(); - }); -}); diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx deleted file mode 100644 index cc449251ba8ed2..00000000000000 --- a/static/app/components/events/autofix/autofixChanges.tsx +++ /dev/null @@ -1,766 +0,0 @@ -import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; -import styled from '@emotion/styled'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; -import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion'; - -import {Alert} from '@sentry/scraps/alert'; -import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Grid} from '@sentry/scraps/layout'; -import {useModal} from '@sentry/scraps/modal'; - -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {ClippedBox} from 'sentry/components/clippedBox'; -import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff'; -import {AutofixHighlightPopup} from 'sentry/components/events/autofix/autofixHighlightPopup'; -import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper'; -import {replaceHeadersWithBold} from 'sentry/components/events/autofix/autofixRootCause'; -import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal'; -import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback'; -import { - AutofixStatus, - type AutofixChangesStep, - type AutofixCodebaseChange, - type CommentThread, -} from 'sentry/components/events/autofix/types'; -import { - autofixApiOptions, - useAutofixData, - useAutofixRepos, -} from 'sentry/components/events/autofix/useAutofix'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {ScrollCarousel} from 'sentry/components/scrollCarousel'; -import {IconChat, IconCode, IconCopy, IconOpen} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {singleLineRenderer} from 'sentry/utils/marked/marked'; -import {MarkedText} from 'sentry/utils/marked/markedText'; -import {useApi} from 'sentry/utils/useApi'; -import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -type AutofixChangesProps = { - groupId: string; - runId: string; - step: AutofixChangesStep; - agentCommentThread?: CommentThread; - isChangesFirstAppearance?: boolean; - previousDefaultStepIndex?: number; - previousInsightCount?: number; -}; - -function AutofixRepoChange({ - change, - groupId, - runId, - previousDefaultStepIndex, - previousInsightCount, - ref, -}: { - change: AutofixCodebaseChange; - groupId: string; - runId: string; - previousDefaultStepIndex?: number; - previousInsightCount?: number; - ref?: React.RefObject; -}) { - const changeDescriptionHtml = useMemo(() => { - return { - __html: singleLineRenderer(change.description), - }; - }, [change.description]); - - return ( - - -
- = 0 - ? previousInsightCount - : null - } - > -
- {change.repo_name} - {change.title} -

-

-
-
-
- -
- ); -} - -const cardAnimationProps: MotionNodeAnimationOptions = { - exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, - initial: {opacity: 0, height: 0, scale: 0.8}, - animate: {opacity: 1, height: 'auto', scale: 1}, - transition: { - duration: 1, - height: { - type: 'spring', - bounce: 0.2, - }, - scale: { - type: 'spring', - bounce: 0.2, - }, - y: { - type: 'tween', - ease: 'easeOut', - }, - }, -}; - -function BranchButton({change}: {change: AutofixCodebaseChange}) { - const {copy} = useCopyToClipboard(); - - return ( - - - copy(change.branch_name ?? '', { - successMessage: t('Branch name copied to clipboard.'), - }) - } - icon={} - aria-label={t('Copy branch in %s', change.repo_name)} - tooltipProps={{title: t('Copy branch in %s', change.repo_name)}} - /> - {change.branch_name} - - ); -} - -const CopyContainer = styled('div')` - display: inline-flex; - align-items: stretch; - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - background: ${p => p.theme.tokens.background.secondary}; - max-width: 25rem; - min-width: 0; - flex: 1; - flex-shrink: 1; -`; - -const CopyButton = styled(Button)` - border: none; - border-radius: ${p => p.theme.radius.md} 0 0 ${p => p.theme.radius.md}; - border-right: 1px solid ${p => p.theme.tokens.border.primary}; - height: auto; - flex-shrink: 0; -`; - -const CodeText = styled('code')` - font-family: ${p => p.theme.font.family.mono}; - padding: ${p => p.theme.space.xs} ${p => p.theme.space.md}; - font-size: ${p => p.theme.font.size.sm}; - display: block; - min-width: 0; - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -export function AutofixChanges({ - step, - groupId, - runId, - previousDefaultStepIndex, - previousInsightCount, - agentCommentThread, - isChangesFirstAppearance, -}: AutofixChangesProps) { - const {data} = useAutofixData({groupId}); - const isBusy = step.status === AutofixStatus.PROCESSING; - const iconCodeRef = useRef(null); - const firstChangeRef = useRef(null); - const [isPrProcessing, setIsPrProcessing] = useState(false); - const [isBranchProcessing, setIsBranchProcessing] = useState(false); - - const handleSelectFirstChange = () => { - if (firstChangeRef.current) { - // Simulate a click on the first change to trigger the text selection - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - }); - firstChangeRef.current.dispatchEvent(clickEvent); - } - }; - - useEffect(() => { - if (step.status === AutofixStatus.COMPLETED) { - const prsNowExist = - step.changes.length > 0 && step.changes.every(c => c.pull_request); - const branchesNowExist = - step.changes.length > 0 && step.changes.every(c => c.branch_name); - - if (prsNowExist) { - setIsPrProcessing(false); - } - if (branchesNowExist) { - setIsBranchProcessing(false); - } - } - }, [step.status, step.changes]); - - if (step.status === 'ERROR' || data?.status === 'ERROR') { - return ( - - - {data?.error_message ? ( - - {t('Something went wrong')} - {data.error_message} - - ) : ( - {t('Something went wrong.')} - )} - - - ); - } - - if (!step.changes.length) { - return ( - - - - - - - - - - ); - } - - const prsMade = - step.status === AutofixStatus.COMPLETED && - step.changes.length >= 1 && - step.changes.every(change => change.pull_request); - - const branchesMade = - step.status === AutofixStatus.COMPLETED && - step.changes.length >= 1 && - step.changes.every(change => change.branch_name); - - return ( - - - - - - - - - {t('Code Changes')} - - - - - {agentCommentThread && iconCodeRef.current && ( - = 0 - ? previousInsightCount - : null - } - isAgentComment - blockName={t('Seer is uncertain of the code changes...')} - /> - )} - - - {step.changes.map((change, i) => ( - - {i > 0 && } - - - ))} - - - - {step.termination_reason && ( - {step.termination_reason} - )} - - {!prsMade && ( - - {branchesMade ? ( - step.changes.length === 1 && step.changes[0] ? ( - - ) : ( - - {step.changes.map( - (change, idx) => - change.branch_name && ( - - ) - )} - - ) - ) : ( - - )} - - - )} - {step.status === AutofixStatus.COMPLETED && ( - - )} - {prsMade && - (step.changes.length === 1 && step.changes[0]?.pull_request?.pr_url ? ( - } - href={step.changes[0].pull_request.pr_url} - external - > - View PR in {step.changes[0].repo_name} - - ) : ( - - {step.changes.map( - (change, idx) => - change.pull_request?.pr_url && ( - } - href={change.pull_request.pr_url} - external - > - View PR in {change.repo_name} - - ) - )} - - ))} - - - - - - ); -} - -const PreviewContent = styled('div')` - display: flex; - flex-direction: column; - color: ${p => p.theme.tokens.content.primary}; - margin-top: ${p => p.theme.space.xl}; -`; - -const AnimationWrapper = styled(motion.div)` - transform-origin: top center; -`; - -const PrefixText = styled('span')``; - -const ChangesContainer = styled('div')` - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - box-shadow: ${p => p.theme.shadow.medium}; - padding: ${p => p.theme.space.xl}; - background: ${p => p.theme.tokens.background.primary}; -`; - -const Content = styled('div')` - padding: 0 0 ${p => p.theme.space.md}; -`; - -const Title = styled('div')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - margin-top: ${p => p.theme.space.md}; - margin-bottom: ${p => p.theme.space.md}; - text-decoration: underline dashed; - text-decoration-color: ${p => p.theme.tokens.content.accent}; - text-decoration-thickness: 1px; - text-underline-offset: 4px; -`; - -const PullRequestTitle = styled('div')` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const RepoChangesHeader = styled('div')` - display: grid; - align-items: center; - grid-template-columns: 1fr auto; -`; - -const MarkdownAlert = styled(MarkedText)` - border: 1px solid ${p => p.theme.colors.yellow200}; - background-color: ${p => p.theme.colors.yellow100}; - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl}; - border-radius: ${p => p.theme.radius.md}; - color: ${p => p.theme.colors.yellow500}; -`; - -const NoChangesPadding = styled('div')` - padding: 0 ${p => p.theme.space.xl}; -`; - -const Separator = styled('hr')` - border: none; - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; - margin: ${p => p.theme.space.xl} -${p => p.theme.space.xl} 0 -${p => p.theme.space.xl}; -`; - -const HeaderText = styled('div')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - font-size: ${p => p.theme.font.size.lg}; - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - margin-right: ${p => p.theme.space.xl}; -`; - -const BottomDivider = styled('div')` - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; - margin-top: ${p => p.theme.space.xl}; - margin-bottom: ${p => p.theme.space.xl}; -`; - -const BottomButtonContainer = styled('div')<{hasTerminationReason?: boolean}>` - display: flex; - justify-content: ${p => (p.hasTerminationReason ? 'space-between' : 'flex-end')}; - align-items: center; - gap: ${p => p.theme.space.xl}; -`; - -const TerminationReasonText = styled('div')` - color: ${p => p.theme.tokens.content.danger}; - font-size: ${p => p.theme.font.size.sm}; - flex: 1; - min-width: 0; -`; - -function CreatePRsButton({ - changes, - groupId, - runId, - isBusy, - onProcessingChange, -}: { - changes: AutofixCodebaseChange[]; - groupId: string; - isBusy: boolean; - onProcessingChange: (processing: boolean) => void; - runId: string; -}) { - const api = useApi(); - const queryClient = useQueryClient(); - const [hasClicked, setHasClicked] = useState(false); - const orgSlug = useOrganization().slug; - - // Reset hasClicked state and notify parent when isBusy goes from true to false - useEffect(() => { - if (!isBusy) { - setHasClicked(false); - onProcessingChange(false); - } - }, [isBusy, onProcessingChange]); - - const {mutate: createPr} = useMutation({ - mutationFn: ({change}: {change: AutofixCodebaseChange}) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'create_pr', - repo_external_id: change.repo_external_id, - }, - }, - } - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - setHasClicked(true); - }, - onError: () => { - addErrorMessage(t('Failed to create a pull request')); - setHasClicked(false); - onProcessingChange(false); - }, - }); - - const createPRs = () => { - setHasClicked(true); - onProcessingChange(true); - for (const change of changes) { - createPr({change}); - } - }; - - return ( - - ); -} - -function CreateBranchButton({ - changes, - groupId, - runId, - isBusy, - onProcessingChange, -}: { - changes: AutofixCodebaseChange[]; - groupId: string; - isBusy: boolean; - onProcessingChange: (processing: boolean) => void; - runId: string; -}) { - const api = useApi(); - const queryClient = useQueryClient(); - const [hasClicked, setHasClicked] = useState(false); - const orgSlug = useOrganization().slug; - - // Reset hasClicked state and notify parent when isBusy goes from true to false - useEffect(() => { - if (!isBusy) { - setHasClicked(false); - onProcessingChange(false); - } - }, [isBusy, onProcessingChange]); - - const {mutate: createBranch} = useMutation({ - mutationFn: ({change}: {change: AutofixCodebaseChange}) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'create_branch', - repo_external_id: change.repo_external_id, - }, - }, - } - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - }, - onError: () => { - addErrorMessage(t('Failed to push to branches.')); - setHasClicked(false); - onProcessingChange(false); - }, - }); - - const pushToBranch = () => { - setHasClicked(true); - onProcessingChange(true); - for (const change of changes) { - createBranch({change}); - } - }; - - return ( - - ); -} - -function SetupAndCreateBranchButton({ - changes, - groupId, - runId, - isBusy, - onProcessingChange, -}: { - changes: AutofixCodebaseChange[]; - groupId: string; - isBusy: boolean; - onProcessingChange: (processing: boolean) => void; - runId: string; -}) { - const {openModal} = useModal(); - - const {codebases} = useAutofixRepos(groupId); - - if ( - !changes.every( - change => - change.repo_external_id && codebases[change.repo_external_id]?.is_writeable - ) - ) { - return ( - - ); - } - - return ( - - ); -} - -function SetupAndCreatePRsButton({ - changes, - groupId, - runId, - isBusy, - onProcessingChange, -}: { - changes: AutofixCodebaseChange[]; - groupId: string; - isBusy: boolean; - onProcessingChange: (processing: boolean) => void; - runId: string; -}) { - const {openModal} = useModal(); - - const {codebases} = useAutofixRepos(groupId); - if ( - !changes.every( - change => - change.repo_external_id && codebases[change.repo_external_id]?.is_writeable - ) - ) { - return ( - - ); - } - - return ( - - ); -} diff --git a/static/app/components/events/autofix/autofixDiff.spec.tsx b/static/app/components/events/autofix/autofixDiff.spec.tsx deleted file mode 100644 index 5a1c9f876fcebd..00000000000000 --- a/static/app/components/events/autofix/autofixDiff.spec.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import {AutofixDiffFilePatch} from 'sentry-fixture/autofixDiffFilePatch'; - -import { - render, - screen, - userEvent, - waitFor, - within, -} from 'sentry-test/reactTestingLibrary'; -import {textWithMarkupMatcher} from 'sentry-test/utils'; - -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff'; - -jest.mock('sentry/actionCreators/indicator'); - -describe('AutofixDiff', () => { - const defaultProps = { - diff: [AutofixDiffFilePatch()], - groupId: '1', - runId: '1', - editable: true, - } satisfies React.ComponentProps; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - jest.mocked(addErrorMessage).mockClear(); - }); - - it('displays a modified file diff correctly', () => { - render(); - - // File path - expect( - screen.getByText('src/sentry/processing/backpressure/memory.py') - ).toBeInTheDocument(); - - // Lines changed - expect(screen.getByText('+1')).toBeInTheDocument(); - expect(screen.getByText('-1')).toBeInTheDocument(); - - // Hunk section header - expect( - screen.getByText( - textWithMarkupMatcher( - '@@ -47,7 +47,7 @@ def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory:' - ) - ) - ).toBeInTheDocument(); - - // One removed line - const removedLine = screen.getByTestId('line-removed'); - expect( - within(removedLine).getByText( - textWithMarkupMatcher( - 'memory_available = info.get("maxmemory", 0) or info["total_system_memory"]' - ) - ) - ).toBeInTheDocument(); - - // One added line - const addedLine = screen.getByTestId('line-added'); - expect( - within(addedLine).getByText( - textWithMarkupMatcher( - 'memory_available = info.get("maxmemory", 0) or info.get("total_system_memory", 0)' - ) - ) - ).toBeInTheDocument(); - - // 6 context lines - expect(screen.getAllByTestId('line-context')).toHaveLength(6); - }); - - it('can collapse a file diff', async () => { - render(); - - expect(screen.getAllByTestId('line-context')).toHaveLength(6); - - // Clicking toggle hides file context - await userEvent.click(screen.getByRole('button', {name: 'Toggle file diff'})); - expect(screen.queryByTestId('line-context')).not.toBeInTheDocument(); - - // Clicking again shows file context - await userEvent.click(screen.getByRole('button', {name: 'Toggle file diff'})); - expect(screen.getAllByTestId('line-context')).toHaveLength(6); - }); - - it('can edit changes', async () => { - render(); - - await userEvent.click(screen.getByRole('button', {name: 'Edit changes'})); - - expect( - screen.getByRole('heading', { - name: 'Editing src/sentry/processing/backpressure/memory.py', - }) - ).toBeInTheDocument(); - expect( - screen.getAllByText('src/sentry/processing/backpressure/memory.py') - ).toHaveLength(2); // one in the header of the diff and one in the popup - - const textarea = screen.getAllByRole('textbox')[0]; - await userEvent.clear(textarea!); - await userEvent.type(textarea!, 'New content'); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/update/', - method: 'POST', - }); - - await userEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(screen.queryByText('Editing')).not.toBeInTheDocument(); - }); - expect( - screen.getAllByText('src/sentry/processing/backpressure/memory.py') - ).toHaveLength(1); // one in the header of the diff and none in the popup - }); - - it('can reject changes', async () => { - render(); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/update/', - method: 'POST', - }); - - await userEvent.click(screen.getByRole('button', {name: 'Reject changes'})); - - await waitFor(() => { - expect(screen.queryByTestId('line-added')).not.toBeInTheDocument(); - }); - expect(screen.queryByTestId('line-removed')).not.toBeInTheDocument(); - }); - - it('shows error message on failed edit', async () => { - render(); - - await userEvent.click(screen.getByRole('button', {name: 'Edit changes'})); - - const textarea = screen.getAllByRole('textbox')[0]; - await userEvent.clear(textarea!); - await userEvent.type(textarea!, 'New content'); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/update/', - method: 'POST', - statusCode: 500, - }); - - await userEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(addErrorMessage).toHaveBeenCalledWith( - 'Something went wrong when updating changes.' - ); - }); - }); - - it('does not show edit buttons when editable is false', () => { - render(); - - expect(screen.queryByRole('button', {name: 'Edit changes'})).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', {name: 'Reject changes'}) - ).not.toBeInTheDocument(); - - // Ensure the diff content is still visible - expect( - screen.getByText('src/sentry/processing/backpressure/memory.py') - ).toBeInTheDocument(); - expect(screen.getByTestId('line-added')).toBeInTheDocument(); - expect(screen.getByTestId('line-removed')).toBeInTheDocument(); - }); -}); diff --git a/static/app/components/events/autofix/autofixDiff.tsx b/static/app/components/events/autofix/autofixDiff.tsx deleted file mode 100644 index 4967d24bbe5096..00000000000000 --- a/static/app/components/events/autofix/autofixDiff.tsx +++ /dev/null @@ -1,923 +0,0 @@ -import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; -import {createPortal} from 'react-dom'; -import styled from '@emotion/styled'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; -import {diffWords, type Change} from 'diff'; - -import {Button} from '@sentry/scraps/button'; -import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {TextArea} from '@sentry/scraps/textarea'; - -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper'; -import { - DiffLineType, - type DiffLine, - type FilePatch, -} from 'sentry/components/events/autofix/types'; -import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix'; -import {DIFF_COLORS} from 'sentry/components/splitDiff'; -import {IconChevron, IconClose, IconDelete, IconEdit} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {getPrismLanguage} from 'sentry/utils/prism'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {usePrismTokens} from 'sentry/utils/usePrismTokens'; - -type AutofixDiffProps = { - diff: FilePatch[]; - editable: boolean; - groupId: string; - runId: string; - integratedStyle?: boolean; - previousDefaultStepIndex?: number; - previousInsightCount?: number; - repoId?: string; -}; - -interface DiffLineWithChanges extends DiffLine { - changes?: Change[]; -} - -function makeTestIdFromLineType(lineType: DiffLineType) { - switch (lineType) { - case DiffLineType.ADDED: - return 'line-added'; - case DiffLineType.REMOVED: - return 'line-removed'; - default: - return 'line-context'; - } -} - -function addChangesToDiffLines(lines: DiffLineWithChanges[]): DiffLineWithChanges[] { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!; - if (line.line_type === DiffLineType.CONTEXT) { - continue; - } - - if (line.line_type === DiffLineType.REMOVED) { - const prevLine = lines[i - 1]; - const nextLine = lines[i + 1]; - const nextNextLine = lines[i + 2]; - - if ( - nextLine?.line_type === DiffLineType.ADDED && - prevLine?.line_type !== DiffLineType.REMOVED && - nextNextLine?.line_type !== DiffLineType.ADDED - ) { - const changes = diffWords(line.value, nextLine.value); - lines[i] = {...line, changes: changes.filter(change => !change.added)}; - lines[i + 1] = {...nextLine, changes: changes.filter(change => !change.removed)}; - } - } - } - - return lines; -} - -function detectLanguageFromPath(filePath: string): string { - if (!filePath) { - return 'plaintext'; - } - const extension = filePath.split('.').pop()?.toLowerCase(); - if (!extension) { - return 'plaintext'; - } - - const language = getPrismLanguage(extension); - return language || 'plaintext'; -} - -const SyntaxHighlightedCode = styled('div')` - font-family: ${p => p.theme.font.family.mono}; - white-space: pre; - - && pre, - && code { - margin: 0; - padding: 0; - background: transparent; - } -`; - -function DiffLineCode({line, fileName}: {line: DiffLineWithChanges; fileName?: string}) { - const language = useMemo( - () => (fileName ? detectLanguageFromPath(fileName) : 'plaintext'), - [fileName] - ); - - const tokens = usePrismTokens({code: line.value, language}); - - // If we have changes (diff), use the CodeDiff component - if (line.changes) { - return ( - - {line.changes.map((change, i) => ( - - {change.value} - - ))} - - ); - } - - // For non-changed lines, apply syntax highlighting - return ( - -
-        
-          {tokens.map((lineTokens, i) => (
-            
-              {lineTokens.map((token, j) => (
-                
-                  {token.children}
-                
-              ))}
-            
-          ))}
-        
-      
-
- ); -} - -function HunkHeader({lines, sectionHeader}: {lines: DiffLine[]; sectionHeader: string}) { - const {sourceStart, sourceLength, targetStart, targetLength} = useMemo( - () => ({ - sourceStart: lines.at(0)?.source_line_no ?? 0, - sourceLength: lines.filter(line => line.line_type !== DiffLineType.ADDED).length, - targetStart: lines.at(0)?.target_line_no ?? 0, - targetLength: lines.filter(line => line.line_type !== DiffLineType.REMOVED).length, - }), - [lines] - ); - - return ( - {`@@ -${sourceStart},${sourceLength} +${targetStart},${targetLength} @@ ${sectionHeader ? ' ' + sectionHeader : ''}`} - ); -} - -function useUpdateHunk({groupId, runId}: {groupId: string; runId: string}) { - const api = useApi({persistInFlight: true}); - const queryClient = useQueryClient(); - const orgSlug = useOrganization().slug; - - return useMutation({ - mutationFn: (params: { - fileName: string; - hunkIndex: number; - lines: DiffLine[]; - repoId?: string; - }) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'update_code_change', - repo_id: params.repoId ?? null, - hunk_index: params.hunkIndex, - lines: params.lines, - file_path: params.fileName, - }, - }, - } - ); - }, - onSuccess: _ => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - }, - onError: () => { - addErrorMessage(t('Something went wrong when updating changes.')); - }, - }); -} - -function DiffHunkContent({ - groupId, - runId, - repoId, - hunkIndex, - lines, - header, - fileName, - editable, -}: { - editable: boolean; - fileName: string; - groupId: string; - header: string; - hunkIndex: number; - lines: DiffLine[]; - runId: string; - repoId?: string; -}) { - const [linesWithChanges, setLinesWithChanges] = useState([]); - - useEffect(() => { - setLinesWithChanges(addChangesToDiffLines(lines)); - }, [lines]); - - const [editingGroup, setEditingGroup] = useState(null); - const [editedContent, setEditedContent] = useState(''); - const [editedLines, setEditedLines] = useState([]); - const overlayRef = useRef(null); - const [hoveredGroup, setHoveredGroup] = useState(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (overlayRef.current && !overlayRef.current.contains(event.target as Node)) { - setEditingGroup(null); - setEditedContent(''); - } - } - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [overlayRef]); - - const lineGroups = useMemo(() => { - const groups: Array<{end: number; start: number; type: 'change' | DiffLineType}> = []; - let currentGroup: (typeof groups)[number] | null = null; - - linesWithChanges.forEach((line, index) => { - if (line.line_type !== DiffLineType.CONTEXT) { - if (!currentGroup) { - currentGroup = {start: index, end: index, type: 'change'}; - } else if (currentGroup.type === 'change') { - currentGroup.end = index; - } else { - groups.push(currentGroup); - currentGroup = {start: index, end: index, type: 'change'}; - } - } else if (currentGroup) { - groups.push(currentGroup); - currentGroup = null; - } - }); - - if (currentGroup) { - groups.push(currentGroup); - } - - return groups; - }, [linesWithChanges]); - - const handleEditClick = (index: number) => { - const group = lineGroups.find(g => g.start === index); - if (group) { - const content = linesWithChanges - .slice(group.start, group.end + 1) - .filter(line => line.line_type === DiffLineType.ADDED) - .map(line => line.value) - .join(''); - const splitLines = content.split('\n'); - if (splitLines[splitLines.length - 1] === '') { - splitLines.pop(); - } - setEditedLines(splitLines); - if (content === '\n') { - setEditedContent(''); - } else { - setEditedContent(content.endsWith('\n') ? content.slice(0, -1) : content); - } - setEditingGroup(index); - } - }; - - const handleTextAreaChange = (e: React.ChangeEvent) => { - const newContent = e.target.value; - setEditedContent(newContent); - setEditedLines(newContent.split('\n')); - }; - - const updateHunk = useUpdateHunk({groupId, runId}); - const handleSaveEdit = () => { - if (editingGroup === null) { - return; - } - const group = lineGroups.find(g => g.start === editingGroup); - if (!group) { - return; - } - - let lastSourceLineNo = 0; - let lastTargetLineNo = 0; - let lastDiffLineNo = 0; - - const updatedLines = linesWithChanges - .map((line, index) => { - if (index < group.start) { - lastSourceLineNo = line.source_line_no ?? lastSourceLineNo; - lastTargetLineNo = line.target_line_no ?? lastTargetLineNo; - lastDiffLineNo = line.diff_line_no ?? lastDiffLineNo; - } - if (index >= group.start && index <= group.end) { - if (line.line_type === DiffLineType.ADDED) { - return null; // Remove existing added lines - } - if (line.line_type === DiffLineType.REMOVED) { - lastSourceLineNo = line.source_line_no ?? lastSourceLineNo; - } - return line; // Keep other lines (removed and context) as is - } - return line; - }) - .filter((line): line is DiffLine => line !== null); - - // Insert new added lines - const newAddedLines: DiffLine[] = editedContent.split('\n').map((content, i) => { - lastDiffLineNo++; - lastTargetLineNo++; - return { - diff_line_no: lastDiffLineNo, - source_line_no: null, - target_line_no: lastTargetLineNo, - line_type: DiffLineType.ADDED, - value: content + (i === editedContent.split('\n').length - 1 ? '' : '\n'), - }; - }); - - // Find the insertion point (after the last removed line or at the start of the group) - const insertionIndex = updatedLines.findIndex( - (line, index) => index >= group.start && line.line_type !== DiffLineType.REMOVED - ); - - updatedLines.splice( - insertionIndex === -1 ? group.start : insertionIndex, - 0, - ...newAddedLines - ); - - // Update diff_line_no for all lines after the insertion - for (let i = insertionIndex + newAddedLines.length; i < updatedLines.length; i++) { - updatedLines[i]!.diff_line_no = ++lastDiffLineNo; - } - - updateHunk.mutate({hunkIndex, lines: updatedLines, repoId, fileName}); - setLinesWithChanges(addChangesToDiffLines(updatedLines)); - setEditingGroup(null); - setEditedContent(''); - }; - - const handleCancelEdit = () => { - setEditingGroup(null); - setEditedContent(''); - }; - - const rejectChanges = (index: number) => { - const group = lineGroups.find(g => g.start === index); - if (!group) { - return; - } - - const updatedLines = linesWithChanges - .map((line, i) => { - if (i >= group.start && i <= group.end) { - if (line.line_type === DiffLineType.ADDED) { - return null; // Remove added lines - } - if (line.line_type === DiffLineType.REMOVED) { - return {...line, line_type: DiffLineType.CONTEXT}; // Convert removed lines to context - } - } - return line; - }) - .filter((line): line is DiffLine => line !== null); - - updateHunk.mutate({hunkIndex, lines: updatedLines, repoId, fileName}); - setLinesWithChanges(addChangesToDiffLines(updatedLines)); - }; - - const getStartLineNumber = (index: number, lineType: DiffLineType) => { - const line = linesWithChanges[index]!; - if (lineType === DiffLineType.REMOVED) { - return line.source_line_no; - } - if (lineType === DiffLineType.ADDED) { - // Find the first non-null target_line_no - for (let i = index; i < linesWithChanges.length; i++) { - if (linesWithChanges[i]!.target_line_no !== null) { - return linesWithChanges[i]!.target_line_no; - } - } - } - return null; - }; - - const handleClearChanges = () => { - setEditedContent(''); - setEditedLines([]); - }; - - const getDeletedLineTitle = (index: number) => { - return t( - '%s deleted line%s%s', - linesWithChanges - .slice(index, lineGroups.find(g => g.start === index)?.end! + 1) - .filter(l => l.line_type === DiffLineType.REMOVED).length, - linesWithChanges - .slice(index, lineGroups.find(g => g.start === index)?.end) - .filter(l => l.line_type === DiffLineType.REMOVED).length === 1 - ? '' - : 's', - linesWithChanges - .slice(index, lineGroups.find(g => g.start === index)?.end) - .some(l => l.line_type === DiffLineType.REMOVED) - ? t(' from line %s', getStartLineNumber(index, DiffLineType.REMOVED)) - : '' - ); - }; - - const getNewLineTitle = (index: number) => { - return t( - '%s new line%s%s', - editedLines.length, - editedLines.length === 1 ? '' : 's', - editedLines.length > 0 - ? t(' from line %s', getStartLineNumber(index, DiffLineType.ADDED)) - : '' - ); - }; - - return ( - - - - {linesWithChanges.map((line, index) => ( - - {line.source_line_no} - {line.target_line_no} - { - const group = lineGroups.find(g => index >= g.start && index <= g.end); - if (group) { - setHoveredGroup(group.start); - } - }} - onMouseLeave={() => setHoveredGroup(null)} - > - - {editable && lineGroups.some(group => index === group.start) && ( - - } - aria-label={t('Edit changes')} - tooltipProps={{title: t('Edit')}} - onClick={() => handleEditClick(index)} - isHovered={hoveredGroup === index} - /> - } - aria-label={t('Reject changes')} - tooltipProps={{title: t('Reject')}} - onClick={() => rejectChanges(index)} - isHovered={hoveredGroup === index} - /> - - )} - - - ))} - {editingGroup !== null && - document.body && - createPortal( - - - - {t('Editing')} {fileName} - - - - {getDeletedLineTitle(editingGroup)} - {linesWithChanges - .slice( - editingGroup, - lineGroups.find(g => g.start === editingGroup)?.end! + 1 - ) - .some(l => l.line_type === DiffLineType.REMOVED) ? ( - - {linesWithChanges - .slice( - editingGroup, - lineGroups.find(g => g.start === editingGroup)?.end! + 1 - ) - .filter(l => l.line_type === DiffLineType.REMOVED) - .map((l, i) => ( - {l.value} - ))} - - ) : ( - {t('No lines are being deleted.')} - )} - {getNewLineTitle(editingGroup)} - - - } - tooltipProps={{title: t('Clear all new lines')}} - /> - - - - - - - - - , - document.body - )} - - ); -} - -function FileDiff({ - file, - groupId, - runId, - repoId, - editable, - integratedStyle, -}: { - editable: boolean; - file: FilePatch; - groupId: string; - integratedStyle: boolean; - runId: string; - repoId?: string; -}) { - const [isExpanded, setIsExpanded] = useState(true); - - const containerRef = useRef(null); - - return ( - - {!integratedStyle && ( - setIsExpanded(value => !value)}> - - - +{file.added} - -{file.removed} - - {file.path} - - - ); - } - if (activeLog) { - return ( - - - - - {seerIconRef?.current && isInitializingRun && ( - - )} - - - - - ); - } - return null; -} - -export function AutofixOutputStream({ - stream, - activeLog = '', - groupId, - runId, - autofixData, - responseRequired = false, -}: Props) { - const api = useApi({persistInFlight: true}); - const queryClient = useQueryClient(); - - const [message, setMessage] = useState(''); - const seerIconRef = useRef(null); - - const isInitializingRun = activeLog === 'Ingesting Sentry data...'; - - const orgSlug = useOrganization().slug; - - const {mutate: send} = useMutation({ - mutationFn: (params: {message: string}) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'user_message', - text: params.message, - }, - }, - } - ); - }, - onSuccess: _ => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - addSuccessMessage(t('Thanks for the input.')); - }, - onError: () => { - addErrorMessage(t('Something went wrong when sending Seer your message.')); - }, - }); - - const handleSend = (e: FormEvent) => { - e.preventDefault(); - if (isInitializingRun) { - // don't send message during loading state - return; - } - if (message.trim() !== '') { - send({message}); - setMessage(''); - } - }; - - return ( - - - - - - {getAutofixRunErrorMessage(autofixData) || activeLog ? ( - - ) : null} - {autofixData && ( - - - - )} - {!responseRequired && stream && } - - setMessage(e.target.value)} - maxLength={4096} - placeholder={ - responseRequired ? 'Please answer to continue...' : 'Add context...' - } - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(e); - } - }} - maxRows={5} - size="sm" - /> - - {'\u23CE'} - - - - - - - ); -} - -const Wrapper = styled(motion.div)` - display: flex; - flex-direction: column; - align-items: flex-start; - margin-bottom: ${p => p.theme.space.md}; - gap: ${p => p.theme.space.md}; -`; - -const ScaleContainer = styled(motion.div)` - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - transform-origin: top left; -`; - -const shimmer = keyframes` - 0% { - background-position: -1000px 0; - } - 100% { - background-position: 1000px 0; - } -`; - -const Container = styled(motion.div)<{required: boolean}>` - position: relative; - width: 100%; - background: ${p => p.theme.tokens.background.primary}; - border-radius: ${p => p.theme.radius.md}; - border: 1px dashed ${p => p.theme.tokens.border.primary}; - - &:before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - transparent, - color-mix( - in srgb, - ${p => - p.required - ? p.theme.colors.pink500 - : p.theme.tokens.background.accent.vibrant} - 12.5%, - transparent - ), - transparent - ); - background-size: 2000px 100%; - border-radius: ${p => p.theme.radius.md}; - animation: ${shimmer} 2s infinite linear; - pointer-events: none; - } -`; - -const StreamContent = styled('div')` - margin: 0; - padding: ${p => p.theme.space.xl}; - white-space: pre-wrap; - word-break: break-word; - color: ${p => p.theme.tokens.content.secondary}; - max-height: 35vh; - overflow-y: auto; - display: flex; - flex-direction: column-reverse; -`; - -const ActiveLogWrapper = styled('div')` - display: flex; - align-items: flex-start; - justify-content: space-between; - padding: ${p => p.theme.space.md}; - background: ${p => p.theme.tokens.background.secondary}; - gap: ${p => p.theme.space.md}; - overflow: visible; -`; - -const ActiveLog = styled('div')` - flex-grow: 1; - word-break: break-word; - margin-top: ${p => p.theme.space['2xs']}; -`; - -const VerticalLine = styled('div')` - width: 0; - height: ${p => p.theme.space['3xl']}; - border-left: 1px dashed ${p => p.theme.tokens.border.primary}; - margin-left: 33px; - margin-bottom: -1px; -`; - -const InputWrapper = styled('form')` - display: flex; - padding: ${p => p.theme.space.xs}; - position: relative; -`; - -const StyledInput = styled(TextArea)` - flex-grow: 1; - border-color: ${p => p.theme.tokens.border.secondary}; - padding-right: ${p => p.theme.space['3xl']}; - resize: none; - - &:hover { - border-color: ${p => p.theme.tokens.border.primary}; - } -`; - -const StyledButton = styled(Button)` - position: absolute; - right: ${p => p.theme.space.md}; - top: 50%; - transform: translateY(-50%); - height: 24px; - width: 24px; - margin-right: 0; - color: ${p => p.theme.tokens.content.secondary}; -`; - -const SeerIconContainer = styled('div')` - position: relative; - flex-shrink: 0; -`; - -const StyledAnimatedSeerIcon = styled(IconSeer)` - position: relative; - transition: opacity 0.2s ease; - top: 0; - flex-shrink: 0; - color: ${p => p.theme.tokens.content.primary}; - z-index: 10000; -`; - -const ProgressBarWrapper = styled('div')` - position: relative; -`; diff --git a/static/app/components/events/autofix/autofixProgressBar.tsx b/static/app/components/events/autofix/autofixProgressBar.tsx deleted file mode 100644 index df2acf0069dd40..00000000000000 --- a/static/app/components/events/autofix/autofixProgressBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import {useMemo} from 'react'; -import styled from '@emotion/styled'; - -import type {AutofixData} from './types'; -import {getAutofixProgressDetails} from './utils'; - -interface AutofixProgressBarProps { - autofixData?: AutofixData; -} - -function AutofixProgressBar({autofixData}: AutofixProgressBarProps) { - const {overallProgress} = useMemo( - () => getAutofixProgressDetails(autofixData), - [autofixData] - ); - - return ( - - - - - - - - ); -} - -const ProgressBarContainer = styled('div')<{hasData: boolean}>` - position: sticky; - top: 0; - left: 0; - right: 0; - width: 100%; - height: 2px; - transition: height 0.2s ease-in-out; -`; - -const ProgressBarWrapper = styled('div')` - position: relative; - width: 100%; - height: 100%; -`; - -const ProgressBarTrack = styled('div')` - position: absolute; - width: 100%; - height: 2px; - background-color: ${p => p.theme.tokens.graphics.neutral.moderate}; -`; - -const ProgressBarFill = styled('div')` - height: 100%; - background-color: ${p => p.theme.tokens.graphics.accent.vibrant}; - opacity: 0.7; - transition: width 1s ease-in-out; -`; - -export {AutofixProgressBar}; diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx deleted file mode 100644 index 5f69003bb62429..00000000000000 --- a/static/app/components/events/autofix/autofixRootCause.spec.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import {AutofixRootCauseData} from 'sentry-fixture/autofixRootCauseData'; - -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {AutofixRootCause} from 'sentry/components/events/autofix/autofixRootCause'; -import {AutofixStatus} from 'sentry/components/events/autofix/types'; - -describe('AutofixRootCause', () => { - beforeEach(() => { - localStorage.clear(); - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/update/', - method: 'POST', - body: {success: true}, - }); - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: {integrations: []}, - }); - }); - - afterEach(() => { - localStorage.clear(); - MockApiClient.clearMockResponses(); - jest.clearAllTimers(); - }); - - const defaultProps = { - causes: [AutofixRootCauseData()], - groupId: '1', - rootCauseSelection: null, - runId: '101', - status: AutofixStatus.COMPLETED, - } satisfies React.ComponentProps; - - it('can view a relevant code snippet', async () => { - render(); - - // Wait for initial render and animations - expect(await screen.findByText('Root Cause')).toBeInTheDocument(); - - expect( - await screen.findByText(defaultProps.causes[0]!.root_cause_reproduction![0]!.title) - ).toBeInTheDocument(); - - await userEvent.click(screen.getByTestId('autofix-root-cause-timeline-item-0')); - - // Wait for code snippet to appear with increased timeout for animation - expect( - await screen.findByText( - defaultProps.causes[0]!.root_cause_reproduction![0]!.code_snippet_and_analysis - ) - ).toBeInTheDocument(); - }); - - it('shows graceful error state when there are no causes', async () => { - render( - - ); - - // Wait for error state to render - expect( - await screen.findByText( - 'No root cause found. The error comes from outside the codebase.' - ) - ).toBeInTheDocument(); - }); - - it('shows selected root cause when rootCauseSelection is provided', async () => { - const selectedCause = AutofixRootCauseData(); - render( - - ); - - // Wait for selected root cause to render - expect(await screen.findByText('Root Cause')).toBeInTheDocument(); - - expect( - await screen.findByText(selectedCause.root_cause_reproduction![0]!.title) - ).toBeInTheDocument(); - }); - - it('saves preference when clicking Find Solution with Seer', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - render(); - - await userEvent.click( - await screen.findByRole('button', {name: 'Find Solution with Seer'}) - ); - - expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe( - 'seer_solution' - ); - }); - - it('saves preference when clicking Cursor agent', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - method: 'POST', - body: {success: true}, - }); - - render(); - - // Find and open the dropdown - const dropdownTrigger = await screen.findByRole('button', { - name: 'More solution options', - }); - await userEvent.click(dropdownTrigger); - - // Click the Cursor option in the dropdown - await userEvent.click(await screen.findByText('Send to Cursor')); - - expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe( - 'agent:cursor-integration-id' - ); - }); - - it('shows Seer as primary button by default', async () => { - render(); - - expect( - await screen.findByRole('button', {name: 'Find Solution'}) - ).toBeInTheDocument(); - }); - - it('shows Seer as primary when preference is seer', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - localStorage.setItem( - 'autofix:rootCauseActionPreference', - JSON.stringify('seer_solution') - ); - - render(); - - expect( - await screen.findByRole('button', {name: 'Find Solution with Seer'}) - ).toBeInTheDocument(); - }); - - it('shows Cursor as primary when preference is cursor', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - localStorage.setItem( - 'autofix:rootCauseActionPreference', - JSON.stringify('agent:cursor-integration-id') - ); - - render(); - - expect( - await screen.findByRole('button', {name: 'Send to Cursor'}) - ).toBeInTheDocument(); - - // Verify Seer option is in the dropdown - const dropdownTrigger = await screen.findByRole('button', { - name: 'More solution options', - }); - await userEvent.click(dropdownTrigger); - - expect(await screen.findByText('Find Solution with Seer')).toBeInTheDocument(); - }); - - it('shows Cursor as primary when using legacy cursor: prefix (backwards compatibility)', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - // Use the legacy 'cursor:' prefix that existing users may have stored - localStorage.setItem( - 'autofix:rootCauseActionPreference', - JSON.stringify('cursor:cursor-integration-id') - ); - - render(); - - expect( - await screen.findByRole('button', {name: 'Send to Cursor'}) - ).toBeInTheDocument(); - }); - - it('both options accessible in dropdown', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - ], - }, - }); - - render(); - - // Primary button is Seer (when cursor integration exists, show "with Seer" to distinguish) - expect( - await screen.findByRole('button', {name: 'Find Solution with Seer'}) - ).toBeInTheDocument(); - - // Open dropdown to find Cursor option - const dropdownTrigger = await screen.findByRole('button', { - name: 'More solution options', - }); - await userEvent.click(dropdownTrigger); - - expect(await screen.findByText('Send to Cursor')).toBeInTheDocument(); - }); - - it('shows Setup button for integration requiring identity but lacking it', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: null, - name: 'GitHub Copilot', - provider: 'github_copilot', - requires_identity: true, - has_identity: false, - }, - ], - }, - }); - - localStorage.setItem( - 'autofix:rootCauseActionPreference', - JSON.stringify('agent:github_copilot') - ); - - render(); - - expect( - await screen.findByRole('button', {name: 'Setup GitHub Copilot'}) - ).toBeInTheDocument(); - }); - - it('shows Send to button for integration with identity', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: null, - name: 'GitHub Copilot', - provider: 'github_copilot', - requires_identity: true, - has_identity: true, - }, - ], - }, - }); - - localStorage.setItem( - 'autofix:rootCauseActionPreference', - JSON.stringify('agent:github_copilot') - ); - - render(); - - expect( - await screen.findByRole('button', {name: 'Send to GitHub Copilot'}) - ).toBeInTheDocument(); - }); - - it('shows Setup option in dropdown for integration requiring identity but lacking it', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/coding-agents/', - body: { - integrations: [ - { - id: 'cursor-integration-id', - name: 'Cursor', - provider: 'cursor', - }, - { - id: null, - name: 'GitHub Copilot', - provider: 'github_copilot', - requires_identity: true, - has_identity: false, - }, - ], - }, - }); - - render(); - - // Open dropdown - const dropdownTrigger = await screen.findByRole('button', { - name: 'More solution options', - }); - await userEvent.click(dropdownTrigger); - - // GitHub Copilot should show "Setup" since it requires identity but user hasn't authenticated - expect(await screen.findByText('Setup GitHub Copilot')).toBeInTheDocument(); - // Cursor should show "Send to" since it doesn't require identity - expect(await screen.findByText('Send to Cursor')).toBeInTheDocument(); - }); -}); diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx deleted file mode 100644 index 295669c6ac8c54..00000000000000 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ /dev/null @@ -1,783 +0,0 @@ -import React, {Fragment, useRef, useState} from 'react'; -import styled from '@emotion/styled'; -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; -import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion'; - -import {Alert} from '@sentry/scraps/alert'; -import {Button, ButtonBar} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {TextArea} from '@sentry/scraps/textarea'; - -import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper'; -import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback'; -import { - AutofixStatus, - type AutofixRootCauseData, - type AutofixRootCauseSelection, - type CodingAgentState, - type CommentThread, -} from 'sentry/components/events/autofix/types'; -import { - autofixApiOptions, - organizationIntegrationsCodingAgents, - useLaunchCodingAgent, - type CodingAgentIntegration, -} from 'sentry/components/events/autofix/useAutofix'; -import {formatRootCauseWithEvent} from 'sentry/components/events/autofix/utils'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {IconChat, IconChevron, IconCopy, IconFocus} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; -import type {Event} from 'sentry/types/event'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {singleLineRenderer} from 'sentry/utils/marked/marked'; -import {useApi} from 'sentry/utils/useApi'; -import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useUser} from 'sentry/utils/useUser'; - -import {AutofixHighlightPopup} from './autofixHighlightPopup'; -import {AutofixTimeline} from './autofixTimeline'; -function useSelectRootCause({groupId, runId}: {groupId: string; runId: string}) { - const api = useApi(); - const queryClient = useQueryClient(); - const orgSlug = useOrganization().slug; - - return useMutation({ - mutationFn: (params: {cause_id: string; instruction?: string}) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'select_root_cause', - cause_id: params.cause_id, - instruction: params.instruction || null, - }, - }, - } - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - addLoadingMessage(t('On it...')); - }, - onError: () => { - addErrorMessage(t('Something went wrong when selecting the root cause.')); - }, - }); -} - -type AutofixRootCauseProps = { - causes: AutofixRootCauseData[]; - groupId: string; - rootCauseSelection: AutofixRootCauseSelection; - runId: string; - status: AutofixStatus; - agentCommentThread?: CommentThread; - codingAgents?: Record; - event?: Event; - isRootCauseFirstAppearance?: boolean; - previousDefaultStepIndex?: number; - previousInsightCount?: number; - terminationReason?: string; -}; - -const cardAnimationProps: MotionNodeAnimationOptions = { - exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, - initial: {opacity: 0, height: 0, scale: 0.8}, - animate: {opacity: 1, height: 'auto', scale: 1}, - transition: { - duration: 1, - height: { - type: 'spring', - bounce: 0.2, - }, - scale: { - type: 'spring', - bounce: 0.2, - }, - y: { - type: 'tween', - ease: 'easeOut', - }, - }, -}; - -export function replaceHeadersWithBold(markdown: string) { - const headerRegex = /^(#{1,6})\s+(.*)$/gm; - const boldMarkdown = markdown.replace(headerRegex, (_match, _hashes, content) => { - return ` **${content}** `; - }); - - return boldMarkdown; -} - -function RootCauseDescription({ - cause, - groupId, - runId, - previousDefaultStepIndex, - previousInsightCount, - ref, -}: { - cause: AutofixRootCauseData; - groupId: string; - runId: string; - previousDefaultStepIndex?: number; - previousInsightCount?: number; - ref?: React.RefObject; -}) { - return ( - - {cause.description && ( - = 0 - ? previousInsightCount - : null - } - > - - - )} - {cause.root_cause_reproduction && ( - = 0 - ? previousInsightCount - : null - } - /> - )} - - ); -} - -export function formatRootCauseText( - cause: AutofixRootCauseData | undefined, - customRootCause?: string -) { - if (!cause && !customRootCause) { - return ''; - } - - if (customRootCause) { - return `# Root Cause of the Issue\n\n${customRootCause}`; - } - - if (!cause) { - return ''; - } - - const parts: string[] = ['# Root Cause of the Issue']; - - if (cause.description) { - parts.push(cause.description); - } - - if (cause.root_cause_reproduction) { - parts.push( - cause.root_cause_reproduction - .map(event => { - const eventParts = [`### ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - } - - return parts.join('\n\n'); -} - -function CopyRootCauseButton({ - cause, - groupId, - customRootCause, - event, -}: { - groupId: string; - cause?: AutofixRootCauseData; - customRootCause?: string; - event?: Event; -}) { - const text = formatRootCauseWithEvent(cause, customRootCause, event); - const {copy} = useCopyToClipboard(); - - return ( - - ); -} - -function SolutionActionButton({ - codingAgentIntegrations, - preferredAction, - primaryButtonPriority, - isSelectingRootCause, - isLaunchingAgent, - isLoadingAgents, - submitFindSolution, - handleLaunchCodingAgent, - findSolutionTitle, -}: { - codingAgentIntegrations: CodingAgentIntegration[]; - findSolutionTitle: string; - handleLaunchCodingAgent: (integration: CodingAgentIntegration) => void; - isLaunchingAgent: boolean; - isLoadingAgents: boolean; - isSelectingRootCause: boolean; - preferredAction: string; - primaryButtonPriority: React.ComponentProps['variant']; - submitFindSolution: () => void; -}) { - // Support both 'agent:' (new) and 'cursor:' (legacy) prefixes for backwards compatibility - const isAgentPreference = - preferredAction.startsWith('agent:') || preferredAction.startsWith('cursor:'); - const preferredIntegration = isAgentPreference - ? codingAgentIntegrations.find(i => { - const key = preferredAction.replace(/^(agent|cursor):/, ''); - return i.id === key || (i.id === null && i.provider === key); - }) - : null; - - const effectivePreference = - preferredAction === 'seer_solution' || !preferredIntegration - ? 'seer_solution' - : preferredAction; - - const isSeerPreferred = effectivePreference === 'seer_solution'; - - // Check if there are duplicate names among integrations (need to show ID to distinguish) - const hasDuplicateNames = - codingAgentIntegrations.length > 1 && - new Set(codingAgentIntegrations.map(i => i.name)).size < - codingAgentIntegrations.length; - - // If no integrations, show simple Seer button - if (codingAgentIntegrations.length === 0) { - return ( - - ); - } - - const dropdownItems = [ - ...(isSeerPreferred - ? [] - : [ - { - key: 'seer_solution', - label: t('Find Solution with Seer'), - onAction: submitFindSolution, - disabled: isSelectingRootCause, - }, - ]), - // Show all integrations except the currently preferred one - ...codingAgentIntegrations - .filter(integration => { - // Compare by key to handle both 'agent:' and legacy 'cursor:' prefixes - const integrationKey = integration.id ?? integration.provider; - const effectiveKey = effectivePreference.replace(/^(agent|cursor):/, ''); - return integrationKey !== effectiveKey; - }) - .map(integration => { - const needsSetup = integration.requires_identity && !integration.has_identity; - const actionLabel = needsSetup - ? t('Setup %s', integration.name) - : t('Send to %s', integration.name); - const textValue = hasDuplicateNames - ? `${actionLabel} (${integration.id ?? integration.provider})` - : actionLabel; - return { - key: `agent:${integration.id ?? integration.provider}`, - textValue, - label: ( - - -
{actionLabel}
- {hasDuplicateNames && ( - - ({integration.id ?? integration.provider}) - - )} -
- ), - onAction: () => handleLaunchCodingAgent(integration), - disabled: isLoadingAgents || isLaunchingAgent, - }; - }), - ]; - - const preferredNeedsSetup = - preferredIntegration?.requires_identity && !preferredIntegration?.has_identity; - - const primaryButtonLabel = isSeerPreferred - ? t('Find Solution with Seer') - : hasDuplicateNames - ? preferredNeedsSetup - ? t( - 'Setup %s (%s)', - preferredIntegration.name, - preferredIntegration.id ?? preferredIntegration.provider - ) - : t( - 'Send to %s (%s)', - preferredIntegration!.name, - preferredIntegration!.id ?? preferredIntegration!.provider - ) - : preferredNeedsSetup - ? t('Setup %s', preferredIntegration.name) - : t('Send to %s', preferredIntegration!.name); - - const primaryButtonProps = isSeerPreferred - ? { - onClick: submitFindSolution, - busy: isSelectingRootCause, - icon: undefined, - children: primaryButtonLabel, - } - : { - onClick: () => handleLaunchCodingAgent(preferredIntegration!), - busy: isLaunchingAgent, - icon: , - children: primaryButtonLabel, - }; - - return ( - - - ( - - ) : ( - - ) - } - /> - )} - /> - - ); -} - -function AutofixRootCauseDisplay({ - causes, - groupId, - runId, - rootCauseSelection, - status, - previousDefaultStepIndex, - previousInsightCount, - agentCommentThread, - codingAgents, - event, -}: AutofixRootCauseProps) { - const cause = causes[0]; - const organization = useOrganization(); - const user = useUser(); - const iconFocusRef = useRef(null); - const descriptionRef = useRef(null); - const [solutionText, setSolutionText] = useState(''); - const {mutate: selectRootCause, isPending: isSelectingRootCause} = useSelectRootCause({ - groupId, - runId, - }); - const {data: codingAgentResponse, isLoading: isLoadingAgents} = useQuery( - organizationIntegrationsCodingAgents(organization) - ); - const codingAgentIntegrations = codingAgentResponse?.integrations ?? []; - const {mutate: launchCodingAgent, isPending: isLaunchingAgent} = useLaunchCodingAgent( - groupId, - runId - ); - - // Stores 'seer_solution' or an integration ID (e.g., 'agent:123') - const [preferredAction, setPreferredAction] = useLocalStorageState( - 'autofix:rootCauseActionPreference', - 'seer_solution' - ); - - // Simulate a click on the description to trigger the text selection - const handleSelectDescription = () => { - if (descriptionRef.current) { - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - }); - descriptionRef.current.dispatchEvent(clickEvent); - } - }; - - const submitFindSolution = () => { - if (cause?.id === undefined || cause.id === null) { - addErrorMessage(t('No root cause available.')); - return; - } - - // Save user preference - setPreferredAction('seer_solution'); - - const instruction = solutionText.trim(); - - if (instruction) { - selectRootCause({ - cause_id: cause.id, - instruction, - }); - } else { - selectRootCause({ - cause_id: cause.id, - }); - } - - setSolutionText(''); - - trackAnalytics('autofix.root_cause.find_solution', { - organization, - group_id: groupId, - instruction_provided: instruction.length > 0, - }); - }; - - const handleLaunchCodingAgent = (integration: CodingAgentIntegration) => { - // Redirect to OAuth if the integration requires identity but user hasn't authenticated - if (integration.requires_identity && !integration.has_identity) { - const currentUrl = window.location.href; - window.location.href = `/remote/github-copilot/oauth/?next=${encodeURIComponent(currentUrl)}`; - return; - } - - // Save user preference with specific integration ID - setPreferredAction(`agent:${integration.id ?? integration.provider}`); - - addLoadingMessage(t('Launching %s...', integration.name), { - duration: 60000, - }); - - const instruction = solutionText.trim(); - - launchCodingAgent({ - integrationId: integration.id, - provider: integration.provider, - agentName: integration.name, - triggerSource: 'root_cause', - instruction: instruction || undefined, - }); - - setSolutionText(''); - - trackAnalytics('autofix.coding_agent.launch', { - organization, - group_id: groupId, - step: 'root_cause', - provider: integration.provider, - }); - trackAnalytics('coding_integration.send_to_agent_clicked', { - organization, - group_id: groupId, - provider: integration.provider, - source: 'autofix', - user_id: user.id, - }); - }; - - // Shared UI state for solution action controls - const isRootCauseAlreadySelected = Boolean( - rootCauseSelection && 'cause_id' in rootCauseSelection - ); - const hasCodingAgents = Boolean(codingAgents && Object.keys(codingAgents).length > 0); - const primaryButtonPriority: React.ComponentProps['variant'] = - isRootCauseAlreadySelected || hasCodingAgents ? 'secondary' : 'primary'; - const findSolutionTitle = t('Let Seer plan a solution to this issue'); - - if (!cause) { - return ( - - {t('No root cause available.')} - - ); - } - - if (rootCauseSelection && 'custom_root_cause' in rootCauseSelection) { - return ( - - - - - - - - {t('Custom Root Cause')} - - - {rootCauseSelection.custom_root_cause} - - - - - - {status === AutofixStatus.COMPLETED && ( - - )} - - - - ); - } - - return ( - - - - - - - {t('Root Cause')} - - - - - {agentCommentThread && iconFocusRef.current && ( - = 0 - ? previousInsightCount - : null - } - isAgentComment - blockName={t('Seer is uncertain of the root cause...')} - /> - )} - - - - - - - - - setSolutionText(e.target.value)} - placeholder={t('Add context for the solution...')} - maxRows={3} - size="sm" - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - submitFindSolution(); - } - }} - /> - - - - - {status === AutofixStatus.COMPLETED && ( - - )} - - - ); -} - -export function AutofixRootCause(props: AutofixRootCauseProps) { - if (props.causes.length === 0) { - return ( - - - - - - {t('No root cause found.\n\n%s', props.terminationReason ?? '')} - - - - - - ); - } - - return ( - - - - - - ); -} - -const Description = styled('div')` - border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; - padding-bottom: ${p => p.theme.space.xl}; - margin-bottom: ${p => p.theme.space.xl}; -`; - -const NoCausesPadding = styled('div')` - padding: 0 ${p => p.theme.space.xl}; -`; - -const CausesContainer = styled('div')` - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - overflow: hidden; - box-shadow: ${p => p.theme.shadow.medium}; - padding: ${p => p.theme.space.lg}; - background: ${p => p.theme.tokens.background.primary}; -`; - -const Content = styled('div')` - padding: ${p => p.theme.space.md} 0; -`; - -const HeaderText = styled('div')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - font-size: ${p => p.theme.font.size.lg}; - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; -`; - -const CustomRootCausePadding = styled('div')` - padding: ${p => p.theme.space.md} ${p => p.theme.space['2xs']} ${p => p.theme.space.xl} - ${p => p.theme.space['2xs']}; -`; - -const CauseDescription = styled('div')` - font-size: ${p => p.theme.font.size.md}; - margin-top: ${p => p.theme.space.xs}; -`; - -const AnimationWrapper = styled(motion.div)` - transform-origin: top center; -`; - -const BottomDivider = styled('div')` - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; -`; - -const SolutionInput = styled(TextArea)` - flex: 1; - resize: none; - margin-right: ${p => p.theme.space.lg}; - margin-left: ${p => p.theme.space['3xl']}; - max-width: 250px; -`; - -const DropdownTrigger = styled(Button)` - box-shadow: none; - border-radius: 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0; - border-left: none; -`; - -const SmallIntegrationIdText = styled('div')` - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; -`; diff --git a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx b/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx deleted file mode 100644 index 138376b87611e0..00000000000000 --- a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; - -import {act, renderGlobalModal, screen} from 'sentry-test/reactTestingLibrary'; - -import {openModal} from 'sentry/actionCreators/modal'; -import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal'; - -describe('AutofixSetupWriteAccessModal', () => { - it('displays help text when repos are not all installed', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/setup/', - match: [MockApiClient.matchQuery({check_write_access: true})], - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: false, - repos: [ - { - provider: 'integrations:github', - owner: 'getsentry', - name: 'sentry', - ok: true, - }, - { - provider: 'integrations:github', - owner: 'getsentry', - name: 'seer', - ok: false, - }, - ], - }, - }), - }); - - const closeModal = jest.fn(); - - renderGlobalModal(); - - act(() => { - openModal( - modalProps => , - { - onClose: closeModal, - } - ); - }); - - expect(screen.getByText(/In order to create pull requests/i)).toBeInTheDocument(); - expect(await screen.findByText('getsentry/sentry')).toBeInTheDocument(); - expect(screen.getByText('getsentry/seer')).toBeInTheDocument(); - - expect( - screen.getByRole('button', {name: 'Install the Seer GitHub App'}) - ).toHaveAttribute('href', 'https://github.com/apps/seer-by-sentry/installations/new'); - }); - - it('displays success text when installed repos for github app text', async () => { - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/1/autofix/setup/', - match: [MockApiClient.matchQuery({check_write_access: true})], - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: true, - repos: [ - { - provider: 'integrations:github', - owner: 'getsentry', - name: 'sentry', - ok: true, - }, - { - provider: 'integrations:github', - owner: 'getsentry', - name: 'seer', - ok: true, - }, - ], - }, - }), - }); - - const closeModal = jest.fn(); - - renderGlobalModal(); - - act(() => { - openModal( - modalProps => , - {onClose: closeModal} - ); - }); - - expect( - await screen.findByText("You've successfully configured write access!") - ).toBeInTheDocument(); - - // Footer with actions should no longer be visible - expect(screen.queryByRole('button', {name: /install/i})).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx b/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx deleted file mode 100644 index a63b4f83787479..00000000000000 --- a/static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import {Fragment, useEffect, useMemo} from 'react'; -import styled from '@emotion/styled'; -import {useQueryClient} from '@tanstack/react-query'; - -import {Button, LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; -import {ExternalLink} from '@sentry/scraps/link'; - -import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix'; -import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup'; -import {IconCheckmark} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -function GitRepoLink({repo}: {repo: {name: string; owner: string; ok?: boolean}}) { - return ( - - - {repo.owner}/{repo.name} - - {repo.ok && } - - ); -} - -interface AutofixSetupWriteAccessModalProps extends ModalRenderProps { - groupId: string; -} - -function Content({groupId, closeModal}: {closeModal: () => void; groupId: string}) { - const {canCreatePullRequests, data} = useAutofixSetup( - {groupId, checkWriteAccess: true}, - {refetchOnWindowFocus: true} // We want to check each time the user comes back to the tab - ); - - const sortedRepos = useMemo( - () => - data?.githubWriteIntegration?.repos.toSorted((a: any, b: any) => { - if (a.ok === b.ok) { - return `${a.owner}/${a.name}`.localeCompare(`${b.owner}/${b.name}`); - } - return a.ok ? -1 : 1; - }) ?? [], - [data] - ); - - if (canCreatePullRequests) { - return ( - - -

{t("You've successfully configured write access!")}

- -
- ); - } - - if (sortedRepos.length > 0) { - return ( - -

- {tct( - 'In order to create pull requests, install and grant write access to the [link:Sentry Seer GitHub App] for the following repositories:', - { - link: ( - - ), - } - )} -

- - {sortedRepos.map((repo: any) => ( - - ))} - -
- ); - } - - return ( - -

- {tct( - 'In order to create pull requests, install and grant write access to the [link:Sentry Seer GitHub App] for the relevant repositories.', - { - link: ( - - ), - } - )} -

-
- ); -} - -export function AutofixSetupWriteAccessModal({ - Header, - Body, - Footer, - groupId, - closeModal, -}: AutofixSetupWriteAccessModalProps) { - const queryClient = useQueryClient(); - const orgSlug = useOrganization().slug; - const {canCreatePullRequests} = useAutofixSetup({groupId, checkWriteAccess: true}); - - useEffect(() => { - return () => { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - }; - }, [queryClient, orgSlug, groupId]); - - return ( -
-
-

{t('Allow Seer to Make Pull Requests')}

-
- - - - {!canCreatePullRequests && ( -
- - - - {t('Install the Seer GitHub App')} - - -
- )} -
- ); -} - -const DoneWrapper = styled('div')` - position: relative; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - padding: 40px; - font-size: ${p => p.theme.font.size.lg}; -`; - -const DoneIcon = styled(IconCheckmark)` - margin-bottom: ${p => p.theme.space['3xl']}; -`; - -const RepoLinkUl = styled('ul')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xs}; - padding: 0; -`; - -const RepoItem = styled('li')<{isOk?: boolean}>` - display: flex; - align-items: center; - justify-content: space-between; - gap: ${p => p.theme.space.xl}; - padding: ${p => p.theme.space.md}; - margin-bottom: ${p => p.theme.space.xs}; - background-color: ${p => (p.isOk ? p.theme.colors.green100 : 'transparent')}; - border-radius: ${p => p.theme.radius.md}; -`; diff --git a/static/app/components/events/autofix/autofixSolution.spec.tsx b/static/app/components/events/autofix/autofixSolution.spec.tsx deleted file mode 100644 index ea94a9847d0777..00000000000000 --- a/static/app/components/events/autofix/autofixSolution.spec.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { - render, - screen, - userEvent, - waitFor, - within, -} from 'sentry-test/reactTestingLibrary'; - -import {AutofixSolution} from 'sentry/components/events/autofix/autofixSolution'; -import { - AutofixStatus, - type AutofixData, - type AutofixSolutionTimelineEvent, -} from 'sentry/components/events/autofix/types'; -import { - useAutofixData, - useAutofixRepos, -} from 'sentry/components/events/autofix/useAutofix'; - -jest.mock('sentry/components/events/autofix/useAutofix'); - -describe('AutofixSolution', () => { - const defaultSolution = [ - { - title: 'Fix the bug', - code_snippet_and_analysis: 'Some code and analysis', - timeline_item_type: 'internal_code' as const, - relevant_code_file: { - file_path: 'src/file.js', - repo_name: 'owner/repo', - url: 'https://github.com/owner/repo/blob/main/src/file.js', - }, - }, - ]; - - const defaultProps = { - solution: defaultSolution, - groupId: '123', - runId: 'run-123', - solutionSelected: false, - status: AutofixStatus.COMPLETED, - } satisfies React.ComponentProps; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - }); - jest.mocked(useAutofixRepos).mockReset(); - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [], - codebases: {}, - }); - - jest.mocked(useAutofixData).mockReset(); - jest.mocked(useAutofixData).mockReturnValue({ - data: {} as AutofixData, - isPending: false, - }); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/', - method: 'GET', - body: { - project: { - slug: 'project-slug', - }, - }, - }); - }); - - it('enables Code It Up button when all repos are readable', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - { - name: 'owner/repo2', - owner: 'owner', - external_id: 'repo2', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByText('Code It Up'); - expect(codeItUpButton).toBeEnabled(); - }); - - it('disables Code It Up button when all repos are not readable', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: false, - is_writeable: false, - }, - { - name: 'owner/repo2', - owner: 'owner', - external_id: 'repo2', - provider: 'github', - provider_raw: 'github', - is_readable: false, - is_writeable: false, - }, - ], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByRole('button', {name: 'Code It Up'}); - expect(codeItUpButton).toBeDisabled(); - }); - - it('enables Code It Up button when at least one repo is readable', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - { - name: 'owner/repo2', - owner: 'owner', - external_id: 'repo2', - provider: 'github', - provider_raw: 'github', - is_readable: false, - is_writeable: false, - }, - ], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByText('Code It Up'); - expect(codeItUpButton).toBeEnabled(); - }); - - it('treats repos with is_readable=null as readable', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: undefined, - is_writeable: undefined, - }, - { - name: 'owner/repo2', - owner: 'owner', - external_id: 'repo2', - provider: 'github', - provider_raw: 'github', - is_readable: false, - is_writeable: false, - }, - ], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByText('Code It Up'); - expect(codeItUpButton).toBeEnabled(); - }); - - it('treats repos with is_readable=undefined as readable', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: undefined, - is_writeable: undefined, - }, - { - name: 'owner/repo2', - owner: 'owner', - external_id: 'repo2', - provider: 'github', - provider_raw: 'github', - is_readable: false, - is_writeable: false, - }, - ], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByText('Code It Up'); - expect(codeItUpButton).toBeEnabled(); - }); - - it('treats empty repos array as having no repository constraints', () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [], - codebases: {}, - }); - - render(); - - const codeItUpButton = screen.getByText('Code It Up'); - expect(codeItUpButton).toBeEnabled(); - }); - - it('renders the solution timeline', () => { - render(); - - expect(screen.getByText('Fix the bug')).toBeInTheDocument(); - }); - - it('passes the solution array when Code It Up button is clicked', async () => { - // Mock the API directly before the test - const mockApi = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - }); - - // Use readable repos to enable the button - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Click the Code It Up button - await userEvent.click(screen.getByRole('button', {name: 'Code It Up'})); - - // Wait for API call - await waitFor(() => { - expect(mockApi).toHaveBeenCalled(); - }); - - // Verify payload - expect(mockApi).toHaveBeenCalledWith( - '/organizations/org-slug/issues/123/autofix/update/', - expect.objectContaining({ - data: { - run_id: 'run-123', - payload: { - type: 'select_solution', - mode: 'fix', - solution: expect.arrayContaining([ - expect.objectContaining({ - title: 'Fix the bug', - is_active: true, // should default to true - }), - ]), - }, - }, - }) - ); - }); - - it('allows toggling solution items active/inactive', async () => { - // Mock the API directly before the test - const mockApi = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - }); - - // Use readable repos to enable the button - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Find the timeline item - const timelineItem = screen.getByTestId('autofix-solution-timeline-item-0'); - expect(timelineItem).toBeInTheDocument(); - - // Find and click the toggle button for deselecting the item - const toggleButton = within(timelineItem).getByRole('button', { - name: 'Remove from plan', - }); - expect(toggleButton).toBeInTheDocument(); - await userEvent.click(toggleButton); - - // Click the Code It Up button - await userEvent.click(screen.getByRole('button', {name: 'Code It Up'})); - - // Wait for API call - await waitFor(() => { - expect(mockApi).toHaveBeenCalled(); - }); - - // Verify payload - expect(mockApi).toHaveBeenCalledWith( - '/organizations/org-slug/issues/123/autofix/update/', - expect.objectContaining({ - data: { - run_id: 'run-123', - payload: { - type: 'select_solution', - mode: 'fix', - solution: expect.arrayContaining([ - expect.objectContaining({ - title: 'Fix the bug', - is_active: false, - }), - ]), - }, - }, - }) - ); - }); - - it('allows adding custom instructions', async () => { - // Mock the API directly before the test - const mockApi = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - }); - - // Use readable repos to enable the button - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo1', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Find and fill the input - const input = screen.getByPlaceholderText('Add to the solution plan...'); - await userEvent.type(input, 'This is a custom instruction'); - - // Enable the Add button by typing non-empty text - const addButton = screen.getByRole('button', {name: 'Add to solution'}); - expect(addButton).toBeEnabled(); - - // Click Add button - await userEvent.click(addButton); - - // Verify the custom instruction was added - expect(screen.getByText('This is a custom instruction')).toBeInTheDocument(); - - // Click Code It Up - await userEvent.click(screen.getByRole('button', {name: 'Code It Up'})); - - // Wait for API call - await waitFor(() => { - expect(mockApi).toHaveBeenCalled(); - }); - - // Verify payload - expect(mockApi).toHaveBeenCalledWith( - '/organizations/org-slug/issues/123/autofix/update/', - expect.objectContaining({ - data: { - run_id: 'run-123', - payload: { - type: 'select_solution', - mode: 'fix', - solution: expect.arrayContaining([ - expect.objectContaining({ - title: 'Fix the bug', - }), - expect.objectContaining({ - title: 'This is a custom instruction', - timeline_item_type: 'human_instruction', - is_active: true, - }), - ]), - }, - }, - }) - ); - }); - - it('allows adding custom instructions with Enter key', async () => { - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Find and fill the input, then press Enter - const input = screen.getByPlaceholderText('Add to the solution plan...'); - await userEvent.type(input, 'Enter key instruction{Enter}'); - - // Verify the custom instruction was added - expect(screen.getByText('Enter key instruction')).toBeInTheDocument(); - - // Input should be cleared - expect(input).toHaveValue(''); - }); - - it('can delete human instructions from solution', async () => { - const solutionWithHumanInstruction = [ - ...defaultSolution, - { - title: 'Human instruction', - timeline_item_type: 'human_instruction' as const, - is_active: true, - }, - ]; - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Find the human instruction item - const humanInstructionElement = screen.getByText('Human instruction'); - expect(humanInstructionElement).toBeInTheDocument(); - - // Find the timeline item containing the human instruction - // https://github.com/typescript-eslint/typescript-eslint/issues/10722 - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const timelineItem = humanInstructionElement.closest( - '[data-test-id^="autofix-solution-timeline-item-"]' - ) as HTMLElement; - expect(timelineItem).not.toBeNull(); - - // Find the delete button using the updated aria-label - const deleteButton = within(timelineItem).getByRole('button', { - name: 'Remove from plan', - }); - expect(deleteButton).toBeInTheDocument(); - - // Click the delete button - await userEvent.click(deleteButton); - - // Verify the human instruction was removed - await waitFor(() => { - expect(screen.queryByText('Human instruction')).not.toBeInTheDocument(); - }); - }); - - it('preserves active state of solution items', async () => { - const solutionWithActiveStates = [ - { - ...defaultSolution[0], - is_active: true, - }, - { - title: 'Another step', - code_snippet_and_analysis: 'More code', - timeline_item_type: 'internal_code' as const, - is_active: false, - relevant_code_file: { - file_path: 'src/another.js', - repo_name: 'owner/repo', - }, - }, - ] as AutofixSolutionTimelineEvent[]; - - // Mock the API directly before the test - const mockApi = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/issues/123/autofix/update/', - method: 'POST', - }); - - jest.mocked(useAutofixRepos).mockReturnValue({ - repos: [ - { - name: 'owner/repo', - owner: 'owner', - external_id: 'repo1', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - }, - ], - codebases: {}, - }); - - render(); - - // Click Code It Up - await userEvent.click(screen.getByRole('button', {name: 'Code It Up'})); - - // Wait for API call - await waitFor(() => { - expect(mockApi).toHaveBeenCalled(); - }); - - // Verify payload - expect(mockApi).toHaveBeenCalledWith( - '/organizations/org-slug/issues/123/autofix/update/', - expect.objectContaining({ - data: { - run_id: 'run-123', - payload: { - type: 'select_solution', - mode: 'fix', - solution: expect.arrayContaining([ - expect.objectContaining({ - title: 'Fix the bug', - is_active: true, - }), - expect.objectContaining({ - title: 'Another step', - is_active: false, - }), - ]), - }, - }, - }) - ); - }); -}); diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx deleted file mode 100644 index 9beee65bd17f38..00000000000000 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ /dev/null @@ -1,788 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import styled from '@emotion/styled'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; -import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion'; - -import {Alert} from '@sentry/scraps/alert'; -import {Button} from '@sentry/scraps/button'; -import {Input} from '@sentry/scraps/input'; -import {Flex, Grid} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator'; -import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper'; -import {SolutionEventItem} from 'sentry/components/events/autofix/autofixSolutionEventItem'; -import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback'; -import { - AutofixStatus, - AutofixStepType, - type AutofixSolutionTimelineEvent, - type CommentThread, -} from 'sentry/components/events/autofix/types'; -import { - autofixApiOptions, - useAutofixData, - useAutofixRepos, -} from 'sentry/components/events/autofix/useAutofix'; -import {formatSolutionWithEvent} from 'sentry/components/events/autofix/utils'; -import {Timeline} from 'sentry/components/timeline'; -import {IconAdd, IconChat, IconCopy, IconFix} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {singleLineRenderer} from 'sentry/utils/marked/marked'; -import {valueIsEqual} from 'sentry/utils/object/valueIsEqual'; -import {useApi} from 'sentry/utils/useApi'; -import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useGroup} from 'sentry/views/issueDetails/useGroup'; - -import {AutofixHighlightPopup} from './autofixHighlightPopup'; - -function useSelectSolution({groupId, runId}: {groupId: string; runId: string}) { - const api = useApi(); - const queryClient = useQueryClient(); - const orgSlug = useOrganization().slug; - - return useMutation({ - mutationFn: (params: { - mode: 'all' | 'fix' | 'test'; - solution: AutofixSolutionTimelineEvent[]; - }) => { - return api.requestPromise( - `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, - { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'select_solution', - mode: params.mode, - solution: params.solution, - }, - }, - } - ); - }, - onSuccess: (_, params) => { - queryClient.setQueryData(autofixApiOptions(orgSlug, groupId).queryKey, prev => { - if (!prev?.json?.autofix) { - return prev; - } - - return { - ...prev, - json: { - ...prev.json, - autofix: { - ...prev.json.autofix, - status: AutofixStatus.PROCESSING, - steps: prev.json.autofix.steps?.map(step => { - if (step.type !== AutofixStepType.SOLUTION) { - return step; - } - - return { - ...step, - selection: - 'customSolution' in params - ? { - custom_solution: params.customSolution, - } - : {}, - }; - }), - }, - }, - }; - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, true).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, groupId, false).queryKey, - }); - addLoadingMessage('On it.'); - }, - onError: () => { - addErrorMessage(t('Something went wrong when selecting the solution.')); - }, - }); -} - -type AutofixSolutionProps = { - groupId: string; - runId: string; - solution: AutofixSolutionTimelineEvent[]; - solutionSelected: boolean; - status: AutofixStatus; - agentCommentThread?: CommentThread; - changesDisabled?: boolean; - customSolution?: string; - description?: string; - event?: Event; - isSolutionFirstAppearance?: boolean; - previousDefaultStepIndex?: number; - previousInsightCount?: number; -}; - -const cardAnimationProps: MotionNodeAnimationOptions = { - exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, - initial: {opacity: 0, height: 0, scale: 0.8}, - animate: {opacity: 1, height: 'auto', scale: 1}, - transition: { - duration: 1, - height: { - type: 'spring', - bounce: 0.2, - }, - scale: { - type: 'spring', - bounce: 0.2, - }, - y: { - type: 'tween', - ease: 'easeOut', - }, - }, -}; - -function SolutionDescription({ - solution, - groupId, - runId, - previousDefaultStepIndex, - previousInsightCount, - description, - onDeleteItem, - onToggleActive, - ref, -}: { - groupId: string; - onDeleteItem: (index: number) => void; - onToggleActive: (index: number) => void; - runId: string; - solution: AutofixSolutionTimelineEvent[]; - description?: string; - previousDefaultStepIndex?: number; - previousInsightCount?: number; - ref?: React.RefObject; -}) { - return ( - - {description && ( - = 0 - ? previousInsightCount - : null - } - > - - - )} - = 0 - ? previousInsightCount - : null - } - /> - - ); -} - -const Description = styled('div')` - border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; - padding-bottom: ${p => p.theme.space.xl}; - margin-bottom: ${p => p.theme.space.xl}; -`; - -type SolutionEventListProps = { - events: AutofixSolutionTimelineEvent[]; - groupId: string; - onDeleteItem: (index: number) => void; - onToggleActive: (index: number) => void; - runId: string; - retainInsightCardIndex?: number | null; - stepIndex?: number; -}; - -function SolutionEventList({ - events, - onDeleteItem, - onToggleActive, - groupId, - runId, - stepIndex = 0, - retainInsightCardIndex = null, -}: SolutionEventListProps) { - if (!events?.length) { - return null; - } - - return ( - - {events.map((event, index) => { - const isSelected = event.is_active !== false; // Default to true if is_active is undefined - - return ( - - ); - })} - - ); -} - -export function formatSolutionText( - solution: AutofixSolutionTimelineEvent[], - customSolution?: string -) { - if (!solution && !customSolution) { - return ''; - } - - if (customSolution) { - return `# Solution Plan\n\n${customSolution}`; - } - - if (!solution || solution.length === 0) { - return ''; - } - - const parts = ['# Solution Plan']; - - parts.push( - solution - .filter(event => event.is_active) - .map((event, index) => { - const eventParts = [`### ${index + 1}. ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - - return parts.join('\n\n'); -} - -function CopySolutionButton({ - solution, - groupId, - customSolution, - event, - isEditing, - rootCause, -}: { - groupId: string; - solution: AutofixSolutionTimelineEvent[]; - customSolution?: string; - event?: Event; - isEditing?: boolean; - rootCause?: any; -}) { - const text = formatSolutionWithEvent(solution, customSolution, event, rootCause); - const {copy} = useCopyToClipboard(); - - if (isEditing) { - return null; - } - - return ( - - ); -} - -function AutofixSolutionDisplay({ - solution, - description, - groupId, - runId, - status, - previousDefaultStepIndex, - previousInsightCount, - customSolution, - solutionSelected, - agentCommentThread, - event, -}: Omit) { - const organization = useOrganization(); - const {data: group} = useGroup({groupId}); - const project = group?.project; - - const {repos} = useAutofixRepos(groupId); - const {data: autofixData} = useAutofixData({groupId}); - - // Get root cause data from autofix data - const rootCauseStep = autofixData?.steps?.find( - step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS - ); - const rootCause = rootCauseStep?.causes?.[0]; - const {mutate: handleContinue, isPending} = useSelectSolution({groupId, runId}); - const [instructions, setInstructions] = useState(''); - const [solutionItems, setSolutionItems] = useState( // This will become outdated if multiple people use it, but we can ignore this for now. - () => { - // Initialize is_active to true for all items that don't have it set for backwards compatibility - return solution.map(item => ({ - ...item, - is_active: item.is_active === undefined ? true : item.is_active, - })); - } - ); - const containerRef = useRef(null); - const iconFixRef = useRef(null); - const descriptionRef = useRef(null); - - const handleSelectDescription = () => { - if (descriptionRef.current) { - // Simulate a click on the description to trigger the text selection - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - }); - descriptionRef.current.dispatchEvent(clickEvent); - } - }; - - const hasNoRepos = repos.length === 0; - const cantReadRepos = repos.every(repo => repo.is_readable === false); - const enableSeerCoding = organization.enableSeerCoding !== false; - - const handleAddInstruction = () => { - if (instructions.trim()) { - // Create a new step from the instructions input - const newStep: AutofixSolutionTimelineEvent = { - title: instructions, - timeline_item_type: 'human_instruction', - is_most_important_event: false, - is_active: true, - }; - - // Add the new step to the solution - setSolutionItems([...solutionItems, newStep]); - - // Clear the input - setInstructions(''); - - trackAnalytics('autofix.solution.add_step', { - organization, - group_id: groupId, - solution: solutionItems, - newStep, - }); - } - }; - - const handleFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - handleAddInstruction(); - }; - - const handleDeleteItem = useCallback( - (index: number) => { - setSolutionItems(current => current.filter((_, i) => i !== index)); - - trackAnalytics('autofix.solution.delete_step', { - organization, - group_id: groupId, - solution: solutionItems, - deletedStep: solutionItems[index], - }); - }, - [organization, groupId, solutionItems] - ); - - const handleToggleActive = useCallback( - (index: number) => { - setSolutionItems(current => - current.map((item, i) => - i === index - ? {...item, is_active: item.is_active === false ? true : false} - : item - ) - ); - - trackAnalytics('autofix.solution.toggle_step', { - organization, - group_id: groupId, - solution: solutionItems, - toggledStep: solutionItems[index], - }); - }, - [organization, groupId, solutionItems] - ); - - const handleCodeItUp = () => { - let finalSolutionItems = solutionItems; - - // Check if there are instructions typed but not added - if (instructions.trim()) { - // Create a new step from the instructions input - const newStep: AutofixSolutionTimelineEvent = { - title: instructions, - timeline_item_type: 'human_instruction', - is_most_important_event: false, - is_active: true, - }; - - // Add the new step to the solution - finalSolutionItems = [...solutionItems, newStep]; - setSolutionItems(finalSolutionItems); - - // Clear the input - setInstructions(''); - - trackAnalytics('autofix.solution.add_step', { - organization, - group_id: groupId, - solution: solutionItems, - newStep, - }); - } - - handleContinue({ - mode: 'fix', - solution: finalSolutionItems, - }); - }; - - // Check if instructions were provided (either typed in input or already added to solution and active) - const hasInstructions = - instructions.trim().length > 0 || - solutionItems.some( - item => item.timeline_item_type === 'human_instruction' && item.is_active !== false - ); - - useEffect(() => { - setSolutionItems( - solution.map(item => ({ - ...item, - is_active: item.is_active === undefined ? true : item.is_active, - })) - ); - }, [solution]); - - if (!solution || solution.length === 0) { - return ( - - {t('No solution available.')} - - ); - } - - if (customSolution) { - return ( - - - - - - - - {t('Custom Solution')} - - - - {customSolution} - - - -
- - - - - ); - } - - return ( - - - - - - - {t('Solution')} - - - - - {agentCommentThread && iconFixRef.current && ( - = 0 - ? previousInsightCount - : null - } - isAgentComment - blockName={t('Seer is uncertain of the solution...')} - /> - )} - - - - - - - - - ) => - setInstructions(e.target.value) - } - size="sm" - /> - - - - - - - - - ), - } - ) - : cantReadRepos - ? t( - "Seer can't access any of your selected repos. Check your GitHub integration and make sure Seer has read access." - ) - : undefined - : tct( - '[settings:"Enable Code Generation"] must be enabled by an admin in settings.', - { - settings: ( - - ), - } - ) - } - > - - - - {status === AutofixStatus.COMPLETED && ( - - )} - - - ); -} - -export function AutofixSolution(props: AutofixSolutionProps) { - if (props.solution.length === 0) { - return ( - - - - {t('No solution found.')} - - - - ); - } - - return ( - - - - - - ); -} - -const NoSolutionPadding = styled('div')` - padding: 0 ${p => p.theme.space.xl}; -`; - -const SolutionContainer = styled('div')` - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - overflow: hidden; - box-shadow: ${p => p.theme.shadow.medium}; - padding: ${p => p.theme.space.lg}; - background: ${p => p.theme.tokens.background.primary}; -`; - -const Content = styled('div')` - padding: ${p => p.theme.space.md} 0 0; -`; - -const HeaderText = styled('div')` - font-weight: ${p => p.theme.font.weight.sans.medium}; - font-size: ${p => p.theme.font.size.lg}; - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; -`; - -const SolutionDescriptionWrapper = styled('div')` - font-size: ${p => p.theme.font.size.md}; - margin-top: ${p => p.theme.space.xs}; -`; - -const AnimationWrapper = styled(motion.div)` - transform-origin: top center; -`; - -const CustomSolutionPadding = styled('div')` - padding: ${p => p.theme.space.md} ${p => p.theme.space['2xs']} ${p => p.theme.space.xl} - ${p => p.theme.space['2xs']}; -`; - -const InstructionsInputWrapper = styled('form')` - display: flex; - position: relative; - border-radius: ${p => p.theme.radius.md}; - margin-left: ${p => p.theme.space['3xl']}; - width: 250px; -`; - -const InstructionsInput = styled(Input)` - flex-grow: 1; - padding-right: ${p => p.theme.space['3xl']}; - - &::placeholder { - color: ${p => p.theme.tokens.content.secondary}; - } -`; - -const SubmitButton = styled(Button)` - position: absolute; - right: ${p => p.theme.space.md}; - top: 50%; - transform: translateY(-50%); - height: 24px; - width: 24px; - border-radius: 5px; -`; - -const BottomDivider = styled('div')` - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; - margin-top: ${p => p.theme.space.lg}; -`; - -const AddInstructionWrapper = styled('div')` - flex: 0; -`; diff --git a/static/app/components/events/autofix/autofixSolutionEventItem.tsx b/static/app/components/events/autofix/autofixSolutionEventItem.tsx deleted file mode 100644 index 26a63e4aa8bee5..00000000000000 --- a/static/app/components/events/autofix/autofixSolutionEventItem.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import {useState} from 'react'; -import type {Theme} from '@emotion/react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {AnimatePresence, motion} from 'framer-motion'; - -import {Flex} from '@sentry/scraps/layout'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {AutofixHighlightWrapper} from 'sentry/components/events/autofix/autofixHighlightWrapper'; -import {AutofixInsightSources} from 'sentry/components/events/autofix/insights/autofixInsightSources'; -import {type AutofixSolutionTimelineEvent} from 'sentry/components/events/autofix/types'; -import {Timeline, type TimelineItemProps} from 'sentry/components/timeline'; -import { - IconAdd, - IconChevron, - IconClose, - IconCode, - IconDelete, - IconLab, - IconUser, -} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {MarkedText} from 'sentry/utils/marked/markedText'; - -function getEventIcon(eventType: string) { - const iconProps = { - style: { - margin: 3, - }, - }; - - switch (eventType) { - case 'internal_code': - return ; - case 'human_instruction': - return ; - case 'repro_test': - return ; - default: - return ; - } -} - -function getEventColor( - theme: Theme, - isActive?: boolean, - isSelected?: boolean -): TimelineItemProps['colorConfig'] { - return { - title: theme.tokens.content.primary, - icon: isSelected - ? isActive - ? theme.colors.green500 - : theme.tokens.content.primary - : theme.tokens.content.secondary, - iconBorder: isSelected - ? isActive - ? theme.colors.green500 - : theme.tokens.content.primary - : theme.tokens.content.secondary, - }; -} - -interface SolutionEventItemProps { - event: AutofixSolutionTimelineEvent; - groupId: string; - index: number; - isSelected: boolean; - onDeleteItem: (index: number) => void; - onToggleActive: (index: number) => void; - runId: string; - stepIndex: number; - retainInsightCardIndex?: number | null; -} - -export function SolutionEventItem({ - event, - groupId, - index, - isSelected, - onDeleteItem, - onToggleActive, - runId, - retainInsightCardIndex, - stepIndex, -}: SolutionEventItemProps) { - const theme = useTheme(); - const [isExpanded, setIsExpanded] = useState(false); - const isHumanAction = event.timeline_item_type === 'human_instruction'; - // XXX: This logic assumes the list length is available, which it isn't here. - // We might need to pass list length or derive this differently if needed. - // For now, approximating based on index 0 not being the last. - const isActive = event.is_most_important_event && index !== 0; - - const handleToggleExpand = () => { - setIsExpanded(e => !e); - }; - - const handleItemClick = () => { - if (!isSelected) { - // If item is disabled, re-enable it instead of toggling expansion - onToggleActive(index); - return; - } - if (!isHumanAction && event.code_snippet_and_analysis) { - handleToggleExpand(); - } - }; - - const handleSelectionToggle = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleActive(index); - if (isSelected) { - setIsExpanded(false); - } - }; - - const handleDeleteClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onDeleteItem(index); - }; - - return ( - - - - - - {!isHumanAction && event.code_snippet_and_analysis && isSelected && ( - - )} - - - - {isHumanAction ? ( - - ) : isSelected ? ( - - ) : ( - - )} - - - - - - } - isActive={isActive} - icon={getEventIcon(event.timeline_item_type)} - colorConfig={getEventColor(theme, isActive, isSelected)} - > - {event.code_snippet_and_analysis && ( - - {isExpanded && ( - - - - - - {event.relevant_code_file?.url && ( - - - - )} - - - )} - - )} - - ); -} - -const SourcesWrapper = styled('div')` - margin-top: ${p => p.theme.space.xl}; -`; - -const StyledIconChevron = styled(IconChevron)` - color: ${p => p.theme.tokens.content.secondary}; - flex-shrink: 0; -`; - -const SelectionButtonWrapper = styled('div')` - background: none; - border: none; - display: flex; - align-items: center; - justify-content: center; - height: 100%; -`; - -type SelectionButtonProps = React.ButtonHTMLAttributes & { - actionType: 'delete' | 'close' | 'add'; -}; - -const SelectionButton = styled('button')` - background: none; - border: none; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: ${p => p.theme.tokens.content.secondary}; - transition: - color 0.2s ease, - background-color 0.2s ease; - border-radius: 5px; - padding: 4px; - - &:hover { - color: ${p => - p.actionType === 'delete' || p.actionType === 'close' - ? p.theme.colors.red500 - : p.theme.colors.green500}; - } -`; - -const AnimatedContent = styled(motion.div)` - overflow: hidden; -`; - -const StyledSpan = styled(MarkedText)` - & code { - font-size: ${p => p.theme.font.size.sm}; - background-color: transparent; - display: inline-block; - } -`; - -const StyledTimelineHeader = styled('div')<{isSelected: boolean; isActive?: boolean}>` - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: ${p => p.theme.space['2xs']}; - padding-right: 0; - border-radius: ${p => p.theme.radius.md}; - cursor: pointer; - font-weight: ${p => p.theme.font.weight.sans.regular}; - gap: ${p => p.theme.space.md}; - opacity: ${p => (p.isSelected ? 1 : 0.6)}; - text-decoration: ${p => - p.isSelected ? (p.isActive ? 'underline dashed' : 'none') : 'line-through'}; - text-decoration-color: ${p => - p.isSelected ? p.theme.colors.green400 : p.theme.tokens.content.primary}; - text-decoration-thickness: 1px; - text-underline-offset: 4px; - transition: opacity 0.2s ease; - - & > div:first-of-type { - flex: 1; - min-width: 0; - margin-right: ${p => p.theme.space.md}; - } - - &:hover { - background-color: ${p => - p.theme.tokens.interactive.transparent.neutral.background.hover}; - } - - &:active { - background-color: ${p => - p.theme.tokens.interactive.transparent.neutral.background.active}; - } -`; diff --git a/static/app/components/events/autofix/autofixStartBox.tsx b/static/app/components/events/autofix/autofixStartBox.tsx deleted file mode 100644 index 933d48f723d641..00000000000000 --- a/static/app/components/events/autofix/autofixStartBox.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import {useCallback, useMemo, useState} from 'react'; -import styled from '@emotion/styled'; - -import starImage from 'sentry-images/spot/banner-star.svg'; - -import {Button, ButtonBar} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {TextArea} from '@sentry/scraps/textarea'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {AutofixStoppingPoint} from 'sentry/components/events/autofix/types'; -import {IconArrow, IconChevron, IconSeer} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -interface AutofixStartBoxProps { - groupId: string; - onSend: (message: string, stoppingPoint?: AutofixStoppingPoint) => void; -} - -function getStoppingPointOptions(organization: Organization) { - const enableSeerCoding = organization.enableSeerCoding !== false; - return [ - { - key: AutofixStoppingPoint.ROOT_CAUSE, - label: t('Start Root Cause Analysis'), - value: AutofixStoppingPoint.ROOT_CAUSE, - disabled: false, - tooltip: undefined, - }, - { - key: AutofixStoppingPoint.SOLUTION, - label: t('Plan a Solution'), - value: AutofixStoppingPoint.SOLUTION, - disabled: false, - tooltip: undefined, - }, - { - key: AutofixStoppingPoint.CODE_CHANGES, - label: t('Write Code Changes'), - value: AutofixStoppingPoint.CODE_CHANGES, - disabled: !enableSeerCoding, - tooltip: enableSeerCoding - ? undefined - : tct( - '[settings:"Enable Code Generation"] must be enabled by an admin in settings.', - { - settings: ( - - ), - } - ), - }, - { - key: AutofixStoppingPoint.OPEN_PR, - label: t('Draft a Pull Request'), - value: AutofixStoppingPoint.OPEN_PR, - disabled: !enableSeerCoding, - tooltip: enableSeerCoding - ? undefined - : tct( - '[settings:"Enable Code Generation"] must be enabled by an admin in settings.', - { - settings: ( - - ), - } - ), - }, - ] as const; -} - -export function AutofixStartBox({onSend, groupId}: AutofixStartBoxProps) { - const organization = useOrganization(); - const [message, setMessage] = useState(''); - const [selectedStoppingPoint, setSelectedStoppingPoint] = useLocalStorageState( - 'autofix:selected-stopping-point', - AutofixStoppingPoint.ROOT_CAUSE - ); - - const handleSubmit = useCallback( - (e: React.FormEvent, stoppingPoint?: AutofixStoppingPoint) => { - e.preventDefault(); - const finalStoppingPoint = stoppingPoint ?? selectedStoppingPoint; - setSelectedStoppingPoint(finalStoppingPoint); - onSend(message, finalStoppingPoint); - }, - [message, selectedStoppingPoint, onSend, setSelectedStoppingPoint] - ); - - const {primaryOption, dropdownOptions} = useMemo(() => { - const options = getStoppingPointOptions(organization); - const primary = - options.find(opt => opt.value === selectedStoppingPoint) ?? options[0]; - const dropdown = options - .filter(opt => opt.value !== selectedStoppingPoint) - .map(opt => ({ - key: opt.key, - label: opt.label, - disabled: opt.disabled ?? false, - tooltip: opt.tooltip, - onAction: () => - handleSubmit({preventDefault: () => {}} as React.FormEvent, opt.value), - })); - return {primaryOption: primary, dropdownOptions: dropdown}; - }, [organization, selectedStoppingPoint, handleSubmit]); - - return ( - - - - - - - - - - - - - - setMessage(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }} - placeholder="Share helpful context here..." - maxLength={4096} - maxRows={10} - size="sm" - /> - - - - {primaryOption.label} - - - ( - } - /> - )} - /> - - - - - - ); -} - -const Wrapper = styled('div')` - display: flex; - flex-direction: column; - align-items: center; - margin: ${p => p.theme.space.md} ${p => p.theme.space['3xl']}; - gap: ${p => p.theme.space.md}; -`; - -const ScaleContainer = styled('div')` - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - gap: ${p => p.theme.space.md}; - margin-bottom: 100px; -`; - -const Container = styled('div')` - position: relative; - width: 100%; - border-radius: ${p => p.theme.radius.md}; - background: ${p => p.theme.tokens.background.primary} - linear-gradient( - 135deg, - ${p => p.theme.colors.pink500}08, - ${p => p.theme.colors.pink500}20 - ); - overflow: visible; - padding: ${p => p.theme.space.xs}; - border: 1px solid ${p => p.theme.tokens.border.primary}; -`; - -const AutofixStartText = styled('div')` - margin: 0; - padding: ${p => p.theme.space.md}; - white-space: pre-wrap; - word-break: break-word; - font-size: ${p => p.theme.font.size.lg}; - position: relative; - overflow: hidden; -`; - -const BackgroundStar = styled('img')` - position: absolute; - filter: sepia(1) saturate(3) hue-rotate(290deg); - opacity: 0.7; - pointer-events: none; - z-index: 0; -`; - -const StyledArrow = styled(IconArrow)` - color: ${p => p.theme.tokens.content.secondary}; - opacity: 0.5; -`; - -const InputWrapper = styled('form')` - display: flex; - gap: ${p => p.theme.space.lg}; - padding: ${p => p.theme.space.sm} ${p => p.theme.space.lg}; -`; - -const StyledInput = styled(TextArea)` - resize: none; - background: ${p => p.theme.tokens.background.primary}; - - border-color: ${p => p.theme.tokens.border.secondary}; - &:hover { - border-color: ${p => p.theme.tokens.border.primary}; - } -`; - -const StyledButton = styled(Button)` - flex-shrink: 0; -`; - -const DropdownTrigger = styled(Button)` - box-shadow: none; - border-radius: 0 ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0; - border-left: none; -`; diff --git a/static/app/components/events/autofix/autofixStepFeedback.tsx b/static/app/components/events/autofix/autofixStepFeedback.tsx deleted file mode 100644 index d0dc7d30596e3b..00000000000000 --- a/static/app/components/events/autofix/autofixStepFeedback.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import {useState} from 'react'; - -import type {ButtonProps} from '@sentry/scraps/button'; -import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import {IconThumb} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useUser} from 'sentry/utils/useUser'; - -type StepType = 'root_cause' | 'solution' | 'changes'; - -interface AutofixStepFeedbackProps { - groupId: string; - runId: string; - stepType: StepType; - buttonSize?: ButtonProps['size']; - compact?: boolean; - onFeedbackClick?: (e: React.MouseEvent) => void; -} - -export function AutofixStepFeedback({ - stepType, - groupId, - runId, - buttonSize = 'xs', - compact = false, - onFeedbackClick, -}: AutofixStepFeedbackProps) { - const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); - const organization = useOrganization(); - const user = useUser(); - - const handleFeedback = (positive: boolean, e?: React.MouseEvent) => { - if (onFeedbackClick && e) { - onFeedbackClick(e); - } - - const analyticsData = { - step_type: stepType, - positive, - group_id: groupId, - autofix_run_id: runId, - user_id: user.id, - organization, - }; - - trackAnalytics('seer.autofix.feedback_submitted', analyticsData); - - setFeedbackSubmitted(true); - }; - - if (feedbackSubmitted) { - return ( - - - {t('Thanks!')} - - - ); - } - - const iconSize = buttonSize === 'zero' ? 'xs' : 'sm'; - const gap = compact ? '2xs' : 'xs'; - - return ( - - - - )} - {codingAgentState.results - ?.filter(result => result.pr_url) - .map(({pr_url}) => ( - - - - ))} - - - - )} - - - - - - - ); -} - -const VerticalLine = styled('div')` - width: 0; - height: ${p => p.theme.space.xl}; - border-left: 1px solid ${p => p.theme.tokens.border.primary}; - margin-left: 16px; - margin-bottom: -1px; -`; - -const StepCard = styled('div')` - overflow: hidden; - - :last-child { - margin-bottom: 0; - } -`; - -const ContentWrapper = styled(motion.div)` - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 300ms; - will-change: grid-template-rows; - - > div { - /* So that focused element outlines don't get cut off */ - padding: 0 1px; - overflow: hidden; - } -`; - -const StyledCard = styled('div')` - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - overflow: hidden; - box-shadow: ${p => p.theme.shadow.medium}; - padding-left: ${p => p.theme.space.xl}; - padding-right: ${p => p.theme.space.xl}; - background: ${p => p.theme.tokens.background.primary}; -`; - -const HeaderWrapper = styled('div')` - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: ${p => p.theme.space.md}; - padding: ${p => p.theme.space.xl} 0 ${p => p.theme.space.md} 0; -`; - -const HeaderText = styled('div')` - font-weight: bold; - font-size: ${p => p.theme.font.size.lg}; - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; -`; - -const Content = styled('div')` - padding: ${p => p.theme.space.md} 0 ${p => p.theme.space.xl} 0; -`; - -const AgentTitle = styled('h4')` - margin: 0 0 ${p => p.theme.space.xs} 0; - font-size: ${p => p.theme.font.size.md}; - color: ${p => p.theme.tokens.content.primary}; -`; - -const DetailRow = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; -`; - -const Label = styled('span')` - font-weight: 600; - color: ${p => p.theme.tokens.content.secondary}; - min-width: 80px; -`; - -const Value = styled('span')` - color: ${p => p.theme.tokens.content.primary}; - font-family: ${p => p.theme.font.family.mono}; - font-size: ${p => p.theme.font.size.sm}; -`; - -const ResultsSection = styled('div')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.md}; - margin-bottom: ${p => p.theme.space.md}; -`; - -const ResultItem = styled('div')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xs}; - padding: ${p => p.theme.space.md} 0; - &:not(:last-child) { - border-bottom: 1px solid ${p => p.theme.tokens.border.secondary}; - } -`; - -const ResultDescription = styled('div')<{status: CodingAgentStatus}>` - color: ${p => - p.status === CodingAgentStatus.FAILED - ? p.theme.tokens.content.danger - : p.theme.tokens.content.primary}; -`; - -const StyledLoadingIndicator = styled(LoadingIndicator)` - height: ${p => p.size}px; - width: ${p => p.size}px; - margin: 0; - margin-bottom: ${p => p.theme.space['2xs']}; -`; - -const BottomDivider = styled('div')` - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; -`; - -const BottomButtonContainer = styled('div')` - display: flex; - justify-content: flex-end; - padding-top: ${p => p.theme.space.xl}; - padding-bottom: ${p => p.theme.space.xl}; -`; diff --git a/static/app/components/events/autofix/drawer/drawerHeader.tsx b/static/app/components/events/autofix/drawer/drawerHeader.tsx deleted file mode 100644 index 3203018382008c..00000000000000 --- a/static/app/components/events/autofix/drawer/drawerHeader.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import styled from '@emotion/styled'; - -import {ProjectAvatar} from '@sentry/scraps/avatar'; -import {DrawerHeader} from '@sentry/scraps/drawer'; -import {Flex} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import {Breadcrumbs} from 'sentry/components/breadcrumbs'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {getShortEventId} from 'sentry/utils/events'; - -interface SeerDrawerHeaderProps { - event: Event; - group: Group; - project: Project; -} - -export function SeerDrawerHeader({group, project, event}: SeerDrawerHeaderProps) { - return ( - - - - - - {group.shortId} - - - ), - }, - {label: getShortEventId(event.id)}, - {label: t('Seer')}, - ]} - /> - - - ); -} - -const NavigationCrumbs = styled(Breadcrumbs)` - margin: 0; - padding: 0; -`; diff --git a/static/app/components/events/autofix/drawer/drawerNavigator.tsx b/static/app/components/events/autofix/drawer/drawerNavigator.tsx deleted file mode 100644 index 459f82bf4c9eb8..00000000000000 --- a/static/app/components/events/autofix/drawer/drawerNavigator.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {Heading} from '@sentry/scraps/text'; - -import {AiPrivacyNotice} from 'sentry/components/aiPrivacyTooltip'; -import {AutofixFeedback} from 'sentry/components/events/autofix/autofixFeedback'; -import {QuestionTooltip} from 'sentry/components/questionTooltip'; -import {IconCopy} from 'sentry/icons/iconCopy'; -import {IconRefresh} from 'sentry/icons/iconRefresh'; -import {IconSeer} from 'sentry/icons/iconSeer'; -import {IconSettings} from 'sentry/icons/iconSettings'; -import {t, tct} from 'sentry/locale'; -import type {Project} from 'sentry/types/project'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventTitle'; - -interface SeerDrawerNavigatorProps { - project: Project; - onCopyMarkdown?: () => void; - onReset?: () => void; - showCopyMarkdown?: boolean; - showReset?: boolean; -} - -export function SeerDrawerNavigator({ - project, - onCopyMarkdown, - onReset, - showCopyMarkdown = true, - showReset = true, -}: SeerDrawerNavigatorProps) { - const organization = useOrganization(); - - return ( - - - - - {t('Seer Autofix')} - - -
- -
-
- {tct('Seer can be turned off in [settingsDocs:Settings].', { - settingsDocs: ( - - ), - })} -
-
- } - size="sm" - /> -
- - {showReset && ( - - - - - - - ) : ( - - - - - - - {isExpandable && ( - - - - , - document.body - )} - - ); -} - -const SourcesContainer = styled('div')` - margin-top: -${p => p.theme.space.md}; - padding-bottom: ${p => p.theme.space.md}; - width: 100%; -`; - -export const SourceCard = styled(Button)<{isHighlighted?: boolean}>` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - font-weight: ${p => p.theme.font.weight.sans.regular}; - color: ${p => - p.isHighlighted ? p.theme.colors.white : p.theme.tokens.content.secondary}; - white-space: nowrap; - flex-shrink: 0; -`; - -const ThoughtsOverlay = styled('div')` - position: fixed; - bottom: ${p => p.theme.space.xl}; - left: 50%; - right: ${p => p.theme.space.xl}; - background: ${p => p.theme.tokens.background.primary}; - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: ${p => p.theme.radius.md}; - box-shadow: ${p => p.theme.shadow.high}; - z-index: ${p => p.theme.zIndex.tooltip}; - display: flex; - flex-direction: column; - max-height: calc(100vh - 18rem); - - @media (max-width: ${p => p.theme.breakpoints.sm}) { - left: ${p => p.theme.space.xl}; - } -`; - -const OverlayHeader = styled('div')` - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0; - border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; -`; - -const OverlayContent = styled('div')` - padding: ${p => p.theme.space.xl}; - overflow-y: auto; -`; - -const OverlayFooter = styled('div')` - padding: ${p => p.theme.space.md}; - border-top: 1px solid ${p => p.theme.tokens.border.primary}; -`; - -const OverlayButtonGroup = styled('div')` - display: flex; - justify-content: flex-end; - gap: ${p => p.theme.space.md}; - font-family: ${p => p.theme.font.family.sans}; -`; - -const OverlayTitle = styled('div')` - font-weight: bold; - color: ${p => p.theme.tokens.content.primary}; - font-family: ${p => p.theme.font.family.sans}; -`; - -const InsightTitle = styled('div')` - padding-bottom: ${p => p.theme.space.md}; - color: ${p => p.theme.tokens.content.secondary}; - font-family: ${p => p.theme.font.family.sans}; -`; - -export function generateSourceCards( - sources?: InsightSources, - codeUrls?: string[], - options?: { - isPrimary?: boolean; - location?: ReturnType; - navigate?: ReturnType; - } -) { - if (!sources && !codeUrls) { - return []; - } - - const sourceCards = []; - const {isPrimary = false, location, navigate} = options || {}; - - // Stacktrace Card - if (sources?.stacktrace_used) { - sourceCards.push({ - key: 'stacktrace', - onClick: () => { - if (navigate && location) { - navigate({ - pathname: location.pathname, - query: location.query, - hash: SectionKey.EXCEPTION, - }); - requestAnimationFrame(() => { - document - .getElementById(SectionKey.EXCEPTION) - ?.scrollIntoView({block: 'start', behavior: 'smooth'}); - }); - } - }, - icon: , - label: t('Stacktrace'), - isPrimary, - }); - } - - // Breadcrumbs Card - if (sources?.breadcrumbs_used) { - sourceCards.push({ - key: 'breadcrumbs', - onClick: () => { - if (navigate && location) { - navigate({ - pathname: location.pathname, - query: location.query, - hash: SectionKey.REQUEST, - }); - requestAnimationFrame(() => { - document - .getElementById(SectionKey.BREADCRUMBS) - ?.scrollIntoView({block: 'start', behavior: 'smooth'}); - }); - } - }, - icon: , - label: t('Breadcrumbs'), - isPrimary, - }); - } - - // HTTP Request Card - if (sources?.http_request_used) { - sourceCards.push({ - key: 'http-request', - onClick: () => { - if (navigate && location) { - navigate({ - pathname: location.pathname, - query: location.query, - hash: SectionKey.REQUEST, - }); - requestAnimationFrame(() => { - document - .getElementById(SectionKey.REQUEST) - ?.scrollIntoView({block: 'start', behavior: 'smooth'}); - }); - } - }, - icon: , - label: t('HTTP Request'), - isPrimary, - }); - } - - // Trace Event Cards - sources?.trace_event_ids_used?.forEach(id => { - sourceCards.push({ - key: `trace-${id}`, - onClick: () => { - if (sources?.event_trace_id) { - window.open( - `/explore/traces/trace/${sources.event_trace_id}/?node=span-${id}×tamp=${sources.event_trace_timestamp?.toString() ?? ''}`, - '_blank' - ); - } - }, - icon: , - label: t('Trace: %s', id.substring(0, 7)), - isPrimary, - }); - }); - - // Profile ID Cards - sources?.profile_ids_used?.forEach(id => { - sourceCards.push({ - key: `profile-${id}`, - icon: , - onClick: () => window.open(`/explore/profiling/profile/${id}/flamegraph`, '_blank'), - label: t('Profile: %s', id.substring(0, 7)), - isPrimary, - }); - }); - - // Connected Error ID Cards - sources?.connected_error_ids_used?.forEach(id => { - sourceCards.push({ - key: `error-${id}`, - onClick: () => { - if (sources?.event_trace_id) { - window.open( - `/issues/trace/${sources.event_trace_id}?node=error-${id}`, - '_blank' - ); - } - }, - icon: , - label: t('Error: %s', id.substring(0, 7)), - isPrimary, - }); - }); - - // Code URL Cards - sources?.code_used_urls?.forEach(url => { - sourceCards.push({ - key: `code-${url}`, - onClick: () => window.open(url, '_blank'), - icon: , - label: getCodeSourceName(url), - isPrimary, - }); - }); - - if (codeUrls) { - codeUrls.forEach(url => { - sourceCards.push({ - key: `passed-code-${url}`, - onClick: () => window.open(url, '_blank'), - icon: , - label: getCodeSourceName(url), - isPrimary, - }); - }); - } - - // Diff URL Cards - sources?.diff_urls?.forEach(url => { - sourceCards.push({ - key: `diff-${url}`, - onClick: () => window.open(url, '_blank'), - icon: , - label: t('Commit %s', getCommitSha(url)), - isPrimary, - }); - }); - - return sourceCards; -} diff --git a/static/app/components/events/autofix/insights/collapsibleChainLink.tsx b/static/app/components/events/autofix/insights/collapsibleChainLink.tsx deleted file mode 100644 index 25310dd2c744ed..00000000000000 --- a/static/app/components/events/autofix/insights/collapsibleChainLink.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, {useState} from 'react'; -import styled from '@emotion/styled'; - -import {Button, ButtonBar} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {TextArea} from '@sentry/scraps/textarea'; - -import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard'; -import {IconClose} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {FlippedReturnIcon} from './autofixInsightCard'; - -interface CollapsibleChainLinkProps { - groupId: string; - runId: string; - stepIndex: number; - insightCount?: number; - isCollapsed?: boolean; - isEmpty?: boolean; - showAddControl?: boolean; -} - -export function CollapsibleChainLink({ - insightCount, - isCollapsed, - isEmpty, - showAddControl, - stepIndex, - groupId, - runId, -}: CollapsibleChainLinkProps) { - // Only show the rethink button if there are no insights - const shouldShowRethinkButton = - showAddControl && !isCollapsed && !isEmpty && insightCount === 0; - - const [isAdding, setIsAdding] = useState(false); - const [newInsightText, setNewInsightText] = useState(''); - const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId}); - - const organization = useOrganization(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsAdding(false); - updateInsight({ - message: newInsightText, - step_index: stepIndex, - retain_insight_card_index: - insightCount !== undefined && insightCount > 0 ? insightCount : null, - }); - setNewInsightText(''); - - trackAnalytics('autofix.step.rethink', { - step_index: stepIndex, - group_id: groupId, - run_id: runId, - organization, - }); - }; - - const handleCancel = () => { - setIsAdding(false); - setNewInsightText(''); - }; - - return ( - - - {shouldShowRethinkButton && - (isAdding ? ( - -
- - setNewInsightText(e.target.value)} - maxLength={4096} - placeholder={t('Share your own insight here...')} - autoFocus - autosize - size="sm" - maxRows={5} - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } else if (e.key === 'Escape') { - handleCancel(); - } - }} - /> - - - - - -
-
- ) : ( - setIsAdding(true)} - tooltipProps={{title: t('Give feedback and rethink the answer')}} - aria-label={t('Give feedback and rethink the answer')} - analyticsEventName="Autofix: Step Rethink Open" - analyticsEventKey="autofix.step.rethink_open" - analyticsParams={{ - step_index: stepIndex, - group_id: groupId, - run_id: runId, - }} - > - {t('Rethink this answer')} - - - ))} -
-
- ); -} - -// Styled Components -const VerticalLineContainer = styled('div')<{ - isEmpty?: boolean; -}>` - position: relative; - z-index: 1; - width: 100%; - display: flex; - padding: 0; - min-height: ${p => (p.isEmpty ? p.theme.space['3xl'] : 'auto')}; -`; - -const RethinkButtonContainer = styled('div')` - position: relative; - display: flex; - justify-content: flex-end; - align-items: center; - width: 100%; - background: ${p => p.theme.tokens.background.primary}; - border-radius: 0; - padding: 0; - z-index: 1; -`; - -const AddEditContainer = styled('div')` - padding: ${p => p.theme.space.md}; - width: 100%; - background: ${p => p.theme.tokens.background.primary}; - border-radius: ${p => p.theme.radius.md}; -`; - -const EditInput = styled(TextArea)` - flex: 1; - resize: none; -`; - -const AddButton = styled(Button)` - color: ${p => p.theme.tokens.content.secondary}; -`; - -const RethinkLabel = styled('span')` - display: flex; - align-items: center; - font-size: ${p => p.theme.font.size.sm}; - color: ${p => p.theme.tokens.content.secondary}; - margin-right: ${p => p.theme.space.xs}; -`; diff --git a/static/app/components/events/autofix/insights/insightSourcesFooter.tsx b/static/app/components/events/autofix/insights/insightSourcesFooter.tsx deleted file mode 100644 index 40b194ed66f0d8..00000000000000 --- a/static/app/components/events/autofix/insights/insightSourcesFooter.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import {Fragment, useMemo, useState} from 'react'; -import styled from '@emotion/styled'; -import {motion} from 'framer-motion'; - -import {Button} from '@sentry/scraps/button'; -import {Input} from '@sentry/scraps/input'; -import {Flex} from '@sentry/scraps/layout'; - -import {useUpdateInsightCard} from 'sentry/components/events/autofix/hooks/useUpdateInsightCard'; -import { - generateSourceCards, - SourceCard, -} from 'sentry/components/events/autofix/insights/autofixInsightSources'; -import type {AutofixInsight} from 'sentry/components/events/autofix/types'; -import { - deduplicateSourcesAndUpdateInsights, - getExpandedInsightSources, -} from 'sentry/components/events/autofix/utils/insightUtils'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {cardAnimationProps, FlippedReturnIcon} from './autofixInsightCard'; - -interface InsightSourcesFooterProps { - expandedCardIndex: number | null; - groupId: string; - insights: AutofixInsight[]; - runId: string; - stepIndex: number; -} - -export function InsightSourcesFooter({ - insights, - expandedCardIndex, - stepIndex, - groupId, - runId, -}: InsightSourcesFooterProps) { - const navigate = useNavigate(); - const location = useLocation(); - const [newInsightText, setNewInsightText] = useState(''); - const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId}); - const organization = useOrganization(); - - const {deduplicatedSources, updatedInsights} = useMemo( - () => deduplicateSourcesAndUpdateInsights(insights), - [insights] - ); - - const expandedSources = useMemo( - () => getExpandedInsightSources(updatedInsights, expandedCardIndex), - [updatedInsights, expandedCardIndex] - ); - - const sourceCards = useMemo( - () => generateSourceCards(deduplicatedSources, undefined, {location, navigate}), - [deduplicatedSources, location, navigate] - ); - - const expandedCards = useMemo( - () => - expandedSources - ? generateSourceCards(expandedSources, undefined, {location, navigate}) - : [], - [expandedSources, location, navigate] - ); - - const renderedSourceCards = useMemo( - () => - sourceCards.map(sourceCard => { - // Check if this source should be primary (expanded insight contains it) - const shouldBePrimary = expandedCards.some( - expandedCard => expandedCard.key === sourceCard.key - ); - - return ( - - - {sourceCard.label} - - - ); - }), - [sourceCards, expandedCards] - ); - - if (insights.length === 0) { - return null; - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!newInsightText.trim()) return; - - updateInsight({ - message: newInsightText, - step_index: stepIndex, - retain_insight_card_index: insights.length > 0 ? insights.length : null, - }); - setNewInsightText(''); - - trackAnalytics('autofix.step.rethink', { - step_index: stepIndex, - group_id: groupId, - run_id: runId, - organization, - }); - }; - - return ( - - - - - - {renderedSourceCards} - - - - setNewInsightText(e.target.value)} - maxLength={4096} - placeholder={t('Rethink this answer...')} - size="xs" - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }} - /> - - - - - - - - - ); -} - -// Styled Components -const BottomDivider = styled('div')` - margin-top: ${p => p.theme.space.lg}; - border-top: 1px solid ${p => p.theme.tokens.border.secondary}; -`; - -const FooterInputContainer = styled('div')` - width: 50%; - max-width: 250px; - align-self: flex-end; -`; - -const FooterInputWrapper = styled('form')` - display: flex; - position: relative; - border-radius: ${p => p.theme.radius.md}; -`; - -const FooterInput = styled(Input)` - padding-right: ${p => p.theme.space['3xl']}; -`; - -const FooterSubmitButton = styled(Button)` - position: absolute; - right: ${p => p.theme.space.md}; - top: 50%; - transform: translateY(-50%); - height: 24px; - width: 24px; - border-radius: 5px; -`; diff --git a/static/app/components/events/autofix/seerCreateViewButton.tsx b/static/app/components/events/autofix/seerCreateViewButton.tsx deleted file mode 100644 index f1ba3dd6698e85..00000000000000 --- a/static/app/components/events/autofix/seerCreateViewButton.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import {useMemo} from 'react'; -import {useQuery} from '@tanstack/react-query'; - -import {Button} from '@sentry/scraps/button'; - -import { - addErrorMessage, - addLoadingMessage, - addSuccessMessage, -} from 'sentry/actionCreators/indicator'; -import {t} from 'sentry/locale'; -import type {Project} from 'sentry/types/project'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useCreateGroupSearchView} from 'sentry/views/issueList/mutations/useCreateGroupSearchView'; -import {useUpdateGroupSearchViewStarred} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViewStarred'; -import {groupSearchViewsApiOptions} from 'sentry/views/issueList/queries/useFetchGroupSearchViews'; -import { - GroupSearchViewCreatedBy, - type GroupSearchView, -} from 'sentry/views/issueList/types'; -import {IssueSortOptions} from 'sentry/views/issueList/utils'; - -interface StarFixabilityViewButtonProps { - isCompleted: boolean; - project: Project; -} - -const TARGET_VIEW_PROPERTIES = { - name: 'Easy Fixes 🤖', - query: 'is:unresolved issue.seer_actionability:[high,super_high]', - querySort: IssueSortOptions.DATE, - projects: [], - environments: [], - timeFilters: { - start: null, - end: null, - period: '7d', - utc: null, - }, -}; - -export function StarFixabilityViewButton({ - isCompleted, - project, -}: StarFixabilityViewButtonProps) { - const organization = useOrganization(); - - const {mutate: createIssueView} = useCreateGroupSearchView({ - onMutate: () => { - addLoadingMessage(t('Creating view...')); - }, - onSuccess: () => { - addSuccessMessage(t('View starred successfully')); - }, - onError: () => { - addErrorMessage(t('Failed to create view')); - }, - }); - - const {mutate: starExistingView} = useUpdateGroupSearchViewStarred({ - onMutate: () => { - addLoadingMessage(t('Starring view...')); - }, - onSuccess: () => { - addSuccessMessage(t('View starred successfully')); - }, - onError: () => { - addErrorMessage(t('Failed to star view')); - }, - }); - - // Fetch all views to check for existing ones with our target name - const {data: othersViews = []} = useQuery( - groupSearchViewsApiOptions({ - orgSlug: organization.slug, - limit: 20, - query: 'Easy Fixes 🤖', // Search by name - sort: ['-popularity'], - createdBy: GroupSearchViewCreatedBy.OTHERS, - }) - ); - - const {data: myViews = []} = useQuery( - groupSearchViewsApiOptions({ - orgSlug: organization.slug, - limit: 20, - query: 'Easy Fixes 🤖', // Search by name - sort: ['-popularity'], - createdBy: GroupSearchViewCreatedBy.ME, - }) - ); - - const allViews = useMemo(() => [...othersViews, ...myViews], [othersViews, myViews]); - - // Check if an existing view matches our criteria - const existingMatchingView = useMemo(() => { - return allViews.find((view: GroupSearchView) => { - // Must have exact name match - if (view.name !== TARGET_VIEW_PROPERTIES.name) { - return false; - } - - // Must query the right field - if ( - !view.query.includes('issue.seer_actionability:[high,super_high]') && - !view.query.includes('issue.seer_actionability:[super_high,high]') && - !view.query.includes('issue.seer_actionability:super_high') - ) { - return false; - } - - // Check project match - either matches current project or is "All Projects" (empty array) - const viewHasNoProjects = view.projects.length === 0; - const viewHasCurrentProject = view.projects.includes(Number(project.id)); - const projectMatches = viewHasNoProjects || viewHasCurrentProject; - - if (!projectMatches) { - return false; - } - - return true; - }); - }, [allViews, project.id]); - - const handleStarFixabilityView = () => { - if (existingMatchingView) { - // Star the existing view instead of creating a new one - starExistingView({ - id: existingMatchingView.id, - starred: true, - view: existingMatchingView, - }); - } else { - // Create a new view - createIssueView({ - ...TARGET_VIEW_PROPERTIES, - starred: true, - }); - } - }; - - return ( - - ); -} diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 9154d4f8713bad..0fad08866b7dcf 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -1,7 +1,4 @@ import {t} from 'sentry/locale'; -import type {EventMetadata} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {User} from 'sentry/types/user'; import {isArrayOf} from 'sentry/types/utils'; export enum DiffFileType { @@ -32,22 +29,6 @@ function isDiffLineType(value: unknown): value is DiffLineType { ); } -export enum AutofixStepType { - DEFAULT = 'default', - ROOT_CAUSE_ANALYSIS = 'root_cause_analysis', - CHANGES = 'changes', - SOLUTION = 'solution', -} - -export enum AutofixStatus { - COMPLETED = 'COMPLETED', - ERROR = 'ERROR', - PROCESSING = 'PROCESSING', - NEED_MORE_INFORMATION = 'NEED_MORE_INFORMATION', - CANCELLED = 'CANCELLED', - WAITING_FOR_USER_RESPONSE = 'WAITING_FOR_USER_RESPONSE', -} - export enum AutofixStoppingPoint { ROOT_CAUSE = 'root_cause', SOLUTION = 'solution', @@ -55,22 +36,6 @@ export enum AutofixStoppingPoint { OPEN_PR = 'open_pr', } -type AutofixPullRequestDetails = { - pr_number: number; - pr_url: string; -}; - -type AutofixOptions = { - iterative_feedback?: boolean; -}; - -interface CodingAgentResult { - description: string; - pr_url: string | null; - repo_full_name: string; - repo_provider: string; -} - export enum CodingAgentStatus { PENDING = 'pending', RUNNING = 'running', @@ -91,193 +56,6 @@ export function getResultButtonLabel(url: string | null | undefined): string { return t('View Pull Request'); } -export interface CodingAgentState { - id: string; - name: string; - provider: CodingAgentProvider; - started_at: string; - status: CodingAgentStatus; - agent_url?: string; - results?: CodingAgentResult[]; -} - -type CodebaseState = { - is_readable: boolean | null; - is_writeable: boolean | null; - repo_external_id: string | null; -}; - -export type AutofixData = { - codebases: Record; - last_triggered_at: string; - request: { - repos: SeerRepoDefinition[]; - options?: { - auto_run_source?: string | null; - }; - }; - run_id: string; - status: AutofixStatus; - actor_ids?: number[]; - codebase_indexing?: { - status: 'COMPLETED'; - }; - coding_agents?: Record; - completed_at?: string | null; - error_message?: string; - options?: AutofixOptions; - steps?: AutofixStep[]; - users?: Record; -}; - -export type AutofixProgressItem = { - message: string; - timestamp: string; - type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION'; - data?: any; -}; - -export type AutofixStep = - | AutofixDefaultStep - | AutofixRootCauseStep - | AutofixSolutionStep - | AutofixChangesStep; - -interface BaseStep { - id: string; - index: number; - progress: AutofixProgressItem[]; - status: AutofixStatus; - title: string; - type: AutofixStepType; - active_comment_thread?: CommentThread | null; - agent_comment_thread?: CommentThread | null; - completedMessage?: string; - key?: string; - output_stream?: string | null; -} - -export type CommentThread = { - id: string; - is_completed: boolean; - messages: CommentThreadMessage[]; -}; - -export interface CommentThreadMessage { - content: string; - role: 'user' | 'assistant'; - isLoading?: boolean; -} - -export type AutofixInsight = { - insight: string; - justification: string; - change_diff?: FilePatch[]; - markdown_snippets?: string; - sources?: InsightSources; - type?: 'insight' | 'file_change'; -}; - -export type InsightSources = { - breadcrumbs_used: boolean; - code_used_urls: string[]; - connected_error_ids_used: string[]; - diff_urls: string[]; - http_request_used: boolean; - profile_ids_used: string[]; - stacktrace_used: boolean; - thoughts: string; - trace_event_ids_used: string[]; - event_trace_id?: string; - event_trace_timestamp?: number; -}; - -export interface AutofixDefaultStep extends BaseStep { - insights: AutofixInsight[]; - type: AutofixStepType.DEFAULT; -} - -export type AutofixRootCauseSelection = - | { - cause_id: string; - } - | {custom_root_cause: string} - | null; - -interface AutofixRootCauseStep extends BaseStep { - causes: AutofixRootCauseData[]; - selection: AutofixRootCauseSelection; - type: AutofixStepType.ROOT_CAUSE_ANALYSIS; - termination_reason?: string; -} - -interface AutofixSolutionStep extends BaseStep { - solution: AutofixSolutionTimelineEvent[]; - solution_selected: boolean; - type: AutofixStepType.SOLUTION; - custom_solution?: string; - description?: string; -} - -export type AutofixCodebaseChange = { - description: string; - diff: FilePatch[]; - repo_name: string; - title: string; - branch_name?: string; - diff_str?: string; - pull_request?: AutofixPullRequestDetails; - repo_external_id?: string; - repo_id?: number; // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon. Use repo_external_id instead. -}; - -export interface AutofixChangesStep extends BaseStep { - changes: AutofixCodebaseChange[]; - type: AutofixStepType.CHANGES; - termination_reason?: string; -} - -type AutofixRelevantCodeFile = { - file_path: string; - repo_name: string; -}; - -type AutofixRelevantCodeFileWithUrl = AutofixRelevantCodeFile & { - url?: string; -}; - -export type AutofixTimelineEvent = { - code_snippet_and_analysis: string; - relevant_code_file: AutofixRelevantCodeFile; - timeline_item_type: 'internal_code' | 'external_system' | 'human_action'; - title: string; - is_most_important_event?: boolean; -}; - -export type AutofixSolutionTimelineEvent = { - timeline_item_type: 'internal_code' | 'human_instruction'; - title: string; - code_snippet_and_analysis?: string; - is_active?: boolean; - is_most_important_event?: boolean; - relevant_code_file?: AutofixRelevantCodeFileWithUrl; -}; - -export type AutofixRootCauseData = { - id: string; - description?: string; - reproduction_urls?: Array; - root_cause_reproduction?: AutofixTimelineEvent[]; -}; - -type EventMetadataWithAutofix = EventMetadata & { - autofix?: AutofixData; -}; - -export type GroupWithAutofix = Group & { - metadata?: EventMetadataWithAutofix; -}; - export type FilePatch = { added: number; hunks: Hunk[]; diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx index 035f091dc8805f..abc969f0682d6a 100644 --- a/static/app/components/events/autofix/useAutofix.tsx +++ b/static/app/components/events/autofix/useAutofix.tsx @@ -1,316 +1,6 @@ -import {useCallback, useMemo, useState} from 'react'; -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; - -import {useModal} from '@sentry/scraps/modal'; - -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {AutofixCursorGithubAccessModal} from 'sentry/components/events/autofix/autofixCursorGithubAccessModal'; -import {AutofixGithubAppPermissionsModal} from 'sentry/components/events/autofix/autofixGithubAppPermissionsModal'; -import {AutofixGithubCopilotPurchaseModal} from 'sentry/components/events/autofix/autofixGithubCopilotPurchaseModal'; -import { - AutofixStatus, - AutofixStepType, - AutofixStoppingPoint, - CodingAgentStatus, - type AutofixData, - type GroupWithAutofix, -} from 'sentry/components/events/autofix/types'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; import type {Organization} from 'sentry/types/organization'; import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {fetchMutation} from 'sentry/utils/queryClient'; import type {RequestError} from 'sentry/utils/requestError/requestError'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -type AutofixResponse = { - autofix: AutofixData | null; -}; - -const POLL_INTERVAL = 500; - -export function autofixApiOptions( - orgSlug: string, - groupId: string, - isUserWatching = false -) { - return apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', - { - path: {organizationIdOrSlug: orgSlug, issueId: groupId}, - query: {isUserWatching, mode: 'legacy'}, - staleTime: Infinity, - } - ); -} - -const makeInitialAutofixData = (): AutofixResponse => ({ - autofix: { - status: AutofixStatus.PROCESSING, - run_id: '', - steps: [ - { - type: AutofixStepType.DEFAULT, - id: '1', - index: 0, - status: AutofixStatus.PROCESSING, - title: 'Starting Autofix...', - insights: [], - progress: [ - { - message: 'Ingesting Sentry data...', - timestamp: new Date().toISOString(), - type: 'INFO', - }, - ], - }, - ], - last_triggered_at: new Date().toISOString(), - request: { - repos: [], - }, - codebases: {}, - }, -}); - -const makeErrorAutofixData = (errorMessage: string): AutofixResponse => { - const data = makeInitialAutofixData(); - - if (data.autofix) { - data.autofix.status = AutofixStatus.ERROR; - data.autofix.steps = [ - { - type: AutofixStepType.DEFAULT, - id: '1', - index: 0, - status: AutofixStatus.ERROR, - title: 'Something went wrong', - completedMessage: errorMessage, - insights: [], - progress: [], - }, - ]; - } - - return data; -}; - -/** Will not poll when the autofix is in an error state or has completed */ -const isPolling = ( - autofixData: AutofixData | null, - runStarted: boolean, - isSidebar?: boolean -) => { - if (!autofixData && !runStarted) { - return false; - } - - if (!autofixData?.steps) { - return true; - } - - if ( - autofixData.status === AutofixStatus.PROCESSING || - autofixData?.steps.some(step => step.status === AutofixStatus.PROCESSING) - ) { - return true; - } - - // Check if there's any active comment thread that hasn't been completed - const hasActiveCommentThread = autofixData.steps.some( - step => - (step.active_comment_thread && !step.active_comment_thread.is_completed) || - (step.agent_comment_thread && !step.agent_comment_thread.is_completed) - ); - - const hasSolutionStep = autofixData.steps.some( - step => step.type === AutofixStepType.SOLUTION - ); - - if ( - !hasSolutionStep && - ![AutofixStatus.ERROR, AutofixStatus.CANCELLED, AutofixStatus.COMPLETED].includes( - autofixData.status - ) - ) { - // we want to keep polling until we have a solution step because that's a stopping point - // we need this explicit check in case we get a state for a fraction of a second where the root cause is complete and there is no step after it started - return true; - } - - // Continue polling if there's an active comment thread, even if the run is completed - if (!isSidebar && hasActiveCommentThread) { - return true; - } - - // Poll while coding agent state is pending or running - if ( - autofixData.coding_agents && - Object.values(autofixData.coding_agents).some( - agent => - agent.status === CodingAgentStatus.PENDING || - agent.status === CodingAgentStatus.RUNNING - ) - ) { - return true; - } - - return ( - !autofixData || - ![ - AutofixStatus.ERROR, - AutofixStatus.COMPLETED, - AutofixStatus.CANCELLED, - AutofixStatus.NEED_MORE_INFORMATION, - ].includes(autofixData.status) - ); -}; - -export const useAutofixRepos = (groupId: string) => { - const {data} = useAutofixData({groupId, isUserWatching: true}); - - return useMemo(() => { - const repos = data?.request?.repos ?? []; - const codebases = data?.codebases ?? {}; - - return { - repos: repos.map(repo => ({ - ...repo, - is_readable: codebases[repo.external_id]?.is_readable, - is_writeable: codebases[repo.external_id]?.is_writeable, - })), - codebases, - }; - }, [data]); -}; - -export const useAutofixData = ({ - groupId, - isUserWatching = false, -}: { - groupId: string; - isUserWatching?: boolean; -}) => { - const orgSlug = useOrganization().slug; - - const {data, isPending} = useQuery({ - ...autofixApiOptions(orgSlug, groupId, isUserWatching), - enabled: false, - }); - - return {data: data?.autofix ?? null, isPending}; -}; - -export const useAiAutofix = ( - group: GroupWithAutofix, - event: Event, - options: { - isSidebar?: boolean; - pollInterval?: number; - } = {} -) => { - const api = useApi(); - const queryClient = useQueryClient(); - const orgSlug = useOrganization().slug; - const isUserWatching = !options.isSidebar; - - const [isReset, setIsReset] = useState(false); - const [currentRunId, setCurrentRunId] = useState(null); - const [waitingForNextRun, setWaitingForNextRun] = useState(false); - - const {data: apiData, isPending} = useQuery({ - ...autofixApiOptions(orgSlug, group.id, isUserWatching), - staleTime: 0, - retry: false, - refetchInterval: query => { - if ( - isPolling( - query.state.data?.json?.autofix || null, - !!currentRunId || waitingForNextRun, - options.isSidebar - ) - ) { - return options.pollInterval ?? POLL_INTERVAL; - } - return false; - }, - refetchOnWindowFocus: 'always', - }); - - const triggerAutofix = useCallback( - async (instruction: string, stoppingPoint?: AutofixStoppingPoint) => { - setIsReset(false); - setCurrentRunId(null); - setWaitingForNextRun(true); - queryClient.setQueryData( - autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey, - prev => ({headers: prev?.headers ?? {}, json: makeInitialAutofixData()}) - ); - - try { - const response = await api.requestPromise( - `/organizations/${orgSlug}/issues/${group.id}/autofix/`, - { - method: 'POST', - query: {mode: 'legacy'}, - data: { - event_id: event.id, - instruction, - referrer: 'api.web', - ...(stoppingPoint && {stopping_point: stoppingPoint}), - }, - } - ); - setCurrentRunId(response.run_id ?? null); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey, - }); - } catch (e: any) { - setWaitingForNextRun(false); - queryClient.setQueryData( - autofixApiOptions(orgSlug, group.id, isUserWatching).queryKey, - prev => ({ - headers: prev?.headers ?? {}, - json: makeErrorAutofixData(e?.responseJSON?.detail ?? 'An error occurred'), - }) - ); - } - }, - [queryClient, group.id, api, event.id, orgSlug, isUserWatching] - ); - - const reset = useCallback(() => { - setIsReset(true); - setCurrentRunId(null); - setWaitingForNextRun(true); - }, []); - - let autofixData = apiData?.autofix ?? null; - if (waitingForNextRun) { - autofixData = makeInitialAutofixData().autofix; - } - if (isReset) { - autofixData = null; - } - - if ( - apiData?.autofix?.steps?.length && - apiData?.autofix?.steps[0]?.progress.length && - waitingForNextRun && - apiData?.autofix?.run_id === currentRunId - ) { - setWaitingForNextRun(false); - } - - return { - autofixData, - isPolling: isPolling(autofixData, !!currentRunId || waitingForNextRun), - isPending, - triggerAutofix, - reset, - }; -}; export type CodingAgentIntegration = { id: string | null; @@ -329,36 +19,6 @@ export function organizationIntegrationsCodingAgents(organization: Organization) }); } -interface LaunchCodingAgentParams { - agentName: string; - integrationId: string | null; - provider: string; - instruction?: string; - triggerSource?: 'root_cause' | 'solution'; -} - -interface LaunchCodingAgentResponse { - failed_count: number; - launched_count: number; - success: boolean; - failures?: Array<{ - error_message: string; - repo_name: string; - failure_type?: string; - github_installation_id?: string; - }>; -} - -function getErrorMessage(error: RequestError, agentName: string): string { - const detail = error.responseJSON?.detail; - - if (detail && typeof detail === 'string') { - return detail; - } - - return t('Failed to launch %s', agentName); -} - export function needsGitHubAuth(error: RequestError): boolean { const detail = error.responseJSON?.detail; return ( @@ -367,107 +27,3 @@ export function needsGitHubAuth(error: RequestError): boolean { detail.toLowerCase().includes('authorization') ); } - -export function useLaunchCodingAgent(groupId: string, runId: string) { - const {openModal} = useModal(); - - const organization = useOrganization(); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (params: LaunchCodingAgentParams) => { - const data: Record = { - run_id: parseInt(runId, 10), - trigger_source: params.triggerSource, - instruction: params.instruction, - }; - - if (params.integrationId === null) { - data.provider = params.provider; - } else { - data.integration_id = parseInt(params.integrationId, 10); - } - - return fetchMutation({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - method: 'POST', - data, - }); - }, - onSuccess: (data, params) => { - if (data.failures && data.failures.length > 0) { - const permissionFailures = data.failures.filter( - f => f.failure_type === 'github_app_permissions' - ); - const copilotLicenseFailures = data.failures.filter( - f => f.failure_type === 'github_copilot_not_licensed' - ); - const cursorGithubAccessFailures = data.failures.filter( - f => f.failure_type === 'cursor_github_access' - ); - const otherFailures = data.failures.filter( - f => - f.failure_type !== 'github_app_permissions' && - f.failure_type !== 'github_copilot_not_licensed' && - f.failure_type !== 'cursor_github_access' - ); - - if (permissionFailures.length > 0) { - const installationId = permissionFailures[0]?.github_installation_id; - const installationUrl = installationId - ? `https://github.com/settings/installations/${installationId}` - : undefined; - openModal(deps => ( - - )); - } - - if (copilotLicenseFailures.length > 0) { - openModal(deps => ); - } - - if (cursorGithubAccessFailures.length > 0) { - openModal(deps => ); - } - - otherFailures.forEach(failure => { - addErrorMessage(t('%s: %s', failure.repo_name, failure.error_message)); - }); - - if (data.launched_count > 0) { - const successRepoText = - data.launched_count === 1 - ? t('%s launched for 1 repository', params.agentName) - : t( - '%s launched for %s repositories', - params.agentName, - data.launched_count - ); - addSuccessMessage(successRepoText); - } - } else { - addSuccessMessage(t('%s launched successfully', params.agentName)); - } - - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(organization.slug, groupId, false).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(organization.slug, groupId, true).queryKey, - }); - }, - onError: (error, params) => { - if (needsGitHubAuth(error)) { - const currentUrl = window.location.href; - const oauthUrl = `/remote/github-copilot/oauth/?next=${encodeURIComponent(currentUrl)}`; - window.location.href = oauthUrl; - return; - } - const message = getErrorMessage(error, params.agentName); - addErrorMessage(message); - }, - }); -} diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 9befd99609f146..73c94332d7d6fe 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -119,7 +119,7 @@ export function isCodingAgentsArtifact( * State returned from the Explorer autofix endpoint. * This extends the SeerExplorer types with autofix-specific data. */ -interface ExplorerAutofixState { +export interface ExplorerAutofixState { blocks: Block[]; run_id: number; status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; diff --git a/static/app/components/events/autofix/useTextSelection.tsx b/static/app/components/events/autofix/useTextSelection.tsx deleted file mode 100644 index b30c52bbdcf9d5..00000000000000 --- a/static/app/components/events/autofix/useTextSelection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import {useCallback, useEffect, useState} from 'react'; - -interface TextSelection { - isRangeSelection: boolean; - referenceElement: HTMLElement | null; - selectedText: string; -} - -export function useTextSelection(containerRef: React.RefObject) { - const [selection, setSelection] = useState(null); - - const isClickInPopup = (target: HTMLElement) => - target.closest('[data-popup="autofix-highlight"]'); - - const shouldIgnoreElement = (target: HTMLElement) => - target.closest('[data-ignore-autofix-highlight="true"]'); - - const getSelectedTextWithinContainer = useCallback(() => { - const container = containerRef.current; - if (!container) { - return ''; - } - const sel = window.getSelection(); - if (!sel || sel.isCollapsed) { - return ''; - } - const anchorNode = sel.anchorNode; - const focusNode = sel.focusNode; - if (!anchorNode || !focusNode) { - return ''; - } - - const isNodeInside = (node: Node) => { - const element = - node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; - return !!element && container.contains(element); - }; - - // Only treat as a valid selection if both endpoints are within our container - if (!isNodeInside(anchorNode) || !isNodeInside(focusNode)) { - return ''; - } - - return sel.toString().trim(); - }, [containerRef]); - - const handleMouseUp = useCallback( - (event: MouseEvent) => { - const target = event.target as HTMLElement; - - // Ignore interactions with the popup or explicitly ignored elements - if (isClickInPopup(target) || shouldIgnoreElement(target)) { - return; - } - - // Only react to mouseup inside the container - if (!containerRef.current?.contains(target)) { - return; - } - - const selected = getSelectedTextWithinContainer(); - if (selected) { - setSelection({ - selectedText: selected, - referenceElement: containerRef.current, - isRangeSelection: true, - }); - } - }, - [containerRef, getSelectedTextWithinContainer] - ); - - const handleClick = useCallback( - (event: MouseEvent) => { - const target = event.target as HTMLElement; - - // If clicking in popup, do nothing - if (isClickInPopup(target)) { - return; - } - - // If clicking in an ignored element, do nothing - if (shouldIgnoreElement(target)) { - return; - } - - // Check if the click is within our container - const isContainedWithin = containerRef.current?.contains(target); - if (!isContainedWithin) { - setSelection(null); - return; - } - - // If there is an actual user text selection inside the container, prefer that - const currentUserSelection = getSelectedTextWithinContainer(); - if (currentUserSelection) { - setSelection({ - selectedText: currentUserSelection, - referenceElement: containerRef.current!, - isRangeSelection: true, - }); - return; - } - - // If clicking within the same container while already selected (and no range selection), toggle off - if (selection?.referenceElement === containerRef.current) { - setSelection(null); - return; - } - - // Otherwise treat it as a simple click-to-open - const clickedText = containerRef.current?.textContent?.trim() || ''; - if (!clickedText) { - setSelection(null); - return; - } - - setSelection({ - selectedText: clickedText, - referenceElement: containerRef.current!, - isRangeSelection: false, - }); - }, - [containerRef, selection, getSelectedTextWithinContainer] - ); - - const clearSelection = useCallback( - (event: MouseEvent) => { - const target = event.target as HTMLElement; - - // Don't clear if clicking within the popup - if (isClickInPopup(target)) { - return; - } - - // Don't clear if clicking the original container that triggered the popup - if (containerRef.current?.contains(target)) { - return; - } - - setSelection(null); - }, - [containerRef] - ); - - useEffect(() => { - document.addEventListener('click', handleClick, true); - document.addEventListener('mouseup', handleMouseUp, true); - document.addEventListener('mousedown', clearSelection); - - return () => { - document.removeEventListener('click', handleClick, true); - document.removeEventListener('mouseup', handleMouseUp, true); - document.removeEventListener('mousedown', clearSelection); - }; - }, [handleClick, handleMouseUp, clearSelection]); - - return selection; -} diff --git a/static/app/components/events/autofix/useTypingAnimation.ts b/static/app/components/events/autofix/useTypingAnimation.ts deleted file mode 100644 index 312a6228569a23..00000000000000 --- a/static/app/components/events/autofix/useTypingAnimation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; - -interface UseTypingAnimationProps { - /** - * The full text to animate. - */ - text: string; - /** - * Whether the animation should run. If false, displays the full text immediately. Defaults to true. - */ - enabled?: boolean; - /** - * Callback fired when the animation completes. - */ - onComplete?: () => void; - /** - * Animation speed in characters per second. Defaults to 50. - */ - speed?: number; -} - -/** - * Animates the display of text as if it were being typed. - */ -export function useTypingAnimation({ - text, - speed = 50, - enabled = true, - onComplete, -}: UseTypingAnimationProps): string { - const [displayedText, setDisplayedText] = useState(enabled ? '' : text); - const currentIndexRef = useRef(enabled ? 0 : text.length); - const animationFrameRef = useRef(null); - const lastUpdateTimeRef = useRef(0); - const onCompleteRef = useRef(onComplete); - - // Keep the onComplete callback reference up-to-date - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - useEffect(() => { - // If disabled, show full text immediately - if (!enabled) { - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setDisplayedText(text); - currentIndexRef.current = text.length; - if (animationFrameRef.current) { - window.cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - return () => {}; - } - - // Reset state for new animation - setDisplayedText(''); - currentIndexRef.current = 0; - lastUpdateTimeRef.current = performance.now(); - - const interval = 1000 / speed; // ms per character - - const animate = (timestamp: number) => { - if (!enabled) return; // Check enabled status - - const elapsed = timestamp - lastUpdateTimeRef.current; - const charsToAdd = Math.floor(elapsed / interval); - - if (charsToAdd > 0) { - const nextIndex = Math.min(text.length, currentIndexRef.current + charsToAdd); - if (nextIndex > currentIndexRef.current) { - setDisplayedText(_prev => text.slice(0, nextIndex)); // Use functional update, ignore prev - currentIndexRef.current = nextIndex; - lastUpdateTimeRef.current = timestamp; - } - } - - if (currentIndexRef.current < text.length) { - animationFrameRef.current = window.requestAnimationFrame(animate); - } else { - // Final check to ensure full text is displayed - setDisplayedText(currentDisplayedText => { - if (currentDisplayedText !== text) { - return text; - } - return currentDisplayedText; - }); - animationFrameRef.current = null; - if (onCompleteRef.current) { - onCompleteRef.current(); - } - } - }; - - // Clear previous frame before starting - if (animationFrameRef.current) { - window.cancelAnimationFrame(animationFrameRef.current); - } - animationFrameRef.current = window.requestAnimationFrame(animate); - - // Cleanup - return () => { - if (animationFrameRef.current) { - window.cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - }; - }, [text, speed, enabled]); // Dependencies are correct now - - // Effect to immediately set full text if enabled becomes false - useEffect(() => { - if (!enabled) { - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setDisplayedText(text); - currentIndexRef.current = text.length; - } - }, [enabled, text]); - - return displayedText; -} diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx index 919f0fdc78a033..3cfb7d38912ab8 100644 --- a/static/app/components/events/autofix/utils.tsx +++ b/static/app/components/events/autofix/utils.tsx @@ -1,191 +1,8 @@ import {useCallback, useMemo} from 'react'; -import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootCause'; -import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution'; -import { - AUTOFIX_TTL_IN_DAYS, - AutofixStatus, - AutofixStepType, - type AutofixCodebaseChange, - type AutofixData, - type AutofixRootCauseData, - type AutofixSolutionTimelineEvent, -} from 'sentry/components/events/autofix/types'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; +import {AUTOFIX_TTL_IN_DAYS} from 'sentry/components/events/autofix/types'; import type {Group} from 'sentry/types/group'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {formatEventToMarkdown} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; - -export function getRootCauseDescription(autofixData: AutofixData) { - const rootCause = autofixData.steps?.find( - step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS - ); - if (!rootCause) { - return null; - } - return rootCause.causes.at(0)?.description ?? null; -} - -export function getRootCauseCopyText(autofixData: AutofixData) { - const rootCause = autofixData.steps?.find( - step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS - ); - if (!rootCause) { - return null; - } - - const cause = rootCause.causes.at(0); - - if (!cause) { - return null; - } - - return formatRootCauseText(cause); -} - -export function getSolutionDescription(autofixData: AutofixData) { - const solution = autofixData.steps?.find( - step => step.type === AutofixStepType.SOLUTION - ); - if (!solution) { - return null; - } - - return solution.description ?? null; -} - -export function getSolutionCopyText(autofixData: AutofixData) { - const solution = autofixData.steps?.find( - step => step.type === AutofixStepType.SOLUTION - ); - if (!solution) { - return null; - } - - return formatSolutionText(solution.solution, solution.custom_solution); -} - -export function formatRootCauseWithEvent( - cause: AutofixRootCauseData | undefined, - customRootCause: string | undefined, - event: Event | undefined -): string { - const rootCauseText = formatRootCauseText(cause, customRootCause); - - if (!event) { - return rootCauseText; - } - - const eventText = '\n# Raw Event Data\n' + formatEventToMarkdown(event, undefined); - return rootCauseText + eventText; -} - -export function formatSolutionWithEvent( - solution: AutofixSolutionTimelineEvent[] | undefined, - customSolution: string | undefined, - event: Event | undefined, - rootCause?: AutofixRootCauseData -): string { - let combinedText = ''; - - if (rootCause) { - const rootCauseText = formatRootCauseText(rootCause); - combinedText += rootCauseText + '\n\n'; - } - - const solutionText = formatSolutionText(solution || [], customSolution); - combinedText += solutionText; - - if (event) { - const eventText = '\n# Raw Event Data\n' + formatEventToMarkdown(event, undefined); - combinedText += eventText; - } - - return combinedText; -} - -export function getSolutionIsLoading(autofixData: AutofixData) { - const solutionProgressStep = autofixData.steps?.find( - step => step.key === 'solution_processing' - ); - return solutionProgressStep?.status === AutofixStatus.PROCESSING; -} - -export function getCodeChangesDescription(autofixData: AutofixData) { - if (!autofixData) { - return null; - } - - const changesStep = autofixData.steps?.find( - step => step.type === AutofixStepType.CHANGES - ); - - if (!changesStep) { - return null; - } - - // If there are changes with PRs, show links to them - const changesWithPRs = changesStep.changes?.filter( - (change: AutofixCodebaseChange) => change.pull_request - ); - if (changesWithPRs?.length) { - return changesWithPRs - .map( - (change: AutofixCodebaseChange) => - `[View PR in ${change.repo_name}](${change.pull_request?.pr_url})` - ) - .join('\n'); - } - - // If there are code changes but no PRs yet, show a summary - if (changesStep.changes?.length) { - // Group changes by repo - const changesByRepo: Record = {}; - changesStep.changes.forEach((change: AutofixCodebaseChange) => { - changesByRepo[change.repo_name] = (changesByRepo[change.repo_name] || 0) + 1; - }); - - const changesSummary = Object.entries(changesByRepo) - .map(([repo, count]) => `${count} ${count === 1 ? 'change' : 'changes'} in ${repo}`) - .join(', '); - - return `Proposed ${changesSummary}.`; - } - - return null; -} - -export const getCodeChangesIsLoading = (autofixData: AutofixData) => { - if (!autofixData) { - return false; - } - - // Check if there's a specific changes processing step, similar to solution_processing - const changesProgressStep = autofixData.steps?.find(step => step.key === 'plan'); - if (changesProgressStep?.status === AutofixStatus.PROCESSING) { - return true; - } - - // Also check if the changes step itself is in processing state - const changesStep = autofixData.steps?.find( - step => step.type === AutofixStepType.CHANGES - ); - - return changesStep?.status === AutofixStatus.PROCESSING; -}; - -export function hasPullRequest(autofixData: AutofixData | null | undefined): boolean { - if (!autofixData) { - return false; - } - - const changesStep = autofixData.steps?.find( - step => step.type === AutofixStepType.CHANGES - ); - - return Boolean(changesStep?.changes?.some(change => change.pull_request)); -} const BASE_SUPPORTED_PROVIDERS = [ 'github', @@ -253,46 +70,6 @@ export function useIsSeerSupportedProvider(): (provider: { ); } -interface AutofixProgressDetails { - overallProgress: number; -} - -export function getAutofixProgressDetails( - autofixData?: AutofixData -): AutofixProgressDetails { - if (!autofixData) { - return {overallProgress: 0}; - } - - const steps = autofixData.steps ?? []; - - if (autofixData.status === AutofixStatus.COMPLETED) { - return {overallProgress: 100}; - } - - if ( - autofixData.status === AutofixStatus.ERROR || - autofixData.status === AutofixStatus.CANCELLED - ) { - return {overallProgress: 0}; - } - - const processingSteps = steps.filter(step => step.status === AutofixStatus.PROCESSING); - const lastProcessingStep = processingSteps[processingSteps.length - 1]; - - if (!lastProcessingStep) { - return {overallProgress: 0}; - } - - const progressCount = lastProcessingStep.progress?.length || 0; - // Increment by 8% per progress log, max 97% - const progress = Math.min(progressCount * 8, 97); - - return { - overallProgress: progress, - }; -} - export function getAutofixRunExists(group: Group) { const autofixLastRunAsDate = group.seerAutofixLastTriggered ? new Date(group.seerAutofixLastTriggered) @@ -308,44 +85,3 @@ export function getAutofixRunExists(group: Group) { export function isIssueQuickFixable(group: Group) { return group.seerFixabilityScore && group.seerFixabilityScore > 0.7; } - -export function getAutofixRunErrorMessage(autofixData: AutofixData | undefined) { - if (autofixData?.status !== AutofixStatus.ERROR) { - return null; - } - - const errorStep = autofixData.steps?.find(step => step.status === AutofixStatus.ERROR); - const errorMessage = errorStep?.completedMessage || t('Something went wrong.'); - - let customErrorMessage = ''; - if ( - errorMessage.toLowerCase().includes('overloaded') || - errorMessage.toLowerCase().includes('no completion tokens') || - errorMessage.toLowerCase().includes('exhausted') - ) { - customErrorMessage = t( - 'The robots are having a moment. Our LLM provider is overloaded - please try again soon.' - ); - } else if ( - errorMessage.toLowerCase().includes('prompt') || - errorMessage.toLowerCase().includes('tokens') - ) { - customErrorMessage = t( - "Seer worked so hard that it couldn't fit all its findings in its own memory. Please try again." - ); - } else if (errorMessage.toLowerCase().includes('iterations')) { - customErrorMessage = t( - 'Seer was taking a ton of iterations, so we pulled the plug out of fear it might go rogue. Please try again.' - ); - } else if (errorMessage.toLowerCase().includes('timeout')) { - customErrorMessage = t( - 'Seer was taking way too long, so we pulled the plug to turn it off and on again. Please try again.' - ); - } else { - customErrorMessage = t( - "Oops, Seer went kaput. We've dispatched Seer to fix Seer. In the meantime, try again?" - ); - } - - return customErrorMessage; -} diff --git a/static/app/components/events/autofix/utils/insightUtils.tsx b/static/app/components/events/autofix/utils/insightUtils.tsx deleted file mode 100644 index ad19ae1b6d28e0..00000000000000 --- a/static/app/components/events/autofix/utils/insightUtils.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import type { - AutofixInsight, - InsightSources, -} from 'sentry/components/events/autofix/types'; - -type ParsedCodeUrl = { - baseUrl: string; - endLine: number | null; - startLine: number | null; -}; - -/** - * Parses a code URL to extract the base URL and line range information. - * Supports GitHub-style URLs with #L10 or #L10-L20 format. - */ -function parseCodeUrl(url: string): ParsedCodeUrl { - try { - const urlObj = new URL(url); - const hash = urlObj.hash; - - // Remove hash from base URL - const baseUrl = url.replace(hash || '', ''); - - if (hash) { - // Check for GitHub-style line numbers in hash (#L10 or #L10-L20) - const lineMatch = hash.match(/^#L(\d+)(?:-L(\d+))?$/); - if (lineMatch) { - const startLine = parseInt(lineMatch[1]!, 10); - const endLine = lineMatch[2] ? parseInt(lineMatch[2], 10) : startLine; - return {baseUrl, endLine, startLine}; - } - } - - // Check query parameters for line numbers (L, line, etc.) - const searchParams = new URLSearchParams(urlObj.search); - if (searchParams.has('L') || searchParams.has('line')) { - const lineParam = searchParams.get('L') || searchParams.get('line'); - if (lineParam !== null) { - const lineNum = parseInt(lineParam, 10); - if (!isNaN(lineNum)) { - return {baseUrl: baseUrl.split('?')[0]!, endLine: lineNum, startLine: lineNum}; - } - } - } - - return {baseUrl, endLine: null, startLine: null}; - } catch (e) { - return {baseUrl: url, endLine: null, startLine: null}; - } -} - -/** - * Checks if range1 contains range2 (i.e., range1 is wider or equal). - */ -function rangeContains( - range1: {endLine: number; startLine: number}, - range2: {endLine: number; startLine: number} -): boolean { - return range1.startLine <= range2.startLine && range1.endLine >= range2.endLine; -} - -/** - * Creates a merged URL with the wider line range. - */ -function createMergedUrl(baseUrl: string, startLine: number, endLine: number): string { - if (startLine === endLine) { - return `${baseUrl}#L${startLine}`; - } - return `${baseUrl}#L${startLine}-L${endLine}`; -} - -/** - * Merges code URLs with overlapping or containing line ranges. - * Returns a map of original URLs to their merged URLs. - */ -function mergeCodeUrls(urls: string[]): Map { - const urlMapping = new Map(); - const parsedUrls = urls.map(url => ({parsed: parseCodeUrl(url), url})); - - // Group URLs by base URL - const baseUrlGroups = new Map>(); - parsedUrls.forEach(({parsed, url}) => { - if (!baseUrlGroups.has(parsed.baseUrl)) { - baseUrlGroups.set(parsed.baseUrl, []); - } - baseUrlGroups.get(parsed.baseUrl)!.push({parsed, url}); - }); - - // Process each group to find containing ranges - baseUrlGroups.forEach(group => { - // Separate URLs with and without line ranges - const withRanges = group.filter( - item => item.parsed.startLine !== null && item.parsed.endLine !== null - ); - const withoutRanges = group.filter( - item => item.parsed.startLine === null || item.parsed.endLine === null - ); - - // For URLs without ranges, map them to themselves - withoutRanges.forEach(item => { - urlMapping.set(item.url, item.url); - }); - - if (withRanges.length === 0) return; - - // Sort by range size (largest first) to prioritize wider ranges - withRanges.sort((a, b) => { - const sizeA = a.parsed.endLine! - a.parsed.startLine!; - const sizeB = b.parsed.endLine! - b.parsed.startLine!; - return sizeB - sizeA; - }); - - const processed = new Set(); - - withRanges.forEach(item => { - if (processed.has(item.url)) return; - - const currentRange = { - startLine: item.parsed.startLine!, - endLine: item.parsed.endLine!, - }; - - // Find all URLs that this range contains - const contained = withRanges.filter( - other => - !processed.has(other.url) && - other.url !== item.url && - rangeContains(currentRange, { - startLine: other.parsed.startLine!, - endLine: other.parsed.endLine!, - }) - ); - - // Map the main URL and all contained URLs to the wider range - const mergedUrl = createMergedUrl( - item.parsed.baseUrl, - currentRange.startLine, - currentRange.endLine - ); - - urlMapping.set(item.url, mergedUrl); - processed.add(item.url); - - contained.forEach(containedItem => { - urlMapping.set(containedItem.url, mergedUrl); - processed.add(containedItem.url); - }); - }); - }); - - return urlMapping; -} - -/** - * Deduplicates sources across multiple insights, combining boolean flags - * and removing duplicate URLs and IDs. Also merges code URLs with overlapping ranges. - */ -function deduplicateSources(insights: AutofixInsight[]): InsightSources { - const allSources: InsightSources = { - breadcrumbs_used: false, - code_used_urls: [], - connected_error_ids_used: [], - diff_urls: [], - http_request_used: false, - profile_ids_used: [], - stacktrace_used: false, - thoughts: '', - trace_event_ids_used: [], - event_trace_id: undefined, - event_trace_timestamp: undefined, - }; - - const seenIds = new Set(); - const allCodeUrls: string[] = []; - const allDiffUrls: string[] = []; - - insights.forEach(insight => { - if (!insight?.sources) return; - - const sources = insight.sources; - - // Boolean flags - OR them together - allSources.breadcrumbs_used = allSources.breadcrumbs_used || sources.breadcrumbs_used; - allSources.http_request_used = - allSources.http_request_used || sources.http_request_used; - allSources.stacktrace_used = allSources.stacktrace_used || sources.stacktrace_used; - - // Use the first available event_trace_id - if (!allSources.event_trace_id && sources.event_trace_id) { - allSources.event_trace_id = sources.event_trace_id; - } - - // Use the first available event_trace_timestamp - if (!allSources.event_trace_timestamp && sources.event_trace_timestamp) { - allSources.event_trace_timestamp = sources.event_trace_timestamp; - } - - // Collect all URLs for merging - sources.code_used_urls?.forEach(url => { - allCodeUrls.push(url); - }); - - sources.diff_urls?.forEach(url => { - allDiffUrls.push(url); - }); - - // Deduplicate IDs - sources.trace_event_ids_used?.forEach(id => { - if (!seenIds.has(id)) { - seenIds.add(id); - allSources.trace_event_ids_used.push(id); - } - }); - - sources.profile_ids_used?.forEach(id => { - if (!seenIds.has(id)) { - seenIds.add(id); - allSources.profile_ids_used.push(id); - } - }); - - sources.connected_error_ids_used?.forEach(id => { - if (!seenIds.has(id)) { - seenIds.add(id); - allSources.connected_error_ids_used.push(id); - } - }); - }); - - // Merge code URLs with overlapping ranges and deduplicate - const codeUrlMapping = mergeCodeUrls(allCodeUrls); - const mergedCodeUrls = new Set(); - codeUrlMapping.forEach(mergedUrl => { - mergedCodeUrls.add(mergedUrl); - }); - allSources.code_used_urls = Array.from(mergedCodeUrls); - - // Merge diff URLs with overlapping ranges and deduplicate - const diffUrlMapping = mergeCodeUrls(allDiffUrls); - const mergedDiffUrls = new Set(); - diffUrlMapping.forEach(mergedUrl => { - mergedDiffUrls.add(mergedUrl); - }); - allSources.diff_urls = Array.from(mergedDiffUrls); - - return allSources; -} - -/** - * Updates insights to use merged URLs from deduplication. - * Returns a new array of insights with updated sources. - */ -function updateInsightsWithMergedUrls(insights: AutofixInsight[]): AutofixInsight[] { - // Collect all URLs first - const allCodeUrls: string[] = []; - const allDiffUrls: string[] = []; - - insights.forEach(insight => { - if (!insight?.sources) return; - const sources = insight.sources; - - sources.code_used_urls?.forEach(url => allCodeUrls.push(url)); - sources.diff_urls?.forEach(url => allDiffUrls.push(url)); - }); - - // Create mappings for merged URLs - const codeUrlMapping = mergeCodeUrls(allCodeUrls); - const diffUrlMapping = mergeCodeUrls(allDiffUrls); - - // Update each insight with merged URLs - return insights.map(insight => { - if (!insight?.sources) return insight; - - const updatedSources: InsightSources = { - ...insight.sources, - code_used_urls: - insight.sources.code_used_urls?.map(url => codeUrlMapping.get(url) || url) || [], - diff_urls: - insight.sources.diff_urls?.map(url => diffUrlMapping.get(url) || url) || [], - }; - - return { - ...insight, - sources: updatedSources, - }; - }); -} - -/** - * Deduplicates sources and updates insights with merged URLs. - * Returns both the deduplicated sources and updated insights. - */ -export function deduplicateSourcesAndUpdateInsights(insights: AutofixInsight[]): { - deduplicatedSources: InsightSources; - updatedInsights: AutofixInsight[]; -} { - const updatedInsights = updateInsightsWithMergedUrls(insights); - const deduplicatedSources = deduplicateSources(updatedInsights); - - return {deduplicatedSources, updatedInsights}; -} - -/** - * Gets the sources for the currently expanded insight card. - */ -export function getExpandedInsightSources( - insights: AutofixInsight[], - expandedCardIndex: number | null -): InsightSources | undefined { - if (expandedCardIndex === null || expandedCardIndex >= insights.length) { - return undefined; - } - return insights[expandedCardIndex]?.sources; -} diff --git a/static/app/components/events/autofix/v1/body.tsx b/static/app/components/events/autofix/v1/body.tsx deleted file mode 100644 index d2b6a1e328d69b..00000000000000 --- a/static/app/components/events/autofix/v1/body.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import {useCallback, useEffect, useRef, type ReactNode} from 'react'; -import styled from '@emotion/styled'; -import {mergeRefs} from '@react-aria/utils'; - -import {DrawerBody} from '@sentry/scraps/drawer'; - -import {AutofixStepType} from 'sentry/components/events/autofix/types'; -import {type useAiAutofix} from 'sentry/components/events/autofix/useAutofix'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; - -interface SeerDrawerBody { - aiAutofix: ReturnType; - children: ReactNode; -} - -export function SeerDrawerBody({children, aiAutofix}: SeerDrawerBody) { - const {scrollContainerRef: scrollToSectionRef} = useScrollToSection({aiAutofix}); - - const {handleScroll, scrollContainerRef: autoScrollRef} = useAutoScroll({aiAutofix}); - - const scrollContainerRef = mergeRefs(scrollToSectionRef, autoScrollRef); - - return ( - - {children} - - ); -} - -function useScrollToSection({aiAutofix}: {aiAutofix: ReturnType}) { - const location = useLocation(); - const navigate = useNavigate(); - - const scrollContainerRef = useRef(null); - - const autofixDataRef = useRef(aiAutofix.autofixData); - autofixDataRef.current = aiAutofix.autofixData; - - const scrollToSection = useCallback( - (sectionType: string | null) => { - if (!scrollContainerRef.current || !autofixDataRef.current) { - return; - } - - const step = autofixDataRef.current.steps?.find(s => { - if (sectionType === 'root_cause') - return s.type === AutofixStepType.ROOT_CAUSE_ANALYSIS; - if (sectionType === 'solution') return s.type === AutofixStepType.SOLUTION; - if (sectionType === 'code_changes') return s.type === AutofixStepType.CHANGES; - return false; - }); - - let element = null; - - if (step) { - const elementId = `autofix-step-${step.id}`; - element = document.getElementById(elementId); - } - - if (element) { - element.scrollIntoView({behavior: 'smooth'}); - } else { - // No matching step found, scroll to bottom - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; - } - - // Clear the scrollTo parameter from the URL after scrolling - setTimeout(() => { - navigate( - { - pathname: location.pathname, - query: { - ...location.query, - scrollTo: undefined, - }, - }, - {replace: true} - ); - }, 200); - }, - [location, navigate] - ); - - useEffect(() => { - const scrollTo = location.query.scrollTo as string | undefined; - if (scrollTo) { - // use a 100ms timeout to allow the page to render first - const timeoutId = setTimeout(() => { - scrollToSection(scrollTo); - }, 100); - return () => clearTimeout(timeoutId); - } - return () => {}; - }, [location.query.scrollTo, scrollToSection]); - - return {scrollContainerRef}; -} - -function useAutoScroll({aiAutofix}: {aiAutofix: ReturnType}) { - const scrollContainerRef = useRef(null); - - const lastScrollTopRef = useRef(0); - const shouldAutoScrollRef = useRef(false); - - const handleScroll = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) { - return; - } - - // Detect scroll direction - const scrollingUp = container.scrollTop < lastScrollTopRef.current; - - // update the last scroll position, make sure to do so after using the last value - lastScrollTopRef.current = container.scrollTop; - - // Check if we're at the bottom - const isAtBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 1; - - // Disable auto-scroll if scrolling up - if (scrollingUp) { - shouldAutoScrollRef.current = false; - } - - // Re-enable auto-scroll if we reach the bottom - if (isAtBottom) { - shouldAutoScrollRef.current = true; - } - }, []); - - useEffect(() => { - // Only auto-scroll if user hasn't manually scrolled - if (shouldAutoScrollRef.current && scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; - } - }, [aiAutofix.autofixData]); - - return { - handleScroll, - scrollContainerRef, - }; -} - -const StyledDrawerBody = styled(DrawerBody)` - overflow-y: scroll; -`; diff --git a/static/app/components/events/autofix/v1/content.tsx b/static/app/components/events/autofix/v1/content.tsx deleted file mode 100644 index f1d0dba9872383..00000000000000 --- a/static/app/components/events/autofix/v1/content.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import {Fragment} from 'react'; - -import {Container, Flex} from '@sentry/scraps/layout'; - -import {AutofixStartBox} from 'sentry/components/events/autofix/autofixStartBox'; -import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps'; -import type {useAiAutofix} from 'sentry/components/events/autofix/useAutofix'; -import {GroupSummary} from 'sentry/components/group/groupSummary'; -import {Placeholder} from 'sentry/components/placeholder'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import type {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; -import {SeerNotices} from 'sentry/views/issueDetails/streamline/sidebar/seerNotices'; - -interface SeerDrawerContentProps { - aiAutofix: ReturnType; - aiConfig: ReturnType; - event: Event; - group: Group; - project: Project; -} - -export function SeerDrawerContent({ - aiAutofix, - aiConfig, - event, - group, - project, -}: SeerDrawerContentProps) { - useRouteAnalyticsParams({autofix_status: aiAutofix.autofixData?.status ?? 'none'}); - - return ( - - - {aiConfig.hasSummary && ( - - - - )} - {aiConfig.hasAutofix && ( - - {aiAutofix.isPending ? ( - - - - - ) : aiAutofix.autofixData ? ( - - ) : ( - - )} - - )} - - ); -} diff --git a/static/app/components/events/autofix/v1/drawer.tsx b/static/app/components/events/autofix/v1/drawer.tsx deleted file mode 100644 index 66311a56b6aaa4..00000000000000 --- a/static/app/components/events/autofix/v1/drawer.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import {useMemo} from 'react'; -import {useQuery} from '@tanstack/react-query'; - -import {Flex} from '@sentry/scraps/layout'; - -import {SeerDrawerHeader} from 'sentry/components/events/autofix/drawer/drawerHeader'; -import {SeerDrawerNavigator} from 'sentry/components/events/autofix/drawer/drawerNavigator'; -import {SeerWelcomeScreen} from 'sentry/components/events/autofix/drawer/welcomeScreen'; -import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix'; -import {SeerDrawerBody} from 'sentry/components/events/autofix/v1/body'; -import {SeerDrawerContent} from 'sentry/components/events/autofix/v1/content'; -import {AiSetupConfiguration} from 'sentry/components/events/autofix/v2/autofixConfigureSeer'; -import {Placeholder} from 'sentry/components/placeholder'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {getSeerOnboardingCheckQueryOptions} from 'sentry/utils/getSeerOnboardingCheckQueryOptions'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; - -interface SeerDrawerProps { - event: Event; - group: Group; - project: Project; -} - -export function SeerDrawer({event, group, project}: SeerDrawerProps) { - const aiConfig = useAiConfig(group, project); - const aiAutofix = useAiAutofix(group, event); - - const handleReset = useHandleReset({aiAutofix, aiConfig}); - - return ( - - - - - - - - ); -} - -interface InnerSeerDrawerProps extends SeerDrawerProps { - aiAutofix: ReturnType; - aiConfig: ReturnType; -} - -function InnerSeerDrawer({ - event, - group, - project, - aiAutofix, - aiConfig, -}: InnerSeerDrawerProps) { - const organization = useOrganization(); - const {isPending, data} = useQuery(getSeerOnboardingCheckQueryOptions({organization})); - - const seatBasedSeer = organization.features.includes('seat-based-seer-enabled'); - - const noAutofixQuota = - !aiConfig.hasAutofixQuota && organization.features.includes('seer-billing'); - - if (aiConfig.isAutofixSetupLoading || (seatBasedSeer && isPending)) { - return ( - - - - - - ); - } - - if (seatBasedSeer) { - // No easy way to add a hook for only configuring quotas. - // So the condition here captures all the possible cases - // that requires some kind of configuration change. - // - // Instead, we bundle all the configuration into 1 hook. - // - // If the hook is not defined, we always direct them to - // the seer configs. - // - // If the hook is defined, the hook will render a different - // component as needed to configure quotas. - if ( - // needs to configure quota - noAutofixQuota || - // needs to configure repos - !aiConfig.seerReposLinked || - !data?.hasSupportedScmIntegration - ) { - return ; - } - } else if ( - // Handle welcome/consent screen at the top level - aiConfig.orgNeedsGenAiAcknowledgement || - noAutofixQuota - ) { - return ; - } - - return ( - - ); -} - -function useHandleReset({ - aiAutofix, - aiConfig, -}: { - aiAutofix: ReturnType; - aiConfig: ReturnType; -}) { - return useMemo(() => { - if (!aiAutofix.autofixData) { - return; - } - return () => { - aiAutofix.reset(); - aiConfig.refetchAutofixSetup?.(); - }; - }, [aiAutofix, aiConfig]); -} diff --git a/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx b/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx deleted file mode 100644 index 2d6fe16241d9f6..00000000000000 --- a/static/app/components/events/autofix/v2/autofixConfigureSeer.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import {Fragment, type CSSProperties} from 'react'; -import styled from '@emotion/styled'; -import {useQuery} from '@tanstack/react-query'; - -import seerConfigSeerImg from 'sentry-images/spot/seer-config-seer.svg'; -import seerConfigShipImg from 'sentry-images/spot/seer-config-ship.svg'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Image} from '@sentry/scraps/image'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {Heading, Text} from '@sentry/scraps/text'; - -import {useGroupSummary} from 'sentry/components/group/groupSummary'; -import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; -import {Panel} from 'sentry/components/panels/panel'; -import {Placeholder} from 'sentry/components/placeholder'; -import {IconSeer} from 'sentry/icons/iconSeer'; -import {IconWarning} from 'sentry/icons/iconWarning'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {getSeerOnboardingCheckQueryOptions} from 'sentry/utils/getSeerOnboardingCheckQueryOptions'; -import {MarkedText} from 'sentry/utils/marked/markedText'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -export const AiSetupConfiguration = OverrideOrDefault({ - overrideName: 'component:ai-setup-configuration', - defaultComponent: ({ - event, - group, - project, - }: { - event: Event; - group: Group; - project: Project; - }) => , -}); - -interface AutofixConfigureSeerProps { - event: Event; - group: Group; - project: Project; -} - -export function AutofixConfigureSeer({event, group, project}: AutofixConfigureSeerProps) { - const organization = useOrganization(); - const {data: setupCheck} = useQuery(getSeerOnboardingCheckQueryOptions({organization})); - const {data, isPending, isError} = useGroupSummary(group, event, project); - - const orgNeedsToConfigureSeer = - // needs to enable autofix - !setupCheck?.isAutofixEnabled || - // catch all, ensure seer is configured - !setupCheck?.isSeerConfigured; - - return ( - - - - - - - - - {t('Debug Faster with Seer')} - - - {t( - 'Seer connects to your repos, scans your issues, highlights quick fixes, and proposes solutions.' - )} - - - {t( - 'You can even integrate with your favorite agent to implement changes in code.' - )} - - - - - - - - - - - {t('What happened')} - {isPending ? ( - - ) : isError ? ( - - - {t('Error loading what happened')} - - ) : ( - data?.whatsWrong && ( - - - - ) - )} - - - - - {t('Initial Guess')} - {isPending ? ( - - ) : isError ? ( - - - {t('Error loading initial guess')} - - ) : ( - data?.possibleCause && ( - - - - ) - )} - - - - - {t('Next Steps')} - - {t( - 'This is the initial analysis, but once Seer is configured you’ll be able to see a detailed breakdown of the issue’s root cause, a multi-step solution, and proposed code changes to fix it.' - )} - - - {orgNeedsToConfigureSeer ? ( - } - > - {t('Set Up Seer')} - - ) : ( - } - > - {t('Set Up Seer for This Project')} - - )} - - - - - - ); -} - -export const SeerFeaturesPanel = styled(Panel)<{width: CSSProperties['width']}>` - width: ${p => p.width}; - min-width: ${p => p.width}; - max-width: ${p => p.width}; - margin-bottom: ${p => p.theme.space['2xl']}; -`; - -const SeerPreviewPanel = styled(Panel)<{alignSelf: CSSProperties['alignSelf']}>` - align-self: ${p => p.alignSelf}; - width: 70%; - min-width: 70%; - max-width: 70%; - margin-bottom: ${p => p.theme.space['2xl']}; -`; - -const AngledImageContainer = styled('div')` - position: absolute; - right: 0px; - width: 40%; - max-width: 400px; - aspect-ratio: 25/12; - /* - * Use the top middle point as the origin to - * 1. Translate the image so the center aligns with the right edge of the containing panel. - * 2. Totate 45 deg clockwise so the image is rotated anchored at the tip of the pyramid. - */ - transform-origin: 50% 0%; - transform: translateX(50%) rotate(45deg); -`; - -const SeerPreviewText = styled('div')` - p { - margin-bottom: 0; - } -`; - -export const ImageContainer = styled(Flex)<{ - aspectRatio?: CSSProperties['aspectRatio']; -}>` - ${p => p.aspectRatio && `aspect-ratio: ${p.aspectRatio}`}; -`; diff --git a/static/app/components/events/autofix/v3/drawer.tsx b/static/app/components/events/autofix/v3/drawer.tsx index 72facd29e2323e..b3952c1671f96c 100644 --- a/static/app/components/events/autofix/v3/drawer.tsx +++ b/static/app/components/events/autofix/v3/drawer.tsx @@ -96,7 +96,7 @@ function useHandleCopyMarkdown({ const markdown = getOrderedAutofixSections(aiAutofix.runState) .map(getAutofixArtifactFromSection) .filter(defined) - .map(artifactToMarkdown) + .map(artifact => artifactToMarkdown(artifact)) .filter(defined) .join('\n\n'); copy(markdown, {successMessage: t('Analysis copied to clipboard.')}); diff --git a/static/app/components/events/autofix/v3/utils.ts b/static/app/components/events/autofix/v3/utils.ts index 1895bc95562479..d1256664d5071a 100644 --- a/static/app/components/events/autofix/v3/utils.ts +++ b/static/app/components/events/autofix/v3/utils.ts @@ -17,44 +17,51 @@ import { type RepoPRState, } from 'sentry/views/seerExplorer/types'; -export function artifactToMarkdown(artifact: AutofixArtifact): string | null { +export function artifactToMarkdown( + artifact: AutofixArtifact, + headingLevel: 1 | 2 | 3 = 1 +): string | null { if (isRootCauseArtifact(artifact)) { - return rootCauseArtifactToMarkdown(artifact); + return rootCauseArtifactToMarkdown(artifact, headingLevel); } if (isSolutionArtifact(artifact)) { - return solutionArtifactToMarkdown(artifact); + return solutionArtifactToMarkdown(artifact, headingLevel); } if (isCodeChangesArtifact(artifact)) { - return filePatchesToMarkdown(artifact); + return filePatchesToMarkdown(artifact, headingLevel); } if (isPullRequestsArtifact(artifact)) { - return repoPRStatesToMarkdown(artifact); + return repoPRStatesToMarkdown(artifact, headingLevel); } if (isCodingAgentsArtifact(artifact)) { - return codingAgentsToMarkdown(artifact); + return codingAgentsToMarkdown(artifact, headingLevel); } return null; // unknown artifact } function rootCauseArtifactToMarkdown( - artifact: Artifact + artifact: Artifact, + headingLevel: number ): string | null { const rootCause = artifact.data; if (!defined(rootCause)) { return null; } - const parts: string[] = ['# Root Cause', '', rootCause.one_line_description]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Root Cause`, '', rootCause.one_line_description]; if (rootCause.five_whys.length) { parts.push( '', - '## Why did this happen?', + `${h2} Why did this happen?`, '', ...rootCause.five_whys.map(why => `- ${why}`) ); @@ -63,7 +70,7 @@ function rootCauseArtifactToMarkdown( if (rootCause.reproduction_steps?.length) { parts.push( '', - '## Reproduction Steps', + `${h2} Reproduction Steps`, '', ...rootCause.reproduction_steps.map((step, index) => `${index + 1}. ${step}`) ); @@ -72,21 +79,28 @@ function rootCauseArtifactToMarkdown( return parts.join('\n'); } -function solutionArtifactToMarkdown(artifact: Artifact): string | null { +function solutionArtifactToMarkdown( + artifact: Artifact, + headingLevel: number +): string | null { const solution = artifact.data; if (!defined(solution)) { return null; } - const parts: string[] = ['# Plan', '', solution.one_line_summary]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + const h3 = '#'.repeat(headingLevel + 2); + + const parts: string[] = [`${h1} Plan`, '', solution.one_line_summary]; if (solution.steps.length) { parts.push( '', - '## Steps to Resolve', + `${h2} Steps to Resolve`, '', ...solution.steps.flatMap((step, index) => [ - `### ${index + 1}. ${step.title}`, + `${h3} ${index + 1}. ${step.title}`, step.description, ]) ); @@ -95,17 +109,23 @@ function solutionArtifactToMarkdown(artifact: Artifact): strin return parts.join('\n'); } -function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { +function filePatchesToMarkdown( + artifact: ExplorerFilePatch[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Code Changes']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Code Changes`]; parts.push( ...artifact.flatMap(filePatch => [ '', - `## Repository: ${filePatch.repo_name}`, + `${h2} Repository: ${filePatch.repo_name}`, '', '```diff', filePatch.diff, @@ -116,12 +136,17 @@ function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { return parts.join('\n'); } -function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { +function repoPRStatesToMarkdown( + artifact: RepoPRState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Pull Requests', '']; + const h1 = '#'.repeat(headingLevel); + + const parts: string[] = [`${h1} Pull Requests`, '']; parts.push( ...artifact @@ -137,12 +162,18 @@ function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { return parts.join('\n'); } -function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | null { +function codingAgentsToMarkdown( + artifact: ExplorerCodingAgentState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Coding Agents', '']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Coding Agents`, '']; parts.push( ...artifact @@ -152,7 +183,7 @@ function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | } return [ - `## ${getCodingAgentName(codingAgent.provider)}`, + `${h2} ${getCodingAgentName(codingAgent.provider)}`, '', `[${codingAgent.name}](${codingAgent.agent_url})`, ]; diff --git a/static/app/components/group/groupSummary.spec.tsx b/static/app/components/group/groupSummary.spec.tsx deleted file mode 100644 index 9ea0626d318d19..00000000000000 --- a/static/app/components/group/groupSummary.spec.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; -import {EventFixture} from 'sentry-fixture/event'; -import {GroupFixture} from 'sentry-fixture/group'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {DetailedProjectFixture} from 'sentry-fixture/project'; - -import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {GroupSummary} from 'sentry/components/group/groupSummary'; - -describe('GroupSummary', () => { - const mockEvent = EventFixture(); - const mockGroup = GroupFixture(); - const mockProject = DetailedProjectFixture(); - const organization = OrganizationFixture({ - hideAiFeatures: false, - features: ['gen-ai-features'], - }); - - const mockSummaryData = { - groupId: '1', - whatsWrong: 'Test whats wrong', - trace: 'Test trace', - possibleCause: 'Test possible cause', - headline: 'Test headline', - scores: { - possibleCauseConfidence: 0.9, - possibleCauseNovelty: 0.8, - }, - }; - - const mockSummaryDataWithNullScores = { - groupId: '1', - whatsWrong: 'Test whats wrong', - trace: 'Test trace', - possibleCause: 'Test possible cause', - headline: 'Test headline', - scores: null, - }; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - method: 'GET', - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: { - ok: true, - repos: [ - { - ok: true, - owner: 'owner', - name: 'hello-world', - provider: 'integrations:github', - }, - ], - }, - }), - }); - }); - - it('renders the summary with all sections', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: mockSummaryData, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByText('What Happened')).toBeInTheDocument(); - }); - expect(await screen.findByText('Test whats wrong')).toBeInTheDocument(); - expect(screen.getByText('In the Trace')).toBeInTheDocument(); - expect(screen.getByText('Test trace')).toBeInTheDocument(); - expect(screen.getByText('Initial Guess')).toBeInTheDocument(); - expect(screen.getByText('Test possible cause')).toBeInTheDocument(); - }); - - it('renders the summary with all sections when scores are null', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: mockSummaryDataWithNullScores, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByText('What Happened')).toBeInTheDocument(); - }); - expect(await screen.findByText('Test whats wrong')).toBeInTheDocument(); - expect(screen.getByText('In the Trace')).toBeInTheDocument(); - expect(screen.getByText('Test trace')).toBeInTheDocument(); - expect(screen.getByText('Initial Guess')).toBeInTheDocument(); - expect(screen.getByText('Test possible cause')).toBeInTheDocument(); - }); - - it('shows loading state', () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {}, - }); - - render(, { - organization, - }); - - // Should show loading placeholders. Currently we load the headline, whatsWrong, and possibleCause sections - expect(screen.getAllByTestId('loading-placeholder')).toHaveLength(3); - }); - - it('shows error state', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {}, - statusCode: 400, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByText('Error loading summary')).toBeInTheDocument(); - }); - }); - - it('hides cards with no content', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: { - ...mockSummaryData, - trace: null, - }, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByText('What Happened')).toBeInTheDocument(); - }); - expect(await screen.findByText('Test whats wrong')).toBeInTheDocument(); - expect(screen.queryByText('In the Trace')).not.toBeInTheDocument(); - expect(screen.getByText('Initial Guess')).toBeInTheDocument(); - expect(screen.getByText('Test possible cause')).toBeInTheDocument(); - }); - - it('renders in preview mode', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: mockSummaryData, - }); - - render( - , - {organization} - ); - - await waitFor(() => { - expect(screen.getByText('Initial Guess')).toBeInTheDocument(); - }); - expect(await screen.findByText('Test possible cause')).toBeInTheDocument(); - expect(screen.queryByText('What Happened')).not.toBeInTheDocument(); - expect(screen.queryByText('Test whats wrong')).not.toBeInTheDocument(); - expect(screen.queryByText('In the Trace')).not.toBeInTheDocument(); - expect(screen.queryByText('Test trace')).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx index 4b805ce199fb35..bc56de0c7665d4 100644 --- a/static/app/components/group/groupSummary.tsx +++ b/static/app/components/group/groupSummary.tsx @@ -1,34 +1,7 @@ -import {isValidElement, useEffect, useLayoutEffect, useState} from 'react'; -import styled from '@emotion/styled'; -import {useQueryClient} from '@tanstack/react-query'; -import {motion} from 'framer-motion'; - -import {Button} from '@sentry/scraps/button'; -import {Container, Flex, Stack} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import {AiPrivacyTooltip} from 'sentry/components/aiPrivacyTooltip'; -import {autofixApiOptions} from 'sentry/components/events/autofix/useAutofix'; -import {Placeholder} from 'sentry/components/placeholder'; -import { - IconChevron, - IconDocs, - IconFatal, - IconFocus, - IconRefresh, - IconSpan, -} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; -import {MarkedText} from 'sentry/utils/marked/markedText'; import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; -import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; export interface GroupSummaryData { groupId: string; @@ -77,447 +50,3 @@ export function useGroupSummaryData(group: Group) { return {data, isPending}; } - -export function useGroupSummary( - group: Group, - event: Event | null | undefined, - project: Project, - forceEvent = false -) { - const organization = useOrganization(); - const aiConfig = useAiConfig(group, project); - const enabled = aiConfig.hasSummary; - const queryClient = useQueryClient(); - const queryKey = makeGroupSummaryQueryKey( - organization.slug, - group.id, - forceEvent ? event?.id : undefined - ); - - const {data, isLoading, isFetching, isError, refetch} = useApiQuery( - queryKey, - { - staleTime: Infinity, - enabled, - } - ); - - const refresh = () => { - queryClient.invalidateQueries({ - queryKey: makeGroupSummaryQueryKey(organization.slug, group.id), - exact: false, - }); - refetch(); - }; - - return { - data, - isPending: aiConfig.isAutofixSetupLoading || isLoading || isFetching, - isError, - refresh, - }; -} - -export function GroupSummary({ - group, - event, - project, - preview = false, - collapsed = false, -}: { - event: Event | null | undefined; - group: Group; - project: Project; - collapsed?: boolean; - preview?: boolean; -}) { - const queryClient = useQueryClient(); - const organization = useOrganization(); - const [forceEvent, setForceEvent] = useState(false); - const aiConfig = useAiConfig(group, project); - const {data, isPending, isError, refresh} = useGroupSummary( - group, - event, - project, - forceEvent - ); - - useEffect(() => { - if (forceEvent && !isPending) { - refresh(); - setForceEvent(false); - } - }, [forceEvent, isPending, refresh]); - - const hasFixabilityScore = - data?.scores?.fixabilityScore !== null && data?.scores?.fixabilityScore !== undefined; - - useEffect(() => { - if (hasFixabilityScore && !isPending && aiConfig.hasAutofix) { - queryClient.invalidateQueries({ - queryKey: autofixApiOptions(organization.slug, group.id).queryKey, - }); - } - }, [ - hasFixabilityScore, - isPending, - aiConfig.hasAutofix, - group.id, - queryClient, - organization.slug, - ]); - - useRouteAnalyticsParams({ - has_summary: Boolean(data && !isPending && !isError), - }); - - if (preview) { - return ; - } - - return ( - - ); -} - -function GroupSummaryPreview({ - data, - isPending, - isError, -}: { - data: GroupSummaryData | undefined; - isError: boolean; - isPending: boolean; -}) { - const insightCards = [ - { - id: 'possible_cause', - title: t('Initial Guess'), - insight: data?.possibleCause, - icon: , - showWhenLoading: true, - }, - ]; - - return ( -
- {isError ?
{t('Error loading summary')}
: null} - - - {insightCards.map(card => { - if ((!isPending && !card.insight) || (isPending && !card.showWhenLoading)) { - return null; - } - return ( - - - {card.icon} - - {card.title} - - - - - - - {isPending ? ( - - - - ) : ( - - {card.insight && ( - - )} - - )} - - - ); - })} - - -
- ); -} - -function GroupSummaryCollapsed({ - group, - project, - event, - data, - isPending, - setForceEvent, - isError, - defaultCollapsed = false, -}: { - data: GroupSummaryData | undefined; - event: Event | null | undefined; - group: Group; - isError: boolean; - isPending: boolean; - project: Project; - setForceEvent: (v: boolean) => void; - defaultCollapsed?: boolean; -}) { - const [isExpanded, setIsExpanded] = useState(!defaultCollapsed); - - const handleToggle = () => { - setIsExpanded(!isExpanded); - }; - - useLayoutEffect(() => { - setIsExpanded(!defaultCollapsed); - }, [defaultCollapsed]); - - return ( -
- {isError ?
{t('Error loading summary')}
: null} - {!isError && ( - - - - {isPending ? ( - - ) : ( - - {data?.headline || t('Issue Summary')} - - )} - - {isExpanded ? ( - - ) : ( - - )} - - - - - - - - - - - )} -
- ); -} - -function GroupSummaryFull({ - group, - project, - data, - isPending, - isError, - setForceEvent, - preview, - event, -}: { - data: GroupSummaryData | undefined; - event: Event | null | undefined; - group: Group; - isError: boolean; - isPending: boolean; - preview: boolean; - project: Project; - setForceEvent: (v: boolean) => void; -}) { - const config = getConfigForIssueType(group, project); - const shouldShowResources = config.resources && !preview; - - const insightCards = [ - { - id: 'whats_wrong', - title: t('What Happened'), - insight: data?.whatsWrong, - icon: , - showWhenLoading: true, - }, - { - id: 'trace', - title: t('In the Trace'), - insight: data?.trace, - icon: , - showWhenLoading: false, - }, - { - id: 'possible_cause', - title: t('Initial Guess'), - insight: data?.possibleCause, - icon: , - showWhenLoading: true, - }, - - ...(shouldShowResources - ? [ - { - id: 'resources', - title: t('Resources'), - // eslint-disable-next-line @typescript-eslint/no-base-to-string - insight: `${isValidElement(config.resources?.description) ? '' : (config.resources?.description ?? '')}\n\n${config.resources?.links?.map(link => `[${link.text}](${link.link})`).join(' • ') ?? ''}`, - insightElement: isValidElement(config.resources?.description) - ? config.resources?.description - : null, - icon: , - showWhenLoading: true, - }, - ] - : []), - ]; - - return ( - - {isError ?
{t('Error loading summary')}
: null} - - - {insightCards.map(card => { - if ((!isPending && !card.insight) || (isPending && !card.showWhenLoading)) { - return null; - } - return ( - - - {card.icon} - {card.title} - - - - - - {isPending ? ( - - - - ) : ( - - {card.insightElement} - {card.insight && ( - - )} - - )} - - - ); - })} - - {data?.eventId && !isPending && event && event.id !== data?.eventId && ( - - - - )} - -
- ); -} - -const InsightCard = styled('div')` - display: flex; - flex-direction: column; - border-radius: ${p => p.theme.radius.md}; - width: 100%; - min-height: 0; -`; - -const CardTitle = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - color: ${p => p.theme.tokens.content.secondary}; - padding-bottom: ${p => p.theme.space.xs}; -`; - -const CardTitleText = styled('p')` - margin: 0; - font-size: ${p => p.theme.font.size.md}; - font-weight: ${p => p.theme.font.weight.sans.medium}; -`; - -const CardTitleIcon = styled('div')` - display: flex; - align-items: center; - color: ${p => p.theme.tokens.content.secondary}; -`; - -const CardLineDecorationWrapper = styled('div')` - display: flex; - width: 14px; - align-self: stretch; - justify-content: center; - flex-shrink: 0; - padding: 0.275rem 0; -`; - -const CardLineDecoration = styled('div')` - width: 1px; - align-self: stretch; - /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ - background-color: ${p => p.theme.tokens.border.primary}; -`; - -const CardContent = styled('div')` - overflow-wrap: break-word; - word-break: break-word; - p { - margin: 0; - white-space: pre-wrap; - } - code { - word-break: break-all; - } - flex: 1; -`; - -const CollapsedHeader = styled('div')` - cursor: pointer; - transition: all 0.2s ease-in-out; -`; - -const ChevronIcon = styled('div')` - display: flex; - align-items: center; - color: ${p => p.theme.tokens.content.secondary}; - transition: transform 0.2s ease-in-out; - flex-shrink: 0; -`; - -const ExpandableContent = styled(motion.div)` - overflow: hidden; -`; diff --git a/static/app/components/group/groupSummaryWithAutofix.spec.tsx b/static/app/components/group/groupSummaryWithAutofix.spec.tsx deleted file mode 100644 index 037893865c9966..00000000000000 --- a/static/app/components/group/groupSummaryWithAutofix.spec.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import {GroupFixture} from 'sentry-fixture/group'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {UserFixture} from 'sentry-fixture/user'; - -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; -import {AutofixSummary} from 'sentry/components/group/groupSummaryWithAutofix'; -import {ConfigStore} from 'sentry/stores/configStore'; -import {trackAnalytics} from 'sentry/utils/analytics'; - -jest.mock('sentry/components/events/autofix/useAutofix'); -jest.mock('sentry/utils/analytics'); - -describe('AutofixSummary', () => { - const organization = OrganizationFixture(); - const group = GroupFixture({id: '1', shortId: 'TEST-1'}); - const user = UserFixture(); - - beforeEach(() => { - jest.clearAllMocks(); - MockApiClient.clearMockResponses(); - ConfigStore.set('user', user); - - jest.mocked(useAutofixData).mockReturnValue({ - data: { - run_id: 'test-run-id', - status: 'COMPLETED', - steps: [], - }, - isPending: false, - isError: false, - error: null, - } as any); - }); - - it('renders feedback buttons for root cause card', () => { - render( - , - {organization} - ); - - expect(screen.getByRole('button', {name: 'This was helpful'})).toBeInTheDocument(); - expect( - screen.getByRole('button', {name: 'This was not helpful'}) - ).toBeInTheDocument(); - }); - - it('renders feedback buttons for solution card', () => { - render( - , - {organization} - ); - - const helpfulButtons = screen.getAllByRole('button', {name: 'This was helpful'}); - expect(helpfulButtons).toHaveLength(2); // One for root cause, one for solution - }); - - it('tracks analytics event when thumbs up is clicked', async () => { - render( - , - {organization} - ); - - const thumbsUpButton = screen.getByRole('button', {name: 'This was helpful'}); - await userEvent.click(thumbsUpButton); - - await waitFor(() => { - expect(trackAnalytics).toHaveBeenCalledWith( - 'seer.autofix.feedback_submitted', - expect.objectContaining({ - step_type: 'root_cause', - positive: true, - group_id: '1', - autofix_run_id: 'test-run-id', - user_id: user.id, - organization, - }) - ); - }); - }); - - it('tracks analytics event when thumbs down is clicked', async () => { - render( - , - {organization} - ); - - const thumbsDownButton = screen.getByRole('button', { - name: 'This was not helpful', - }); - await userEvent.click(thumbsDownButton); - - await waitFor(() => { - expect(trackAnalytics).toHaveBeenCalledWith( - 'seer.autofix.feedback_submitted', - expect.objectContaining({ - step_type: 'root_cause', - positive: false, - group_id: '1', - autofix_run_id: 'test-run-id', - user_id: user.id, - organization, - }) - ); - }); - }); - - it('shows "Thanks!" message after feedback is submitted', async () => { - render( - , - {organization} - ); - - const thumbsUpButton = screen.getByRole('button', {name: 'This was helpful'}); - await userEvent.click(thumbsUpButton); - - expect(await screen.findByText('Thanks!')).toBeInTheDocument(); - expect( - screen.queryByRole('button', {name: 'This was helpful'}) - ).not.toBeInTheDocument(); - }); - - it('does not render feedback buttons when loading', () => { - render( - , - {organization} - ); - - // Root cause should have feedback buttons - expect(screen.getByRole('button', {name: 'This was helpful'})).toBeInTheDocument(); - - // Solution should not have feedback buttons because it's loading - const helpfulButtons = screen.getAllByRole('button', {name: 'This was helpful'}); - expect(helpfulButtons).toHaveLength(1); - }); - - it('tracks different step_type for solution feedback', async () => { - render( - , - {organization} - ); - - const thumbsUpButtons = screen.getAllByRole('button', {name: 'This was helpful'}); - // Click the second thumbs up (solution) - const secondButton = thumbsUpButtons[1]; - if (!secondButton) { - throw new Error('Second thumbs up button not found'); - } - await userEvent.click(secondButton); - - await waitFor(() => { - expect(trackAnalytics).toHaveBeenCalledWith( - 'seer.autofix.feedback_submitted', - expect.objectContaining({ - step_type: 'solution', - positive: true, - }) - ); - }); - }); - - it('tracks changes step_type for code changes feedback', async () => { - render( - , - {organization} - ); - - const thumbsUpButtons = screen.getAllByRole('button', {name: 'This was helpful'}); - // Click the third thumbs up (code changes) - const thirdButton = thumbsUpButtons[2]; - if (!thirdButton) { - throw new Error('Third thumbs up button not found'); - } - await userEvent.click(thirdButton); - - await waitFor(() => { - expect(trackAnalytics).toHaveBeenCalledWith( - 'seer.autofix.feedback_submitted', - expect.objectContaining({ - step_type: 'changes', - positive: true, - }) - ); - }); - }); - - it('does not render feedback buttons when run_id is missing', () => { - jest.mocked(useAutofixData).mockReturnValue({ - data: null, - isPending: false, - isError: false, - error: null, - } as any); - - render( - , - {organization} - ); - - expect( - screen.queryByRole('button', {name: 'This was helpful'}) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/group/groupSummaryWithAutofix.tsx b/static/app/components/group/groupSummaryWithAutofix.tsx deleted file mode 100644 index e068e0357ff625..00000000000000 --- a/static/app/components/group/groupSummaryWithAutofix.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import {Fragment, useMemo} from 'react'; -import styled from '@emotion/styled'; -import type {Variants} from 'framer-motion'; -import {motion} from 'framer-motion'; - -import {Flex, Stack} from '@sentry/scraps/layout'; - -import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton'; -import {AutofixStepFeedback} from 'sentry/components/events/autofix/autofixStepFeedback'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; -import { - getAutofixRunExists, - getCodeChangesDescription, - getCodeChangesIsLoading, - getRootCauseCopyText, - getRootCauseDescription, - getSolutionCopyText, - getSolutionDescription, - getSolutionIsLoading, - hasPullRequest, -} from 'sentry/components/events/autofix/utils'; -import {GroupSummary} from 'sentry/components/group/groupSummary'; -import {Placeholder} from 'sentry/components/placeholder'; -import {IconCode, IconFix, IconFocus} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {MarkedText} from 'sentry/utils/marked/markedText'; -import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -const pulseAnimation: Variants = { - initial: {opacity: 1}, - animate: { - opacity: 0.6, - transition: { - repeat: Infinity, - repeatType: 'reverse', - duration: 1, - }, - }, -}; - -interface InsightCardObject { - id: string; - insight: string | null | undefined; - title: string; - copyAnalyticsEventKey?: string; - copyAnalyticsEventName?: string; - copyText?: string | null; - copyTitle?: string | null; - feedbackType?: 'root_cause' | 'solution' | 'changes'; - icon?: React.ReactNode; - insightElement?: React.ReactNode; - isLoading?: boolean; - onClick?: () => void; -} - -export function GroupSummaryWithAutofix({ - group, - event, - project, - preview = false, -}: { - event: Event; - group: Group; - project: Project; - preview?: boolean; -}) { - const {data: autofixData, isPending} = useAutofixData({groupId: group.id}); - - const rootCauseDescription = useMemo( - () => (autofixData ? getRootCauseDescription(autofixData) : null), - [autofixData] - ); - - const rootCauseCopyText = useMemo( - () => (autofixData ? getRootCauseCopyText(autofixData) : null), - [autofixData] - ); - - const solutionDescription = useMemo( - () => (autofixData ? getSolutionDescription(autofixData) : null), - [autofixData] - ); - - const solutionCopyText = useMemo( - () => (autofixData ? getSolutionCopyText(autofixData) : null), - [autofixData] - ); - - const solutionIsLoading = useMemo( - () => (autofixData ? getSolutionIsLoading(autofixData) : false), - [autofixData] - ); - - const codeChangesDescription = useMemo( - () => (autofixData ? getCodeChangesDescription(autofixData) : null), - [autofixData] - ); - - const codeChangesIsLoading = useMemo( - () => (autofixData ? getCodeChangesIsLoading(autofixData) : false), - [autofixData] - ); - - // Track autofix features analytics - useRouteAnalyticsParams({ - has_root_cause: Boolean(rootCauseDescription), - has_solution: Boolean(solutionDescription), - has_coded_solution: Boolean(codeChangesDescription), - has_pr: hasPullRequest(autofixData), - }); - - if (isPending && getAutofixRunExists(group)) { - return ; - } - - if (rootCauseDescription) { - return ( - - ); - } - - return ; -} - -export function AutofixSummary({ - group, - rootCauseDescription, - solutionDescription, - solutionIsLoading, - codeChangesDescription, - codeChangesIsLoading, - rootCauseCopyText, - solutionCopyText, -}: { - codeChangesDescription: string | null; - codeChangesIsLoading: boolean; - group: Group; - rootCauseCopyText: string | null; - rootCauseDescription: string | null; - solutionCopyText: string | null; - solutionDescription: string | null; - solutionIsLoading: boolean; -}) { - const organization = useOrganization(); - const navigate = useNavigate(); - const location = useLocation(); - const {data: autofixData} = useAutofixData({groupId: group.id}); - - const seerLink = { - pathname: location.pathname, - query: { - ...location.query, - seerDrawer: true, - }, - }; - - const insightCards: InsightCardObject[] = [ - { - id: 'root_cause_description', - title: t('Root Cause'), - insight: rootCauseDescription, - icon: , - feedbackType: 'root_cause', - onClick: () => { - trackAnalytics('autofix.summary_root_cause_clicked', { - organization, - group_id: group.id, - }); - navigate({ - ...seerLink, - query: { - ...seerLink.query, - scrollTo: 'root_cause', - }, - }); - }, - copyTitle: t('Copy root cause as Markdown'), - copyText: rootCauseCopyText, - copyAnalyticsEventName: 'Autofix: Copy Root Cause as Markdown', - copyAnalyticsEventKey: 'autofix.root_cause.copy', - }, - - ...(solutionDescription || solutionIsLoading - ? [ - { - id: 'solution_description', - title: t('Solution'), - insight: solutionDescription, - icon: , - isLoading: solutionIsLoading, - feedbackType: 'solution' as const, - onClick: () => { - trackAnalytics('autofix.summary_solution_clicked', { - organization, - group_id: group.id, - }); - navigate({ - ...seerLink, - query: { - ...seerLink.query, - scrollTo: 'solution', - }, - }); - }, - copyTitle: t('Copy solution as Markdown'), - copyText: solutionCopyText, - copyAnalyticsEventName: 'Autofix: Copy Solution as Markdown', - copyAnalyticsEventKey: 'autofix.solution.copy', - }, - ] - : []), - - ...(codeChangesDescription || codeChangesIsLoading - ? [ - { - id: 'code_changes', - title: t('Code Changes'), - insight: codeChangesDescription, - icon: , - isLoading: codeChangesIsLoading, - feedbackType: 'changes' as const, - onClick: () => { - trackAnalytics('autofix.summary_code_changes_clicked', { - organization, - group_id: group.id, - }); - navigate({ - ...seerLink, - query: { - ...seerLink.query, - scrollTo: 'code_changes', - }, - }); - }, - }, - ] - : []), - ]; - - return ( -
- - - {insightCards.map(card => { - if (!card.isLoading && !card.insight) { - return null; - } - - return ( - - - - - {card.icon} - {card.title} - - - {!card.isLoading && card.feedbackType && autofixData?.run_id && ( - e.stopPropagation()} - /> - )} - {card.copyText && card.copyTitle && ( - { - e.stopPropagation(); - }} - analyticsEventName={card.copyAnalyticsEventName} - analyticsEventKey={card.copyAnalyticsEventKey} - /> - )} - - - - {card.isLoading ? ( - - - - ) : ( - - {card.insightElement} - {card.insight && ( - { - // Stop propagation if the click is directly on a link - if ((e.target as HTMLElement).tagName === 'A') { - e.stopPropagation(); - } - }} - text={ - card.isLoading - ? card.insight.replace(/\*\*/g, '') - : card.insight - } - /> - )} - - )} - - - - ); - })} - - -
- ); -} - -const InsightCardButton = styled(motion.div)` - border-radius: ${p => p.theme.radius.md}; - border: 1px solid ${p => p.theme.tokens.border.primary}; - width: 100%; - min-height: 0; - position: relative; - overflow: hidden; - cursor: pointer; - padding: 0; - box-shadow: ${p => p.theme.shadow.low}; - background-color: ${p => p.theme.tokens.background.primary}; - - &:hover { - background-color: ${p => - p.theme.tokens.interactive.transparent.neutral.background.hover}; - } -`; - -const InsightGrid = styled('div')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.lg}; - position: relative; - - &:before { - content: ''; - position: absolute; - left: ${p => p.theme.space['2xl']}; - top: ${p => p.theme.space['3xl']}; - bottom: ${p => p.theme.space.xl}; - width: 1px; - /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ - background: ${p => p.theme.tokens.border.primary}; - z-index: 0; - } -`; - -const CardTitle = styled('div')<{preview?: boolean}>` - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - color: ${p => p.theme.tokens.content.primary}; - padding: ${p => p.theme.space.xs} ${p => p.theme.space.xs} 0 ${p => p.theme.space.md}; - justify-content: space-between; -`; - -const CardTitleText = styled('p')` - margin: 0; - font-size: ${p => p.theme.font.size.md}; - font-weight: ${p => p.theme.font.weight.sans.medium}; - margin-top: 1px; -`; - -const CardTitleIcon = styled('div')` - display: flex; - align-items: center; - color: ${p => p.theme.tokens.content.primary}; -`; - -const CardContent = styled('div')` - overflow-wrap: break-word; - word-break: break-word; - padding: ${p => p.theme.space.xs} ${p => p.theme.space.md} ${p => p.theme.space.md} - ${p => p.theme.space.md}; - text-align: left; - flex: 1; - - p { - margin: 0; - white-space: pre-wrap; - } - - code { - word-break: break-all; - } - - a { - color: ${p => p.theme.tokens.interactive.link.accent.rest}; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } -`; diff --git a/static/app/components/modals/dataWidgetViewerModal.tsx b/static/app/components/modals/dataWidgetViewerModal.tsx index 41079fcb99f423..520f6ab2681198 100644 --- a/static/app/components/modals/dataWidgetViewerModal.tsx +++ b/static/app/components/modals/dataWidgetViewerModal.tsx @@ -11,7 +11,7 @@ import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Grid, Stack, Container} from '@sentry/scraps/layout'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Pagination} from '@sentry/scraps/pagination'; import {Select, SelectOption} from '@sentry/scraps/select'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -79,7 +79,6 @@ import { dashboardFiltersToString, eventViewFromWidget, getFieldsFromEquations, - getNumEquations, getWidgetDiscoverUrl, getWidgetIssueUrl, getWidgetReleasesUrl, @@ -306,7 +305,6 @@ function DataWidgetViewerModal(props: Props) { }; const {aggregates, columns} = tableWidget.queries[0]!; const {orderby} = widget.queries[0]!; - const order = orderby.startsWith('-'); const rawOrderby = trimStart(orderby, '-'); const fields = @@ -342,14 +340,6 @@ function DataWidgetViewerModal(props: Props) { } } - // Need to set the orderby of the eventsv2 query to equation[index] format - // since eventsv2 does not accept the raw equation as a valid sort payload - if (isEquation(rawOrderby) && tableWidget.queries[0]!.orderby === orderby) { - tableWidget.queries[0]!.orderby = `${order ? '-' : ''}equation[${ - getNumEquations(fields) - 1 - }]`; - } - // Default table columns for visualizations that don't have a group by set const hasGroupBy = (widget.queries[0]?.columns.length ?? 0) > 0; const shouldReplaceTableColumns = @@ -1075,8 +1065,8 @@ function ViewerTableV2({ datasetConfig?.getFieldHeaderMap?.(tableWidget.queries[selectedQueryIndex]) ?? {} ); - // Inject any prettified function names that aren't currently aliased into the aliases for (const column of tableColumns) { + // Inject any prettified function names that aren't currently aliased into the aliases const parsedFunction = parseFunction(column.key); if (!aliases[column.key] && parsedFunction) { aliases[column.key] = prettifyParsedFunction(parsedFunction); diff --git a/static/app/components/pageFilters/actions.tsx b/static/app/components/pageFilters/actions.tsx index a9e1f369f6409c..efb3d77fdc482c 100644 --- a/static/app/components/pageFilters/actions.tsx +++ b/static/app/components/pageFilters/actions.tsx @@ -110,6 +110,10 @@ export type InitializeUrlStateParams = { organization: Organization; defaultSelection?: Partial; forceProject?: MinimalProject | null; + /** + * the maximum number of sequential days that can be selected on the date page filter + */ + maxDateRange?: number; /** * When set, the stats period will fallback to the `maxPickableDays` days if the stored selection exceeds the limit. */ @@ -157,6 +161,7 @@ export function initializeUrlState({ skipLoadLastUsed, skipLoadLastUsedEnvironment, maxPickableDays, + maxDateRange, shouldPersist = true, shouldForceProject, defaultSelection, @@ -308,6 +313,7 @@ export function initializeUrlState({ } let shouldUseMaxPickableDays = false; + let shouldUseMaxDateRange = false; if (maxPickableDays && pageFilters.datetime) { let {start, end} = pageFilters.datetime; @@ -320,16 +326,33 @@ export function initializeUrlState({ if (start && end) { const periodStart = new Date(start); + const periodEnd = new Date(end); const maxPeriod = parseStatsPeriod(`${maxPickableDays}d`); + const maxTimeRange = (maxDateRange ?? maxPickableDays) * 24 * 60 * 60 * 1000; const maxStart = new Date(maxPeriod.start); - if (periodStart.getTime() < maxStart.getTime()) { - shouldUseMaxPickableDays = true; - pageFilters.datetime = { - period: `${maxPickableDays}d`, - start: null, - end: null, - utc: datetime.utc, - }; + if (maxDateRange) { + if ( + periodEnd.getTime() - periodStart.getTime() > maxTimeRange || + periodStart.getTime() < maxStart.getTime() + ) { + shouldUseMaxDateRange = true; + pageFilters.datetime = { + period: `${maxDateRange}d`, + start: null, + end: null, + utc: datetime.utc, + }; + } + } else { + if (periodStart.getTime() < maxStart.getTime()) { + shouldUseMaxPickableDays = true; + pageFilters.datetime = { + period: `${maxPickableDays}d`, + start: null, + end: null, + utc: datetime.utc, + }; + } } } } @@ -343,21 +366,31 @@ export function initializeUrlState({ ); } - const newDatetime = shouldUseMaxPickableDays - ? { - period: `${maxPickableDays}d`, - start: null, - end: null, - utc: datetime.utc, - } - : { - ...datetime, - period: - parsed.start || parsed.end || parsed.period || shouldUsePinnedDatetime - ? datetime.period - : null, - utc: parsed.utc || shouldUsePinnedDatetime ? datetime.utc : null, - }; + let newDatetime: PageFiltersUpdate; + if (shouldUseMaxDateRange) { + newDatetime = { + period: `${maxDateRange}d`, + start: null, + end: null, + utc: datetime.utc, + }; + } else if (shouldUseMaxPickableDays) { + newDatetime = { + period: `${maxPickableDays}d`, + start: null, + end: null, + utc: datetime.utc, + }; + } else { + newDatetime = { + ...datetime, + period: + parsed.start || parsed.end || parsed.period || shouldUsePinnedDatetime + ? datetime.period + : null, + utc: parsed.utc || shouldUsePinnedDatetime ? datetime.utc : null, + }; + } if (!skipInitializeUrlParams) { updateParams({project, environment, ...newDatetime}, location, navigate, { diff --git a/static/app/components/pageFilters/container.spec.tsx b/static/app/components/pageFilters/container.spec.tsx index 2f0eaee741d6a1..9a7a316bdbc6d6 100644 --- a/static/app/components/pageFilters/container.spec.tsx +++ b/static/app/components/pageFilters/container.spec.tsx @@ -11,6 +11,7 @@ import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {getUtcToLocalDateObject} from 'sentry/utils/dates'; import {localStorageWrapper} from 'sentry/utils/localStorage'; describe('PageFiltersContainer', () => { @@ -561,6 +562,129 @@ describe('PageFiltersContainer', () => { }); }); + describe('maxDateRange param', () => { + it('resets period when maxDateRange appears and current selection exceeds it', async () => { + const {rerender} = render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/test/', + query: {statsPeriod: '14d'}, + }, + route: '/organizations/:orgId/test/', + }, + }); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: '14d', + utc: null, + start: null, + end: null, + }) + ); + + rerender(); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: '7d', + utc: null, + start: null, + end: null, + }) + ); + }); + + it('does not reset period when maxDateRange appears but selection is within it', async () => { + const {rerender} = render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/test/', + query: {statsPeriod: '7d'}, + }, + route: '/organizations/:orgId/test/', + }, + }); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: '7d', + utc: null, + start: null, + end: null, + }) + ); + + rerender(); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: '7d', + utc: null, + start: null, + end: null, + }) + ); + }); + + it('does not reset period when selection is within maxPickableDays and maxDateRange', async () => { + const start = moment().subtract(14, 'days').format('YYYY-MM-DDTHH:mm:ss'); + const end = moment().subtract(8, 'days').format('YYYY-MM-DDTHH:mm:ss'); + render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/test/', + query: {start, end}, + }, + route: '/organizations/:orgId/test/', + }, + }); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: null, + utc: null, + start: getUtcToLocalDateObject(start), + end: getUtcToLocalDateObject(end), + }) + ); + }); + + it('resets absolute range when maxDateRange appears and range exceeds it', async () => { + const start = moment().subtract(10, 'days').format('YYYY-MM-DDTHH:mm:ss'); + const end = moment().subtract(1, 'days').format('YYYY-MM-DDTHH:mm:ss'); + + const {rerender} = render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/test/', + query: {start, end}, + }, + route: '/organizations/:orgId/test/', + }, + }); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime.period).toBeNull() + ); + + rerender(); + + await waitFor(() => + expect(PageFiltersStore.getState().selection.datetime).toEqual({ + period: '7d', + utc: null, + start: null, + end: null, + }) + ); + }); + }); + describe('skipInitializeUrlParams', () => { const skipInitProjects = [ ProjectFixture({id: '1', slug: 'staging-project', environments: ['staging']}), diff --git a/static/app/components/pageFilters/container.tsx b/static/app/components/pageFilters/container.tsx index 2b1d73b87cc34d..5b43b009ec8a5f 100644 --- a/static/app/components/pageFilters/container.tsx +++ b/static/app/components/pageFilters/container.tsx @@ -16,6 +16,7 @@ import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {parseStatsPeriod} from 'sentry/components/timeRangeSelector/utils'; import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays'; +import {DAY as DAY_IN_MS} from 'sentry/utils/formatters'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import {useLocation} from 'sentry/utils/useLocation'; import {useDefaultMaxPickableDays} from 'sentry/utils/useMaxPickableDays'; @@ -58,6 +59,7 @@ export function PageFiltersContainer({ skipLoadLastUsed, skipLoadLastUsedEnvironment, maxPickableDays, + maxDateRange, children, ...props }: Props) { @@ -103,6 +105,7 @@ export function PageFiltersContainer({ skipLoadLastUsed, skipLoadLastUsedEnvironment, maxPickableDays, + maxDateRange, memberProjects, nonMemberProjects, defaultSelection, @@ -132,9 +135,10 @@ export function PageFiltersContainer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectsLoaded]); - // Handle dynamic maxPickableDays changes (e.g., switching between pages with different limits). + // Handle dynamic maxPickableDays/maxDateRange changes (e.g., switching between pages with different limits). // When the limit decreases and the current selection exceeds it, reset to the new max. const previousMaxPickableDays = usePrevious(maxPickableDays); + const previousMaxDateRange = usePrevious(maxDateRange); const shouldResetDateTime = useMemo(() => { // Don't act until page filters are initialized - selection.datetime contains // default values until isReady, not the actual URL state @@ -142,10 +146,13 @@ export function PageFiltersContainer({ return false; } - // Only act when maxPickableDays decreases (increasing the limit never invalidates selection) + const effectiveMaxDays = maxDateRange ?? maxPickableDays; + const previousEffectiveMaxDays = previousMaxDateRange ?? previousMaxPickableDays; + + // Only act when the effective limit decreases (increasing the limit never invalidates selection) if ( - previousMaxPickableDays === maxPickableDays || - previousMaxPickableDays < maxPickableDays + previousEffectiveMaxDays === effectiveMaxDays || + previousEffectiveMaxDays < effectiveMaxDays ) { return false; } @@ -154,19 +161,39 @@ export function PageFiltersContainer({ // For relative periods (e.g., "14d"), check if the period exceeds the new max if (period) { - return statsPeriodToDays(period) > maxPickableDays; + return statsPeriodToDays(period) > effectiveMaxDays; } // For absolute date ranges, check if the start date is before the allowed window. // Uses same calculation as initialization in pageFilters.tsx if (start && end) { + const periodStart = new Date(start); + const periodEnd = new Date(end); const maxPeriod = parseStatsPeriod(`${maxPickableDays}d`); const maxStart = new Date(maxPeriod.start); - return new Date(start).getTime() < maxStart.getTime(); + + if (maxDateRange) { + const maxTimeRange = maxDateRange * DAY_IN_MS; + return ( + periodEnd.getTime() - periodStart.getTime() > maxTimeRange || + periodStart.getTime() < maxStart.getTime() + ); + } + + return periodStart.getTime() < maxStart.getTime(); } return false; - }, [isReady, maxPickableDays, previousMaxPickableDays, selection.datetime]); + }, [ + isReady, + maxDateRange, + maxPickableDays, + previousMaxDateRange, + previousMaxPickableDays, + selection.datetime, + ]); + + const resetPeriodDays = maxDateRange ?? maxPickableDays; useLayoutEffect(() => { if (!shouldResetDateTime) { @@ -175,7 +202,7 @@ export function PageFiltersContainer({ // Reset to a relative period matching the new max (clears any absolute dates) const newDateState = getDatetimeFromState({ - period: `${maxPickableDays}d`, + period: `${resetPeriodDays}d`, start: null, end: null, utc: selection.datetime.utc, @@ -183,7 +210,7 @@ export function PageFiltersContainer({ project: [], }); updateDateTime(newDateState, location, navigate); - }, [maxPickableDays, location, navigate, selection.datetime.utc, shouldResetDateTime]); + }, [location, navigate, resetPeriodDays, selection.datetime.utc, shouldResetDateTime]); // Update store persistence when `disablePersistence` changes useEffect(() => updatePersistence(!disablePersistence), [disablePersistence]); diff --git a/static/app/components/timeRangeSelector/utils.tsx b/static/app/components/timeRangeSelector/utils.tsx index eeaf386bfa604a..a6e1d5250806da 100644 --- a/static/app/components/timeRangeSelector/utils.tsx +++ b/static/app/components/timeRangeSelector/utils.tsx @@ -277,7 +277,7 @@ export const _timeRangeAutoCompleteFilter = function { return routes; } +/** + * Returns the paths of all matched route segments for a given URL. + */ +function getMatchedPaths(routes: RouteObject[], url: string): string[] { + const matches = matchRoutes(routes, url); + if (!matches) { + return []; + } + return matches.map(m => m.route.path ?? '(layout)'); +} + describe('buildRoutes()', () => { // Until customer-domains is enabled for single-tenant, self-hosted and path // based slug routes are removed we need to ensure @@ -109,4 +120,23 @@ describe('buildRoutes()', () => { ); } }); + + describe('explore route catch-all', () => { + it('catches unknown subpaths under /explore/', () => { + const spy = jest.spyOn(constants, 'USING_CUSTOMER_DOMAIN', 'get'); + + spy.mockReturnValue(true); + let routes = buildRoutes(); + let matchedPaths = getMatchedPaths(routes, '/explore/nonexistent-page/'); + expect(matchedPaths).toContain(':catchAll/'); + + spy.mockReturnValue(false); + routes = buildRoutes(); + matchedPaths = getMatchedPaths( + routes, + '/organizations/test-org/explore/nonexistent-page/also-nonexistent-page/' + ); + expect(matchedPaths).toContain(':catchAll/*'); + }); + }); }); diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 0a9e7dfe2cb7be..11666b6aa293fc 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2349,6 +2349,16 @@ function buildRoutes(): RouteObject[] { path: 'saved-queries/', component: make(() => import('sentry/views/explore/savedQueries')), }, + // These two routes have to be placed at the end of the exploreChildren + // array to avoid being overridden by the other routes. + { + path: ':catchAll/', + component: make(() => import('sentry/views/explore/indexRedirect')), + }, + { + path: ':catchAll/*', + component: make(() => import('sentry/views/explore/indexRedirect')), + }, ]; const exploreRoutes: SentryRouteObject = { path: '/explore/', diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx index 798dee1bd8a691..7276f6385dfc29 100644 --- a/static/app/types/overrides.tsx +++ b/static/app/types/overrides.tsx @@ -11,8 +11,6 @@ import type {DateRange} from 'sentry/components/timeRangeSelector/dateRange'; import type {SelectorItems} from 'sentry/components/timeRangeSelector/selectorItems'; import type {SentryRouteObject} from 'sentry/router/types'; import type {DataCategory} from 'sentry/types/core'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; import type {DetailedProject, Project} from 'sentry/types/project'; import type {UseReplayForCriticalFlowOptions} from 'sentry/utils/replays/useReplayForCriticalFlow'; import type {UseExperimentOptions, UseExperimentResult} from 'sentry/utils/useExperiment'; @@ -71,12 +69,6 @@ type RouteOverrides = { 'routes:subscription-settings': RouteObjectOverride; }; -type AiSetupConfigrationProps = { - event: Event; - group: Group; - project: Project; -}; - type AiSetupDataConsentProps = { groupId: string; }; @@ -194,7 +186,6 @@ type DashboardLimitProviderProps = { */ type ComponentOverrides = { 'component:ai-configure-seer-quota-sidebar': () => React.ComponentType; - 'component:ai-setup-configuration': () => React.ComponentType; 'component:ai-setup-data-consent': () => React.ComponentType | null; 'component:codecov-integration-settings-link': () => React.ComponentType; 'component:confirm-account-close': () => React.ComponentType; diff --git a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx index 240790dfea562f..4ba171db6081a4 100644 --- a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx @@ -9,6 +9,7 @@ export enum WidgetBuilderVersion { type DashboardsEventParametersWidgetBuilder = { 'dashboards_views.engagement.load': { globalFilterCount: number; + isSentryBuilt: boolean; issuesRatio: number; logRatio: number; metricsRatio: number; diff --git a/static/app/utils/marked/marked.spec.tsx b/static/app/utils/marked/marked.spec.tsx index 1da0feee5fa043..c3d2ba875c1213 100644 --- a/static/app/utils/marked/marked.spec.tsx +++ b/static/app/utils/marked/marked.spec.tsx @@ -3,7 +3,6 @@ import { asyncSanitizedMarked, sanitizedMarked, - sanitizedMarkedNoHeadings, singleLineRenderer, } from 'sentry/utils/marked/marked'; import {loadPrismLanguage} from 'sentry/utils/prism'; @@ -89,20 +88,6 @@ describe('marked', () => { ); }); - it('sanitizedMarkedNoHeadings renders headings as bold text', () => { - expect(sanitizedMarkedNoHeadings('# Heading 1')).toBe('Heading 1'); - expect(sanitizedMarkedNoHeadings('## Heading 2')).toBe('Heading 2'); - expect(sanitizedMarkedNoHeadings('### Heading 3')).toBe('Heading 3'); - }); - - it('sanitizedMarkedNoHeadings renders non-heading markdown normally', () => { - expect(sanitizedMarkedNoHeadings('**bold**')).toBe('

bold

\n'); - expect(sanitizedMarkedNoHeadings('`code`')).toBe('

code

\n'); - expect(sanitizedMarkedNoHeadings('[link](https://example.com)')).toBe( - '

link

\n' - ); - }); - it('single line renderer should not render paragraphs', () => { expect(singleLineRenderer('foo')).toBe('foo'); expect(sanitizedMarked('foo')).toBe('

foo

\n'); diff --git a/static/app/utils/marked/marked.tsx b/static/app/utils/marked/marked.tsx index 218d447046198b..00f3522dcd4de1 100644 --- a/static/app/utils/marked/marked.tsx +++ b/static/app/utils/marked/marked.tsx @@ -43,13 +43,6 @@ class SafeRenderer extends marked.Renderer { } } -class NoHeadingRenderer extends SafeRenderer { - heading(tokens: Tokens.Heading) { - // Render headings as bold text instead of h1-h6 elements - return super.strong({...tokens, type: 'strong'}); - } -} - class NoParagraphRenderer extends SafeRenderer { paragraph(tokens: Tokens.Paragraph) { // Do not render the paragraph but still render sub-tokens @@ -178,17 +171,6 @@ export const sanitizedMarked = (src: string): string => { return noHighlightingMarked.parse(src, {async: false}); }; -/** - * Renders markdown without any heading tags applied. - * WARNING: Does not apply any syntax highlighting. - */ -export const sanitizedMarkedNoHeadings = (src: string): string => { - return noHighlightingMarked.parse(src, { - async: false, - renderer: new NoHeadingRenderer(), - }); -}; - /** * Renders a single line of markdown not wrapped in a paragraph tag. * WARNING: Does not apply any syntax highlighting. diff --git a/static/app/utils/theme/theme.tsx b/static/app/utils/theme/theme.tsx index 936707fe074d86..960548ef21a727 100644 --- a/static/app/utils/theme/theme.tsx +++ b/static/app/utils/theme/theme.tsx @@ -196,7 +196,6 @@ const commonTheme = { truncationFullValue: 10, header: 1000, - dropdown: 1001, // dashboard widget builder backdrop sits behind the sidebar // because it renders on the right next to the sidebar @@ -204,6 +203,7 @@ const commonTheme = { widgetBuilderDrawer: 1016, sidebarPanel: 1019, + dropdown: 1020, sidebar: 1020, // Sentry user feedback modal diff --git a/static/app/utils/useChartInterval.spec.tsx b/static/app/utils/useChartInterval.spec.tsx index 4d84c6de26f188..2849ac53aa2268 100644 --- a/static/app/utils/useChartInterval.spec.tsx +++ b/static/app/utils/useChartInterval.spec.tsx @@ -3,7 +3,11 @@ import {act, render} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; -import {getIntervalOptionsForPageFilter, useChartInterval} from './useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + getIntervalOptionsForPageFilter, + useChartInterval, +} from './useChartInterval'; describe('useChartInterval', () => { beforeEach(() => { @@ -53,6 +57,83 @@ describe('useChartInterval', () => { }); expect(chartInterval).toBe('5m'); }); + + it('defaults to the smallest interval with USE_SMALLEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'] + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('1h'); + }); + + it('defaults to the largest ladder-derived interval with USE_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h']. + // The '1d' option is appended after the default is computed, so it is not + // considered when selecting the biggest default. + let chartInterval!: ReturnType[0]; + let intervalOptions!: ReturnType[2]; + + function TestPage() { + [chartInterval, , intervalOptions] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('6h'); + // '1d' is still present as a selectable option even though it was not the default + expect(intervalOptions.map(o => o.value)).toContain('1d'); + }); + + it('defaults to the second-largest interval with USE_SECOND_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'], + // so the second-biggest is '3h'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('3h'); + }); + + it('falls back to the only option when USE_SECOND_BIGGEST is used with a single-option period', () => { + // A 1-minute period produces only ['1m'] as the valid interval option. + // options[length-2] is undefined, so the fallback is options[length-1] = '1m'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + + act(() => + PageFiltersStore.updateDateTime({ + period: '1m', + start: null, + end: null, + utc: true, + }) + ); + + expect(chartInterval).toBe('1m'); + }); }); describe('getIntervalOptionsForPageFilter', () => { diff --git a/static/app/utils/useChartInterval.tsx b/static/app/utils/useChartInterval.tsx index 743564809f02bb..9ebb5fe5e5d83c 100644 --- a/static/app/utils/useChartInterval.tsx +++ b/static/app/utils/useChartInterval.tsx @@ -26,6 +26,8 @@ export enum ChartIntervalUnspecifiedStrategy { USE_SECOND_BIGGEST = 'use_second_biggest', /** Use the smallest possible interval (e.g., the smallest possible buckets) */ USE_SMALLEST = 'use_smallest', + /** Use the biggest possible interval (e.g., the biggest possible buckets) */ + USE_BIGGEST = 'use_biggest', } interface Options { @@ -71,10 +73,20 @@ function useChartIntervalImpl({ const options = getIntervalOptionsForPageFilter(datetime); // Compute the default from the ladder-derived options, before appending extras - const fallback = - unspecifiedStrategy === ChartIntervalUnspecifiedStrategy.USE_SMALLEST - ? options[0]!.value - : (options[options.length - 2]?.value ?? options[options.length - 1]!.value); + let fallback: string; + switch (unspecifiedStrategy) { + case ChartIntervalUnspecifiedStrategy.USE_SMALLEST: + fallback = options[0]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_BIGGEST: + fallback = options[options.length - 1]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST: + default: + fallback = + options[options.length - 2]?.value ?? options[options.length - 1]!.value; + break; + } if (diffInMinutes >= MINIMUM_DURATION_FOR_ONE_DAY_INTERVAL) { options.push(ONE_DAY_OPTION); diff --git a/static/app/utils/useDatePageFilterProps.tsx b/static/app/utils/useDatePageFilterProps.tsx index 4d5d62ad43640d..ac104aa2b5d5fe 100644 --- a/static/app/utils/useDatePageFilterProps.tsx +++ b/static/app/utils/useDatePageFilterProps.tsx @@ -13,6 +13,7 @@ export function useDatePageFilterProps({ defaultPeriod, maxPickableDays, maxUpgradableDays, + maxDateRange, upsellFooter, }: UseDatePageFilterPropsProps): DatePageFilterProps { return useMemo(() => { @@ -26,9 +27,12 @@ export function useDatePageFilterProps({ [90, '90d', t('Last 90 days')], ]; + // if maxDateRange is set, we need to make sure the options shown don't exceed this max range. // find the relative options that should be enabled based on the maxPickableDays const pickableIndex = - availableRelativeOptions.findLastIndex(([days]) => days <= maxPickableDays) + 1; + availableRelativeOptions.findLastIndex(([days]) => + maxDateRange ? days <= maxDateRange : days <= maxPickableDays + ) + 1; const enabledOptions = Object.fromEntries( availableRelativeOptions .slice(0, pickableIndex) @@ -54,6 +58,7 @@ export function useDatePageFilterProps({ defaultPeriod, isOptionDisabled, maxPickableDays, + maxDateRange, menuFooter, relativeOptions: ({arbitraryOptions}) => ({ ...arbitraryOptions, @@ -61,5 +66,5 @@ export function useDatePageFilterProps({ ...disabledOptions, }), }; - }, [defaultPeriod, maxPickableDays, maxUpgradableDays, upsellFooter]); + }, [defaultPeriod, maxDateRange, maxPickableDays, maxUpgradableDays, upsellFooter]); } diff --git a/static/app/utils/useMaxPickableDays.tsx b/static/app/utils/useMaxPickableDays.tsx index c82bee8eb02fac..6fbc051ae31823 100644 --- a/static/app/utils/useMaxPickableDays.tsx +++ b/static/app/utils/useMaxPickableDays.tsx @@ -35,6 +35,10 @@ export interface MaxPickableDaysOptions { */ maxUpgradableDays: NonNullable; defaultPeriod?: DatePageFilterProps['defaultPeriod']; + /** + * The maximum number of sequential days that can be selected on the date page filter + */ + maxDateRange?: number; upsellFooter?: ReactNode; } diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx index e5511bc1654cba..cb86a27c79aa4d 100644 --- a/static/app/views/dashboards/dashboard.tsx +++ b/static/app/views/dashboards/dashboard.tsx @@ -203,7 +203,8 @@ function DashboardInner({ dashboard.widgets, organization, dashboard.title, - dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0 + dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0, + dashboard.prebuiltId !== undefined && dashboard.prebuiltId !== null ); return () => { diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx index 13df2a15b087ce..1b6aaec654e31b 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx @@ -698,6 +698,59 @@ describe('TraceMetricsConfig', () => { }); }); + describe('getTableSortOptions', () => { + it('returns equation aliases with ƒ labels', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({value: 'equation[0]', label: 'ƒ1'}), + ]) + ); + }); + + it('returns regular aggregates alongside equation aliases', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + const labels = options.map(o => o.label); + expect(labels).toHaveLength(2); + expect(labels[0]).toBe('avg(test_metric)'); + expect(labels[1]).toBe('ƒ1'); + }); + }); + describe('TraceMetricsSearchBar', () => { const SearchBar = TraceMetricsConfig.SearchBar; diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx index 0f15eed12dc544..328e8e82f44e93 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx @@ -8,8 +8,11 @@ import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/ import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import { + getEquationAliasIndex, + isEquationAlias, parseFunction, RateUnit, + stripEquationPrefix, type AggregationOutputType, type DataUnit, type QueryFieldValue, @@ -264,10 +267,19 @@ export const TraceMetricsConfig: DatasetConfig< // We've forced the sort options to use the table sort options UI because // we only want to allow sorting by selected aggregates. getTableSortOptions: (organization, widgetQuery) => - getTableSortOptions(organization, widgetQuery).map(option => ({ - label: formatTraceMetricsFunction(option.value, option.label), - value: option.value, - })), + getTableSortOptions(organization, widgetQuery).map(({value, label}) => { + if (isEquationAlias(value)) { + return { + label: `ƒ${getEquationAliasIndex(value) + 1}`, + value, + }; + } + + return { + label, + value, + }; + }), getGroupByFieldOptions, supportedDisplayTypes: [ DisplayType.AREA, @@ -318,7 +330,9 @@ export const TraceMetricsConfig: DatasetConfig< getFieldHeaderMap: widgetQuery => { return ( widgetQuery?.aggregates.reduce>((acc, aggregate) => { - acc[aggregate] = formatTraceMetricsFunction(aggregate) as string; + acc[aggregate] = stripEquationPrefix( + formatTraceMetricsFunction(aggregate) as string + ); return acc; }, {}) ?? {} ); diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx index d65d9bc1fafd85..aa820d9fe38596 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx @@ -188,6 +188,140 @@ describe('utils', () => { expect(requestData.field).not.toContain('count_unique_user'); }); + describe('equation orderby handling for trace metrics', () => { + it('passes full equation orderby through and adds it to fields for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation|avg(value,test_metric,millisecond,none) / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + expect(requestData.field).toContain( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves equation alias orderby to full equation form for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves ascending equation alias orderby for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: 'equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('converts full equation orderby to alias format for non-EAP datasets', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: ['project', 'count()', 'equation|count() / 2'], + aggregates: ['count()'], + columns: ['project'], + orderby: '-equation|count() / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.ERRORS, + 'test-referrer' + ); + + // Non-EAP datasets convert to equation[N] alias format + // The index is based on the number of equations in aggregates (0 here) + expect(requestData.orderby).toBe('-equation[0]'); + expect(requestData.field).toContain('equation|count() / 2'); + }); + }); + it('adds the orderby to fields if it is not in fields, columns, or aggregates', () => { const widget = WidgetFixture({ displayType: DisplayType.LINE, diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx index 68905561156aa0..311c01de725ffe 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx @@ -95,7 +95,13 @@ export function getSeriesRequestData( requestData.excludeOther = widgetQuery.aggregates.length !== 1 || widget.queries.length !== 1; - if ([DiscoverDatasets.OURLOGS, DiscoverDatasets.SPANS].includes(dataset)) { + if ( + [ + DiscoverDatasets.OURLOGS, + DiscoverDatasets.SPANS, + DiscoverDatasets.TRACEMETRICS, + ].includes(dataset) + ) { if ( isEquation(trimStart(widgetQuery.orderby, '-')) && !requestData.field?.includes(trimStart(widgetQuery.orderby, '-')) diff --git a/static/app/views/dashboards/editAccessSelector.tsx b/static/app/views/dashboards/editAccessSelector.tsx index 214e40fcc50e4f..44695afbb6f4be 100644 --- a/static/app/views/dashboards/editAccessSelector.tsx +++ b/static/app/views/dashboards/editAccessSelector.tsx @@ -361,6 +361,7 @@ export function EditAccessSelector({ />
} + position="bottom-end" strategy="fixed" preventOverflowOptions={{mainAxis: false}} disabled={disabled} diff --git a/static/app/views/dashboards/utils.spec.tsx b/static/app/views/dashboards/utils.spec.tsx index ee76c15f8f821a..8ed3833a318d06 100644 --- a/static/app/views/dashboards/utils.spec.tsx +++ b/static/app/views/dashboards/utils.spec.tsx @@ -194,6 +194,30 @@ describe('Dashboards util', () => { const urlParams = new URLSearchParams(queryString); expect(urlParams.get('query')).toBe('(is:unresolved) release:["1.0.0","2.0.0"] '); }); + it('applies global filters scoped to the issue dataset', () => { + const url = getWidgetIssueUrl( + widget, + { + globalFilter: [ + { + dataset: WidgetType.ISSUE, + tag: {key: 'transaction', name: 'transaction'}, + value: 'transaction:/api/foo', + }, + { + dataset: WidgetType.DISCOVER, + tag: {key: 'transaction', name: 'transaction'}, + value: 'transaction:/api/bar', + }, + ], + }, + selection, + OrganizationFixture() + ); + const queryString = url.split('?')[1]; + const urlParams = new URLSearchParams(queryString); + expect(urlParams.get('query')).toBe('(is:unresolved) transaction:/api/foo'); + }); }); describe('flattenErrors', () => { diff --git a/static/app/views/dashboards/utils.tsx b/static/app/views/dashboards/utils.tsx index 32fb5a597ee8dc..49f3d37079cdc5 100644 --- a/static/app/views/dashboards/utils.tsx +++ b/static/app/views/dashboards/utils.tsx @@ -278,7 +278,11 @@ export function getWidgetIssueUrl( ? {start: getUtcDateString(start), end: getUtcDateString(end), utc} : {statsPeriod: period}; const issuesLocation = `/organizations/${organization.slug}/issues/?${qs.stringify({ - query: applyDashboardFilters(widget.queries?.[0]?.conditions, dashboardFilters), + query: applyDashboardFilters( + widget.queries?.[0]?.conditions, + dashboardFilters, + widget.widgetType + ), sort: widget.queries?.[0]?.orderby, ...datetime, // Pass empty string when projects is empty to preserve "My Projects" selection in URL diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx index dd863d8e9f2b85..f694939ddda362 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx @@ -475,6 +475,194 @@ describe('getWidgetMetricsUrl', () => { expect(metricQuery.query).toBe(''); }); + describe('equations', () => { + it('parses equation into sub-component metric queries and equation row', () => { + const widget: Widget = { + id: '1', + title: 'Equation Widget', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + 'transaction', + ], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + ], + columns: ['transaction'], + conditions: 'transaction:"/api/users"', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + expect(params.metric).toBeDefined(); + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 2 sub-component queries + 1 equation row + expect(metrics).toHaveLength(3); + + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + + expect(parsedMetrics[1].metric).toEqual({ + name: 'requests', + type: 'counter', + unit: 'none', + }); + expect(parsedMetrics[1].aggregateFields).toHaveLength(1); + expect(parsedMetrics[1].aggregateFields[0].groupBy).toBeUndefined(); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,requests,counter,none)' + ); + + // 2 fields, one for the equation and one for the group by + expect(parsedMetrics[2].aggregateFields).toHaveLength(2); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)' + ); + expect(parsedMetrics[2].aggregateFields[1].groupBy).toBe('transaction'); + expect(parsedMetrics[2].query).toContain('transaction:"/api/users"'); + }); + + it('applies dashboard filters to equation query', () => { + const widget: Widget = { + id: '1', + title: 'Filtered Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const dashboardFilters: DashboardFilters = { + release: ['v1.0.0'], + }; + + const url = getWidgetMetricsUrl(widget, dashboardFilters, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + // The equation row should have dashboard filters applied + const equationRow = parsedMetrics[parsedMetrics.length - 1]; + expect(equationRow.query).toContain('release'); + expect(equationRow.query).toContain('v1.0.0'); + }); + + it('handles equation with duplicate function calls', () => { + const widget: Widget = { + id: '1', + title: 'Duplicate Funcs', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + avg(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 1 unique sub-component + 1 equation row (duplicates are collapsed) + expect(metrics).toHaveLength(2); + }); + + it('handles equations with conditional subcomponents', () => { + const widget: Widget = { + id: '1', + title: 'Conditional Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics).toHaveLength(3); + + // First subcomponent is normalized from avg_if to avg with a filter query + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].query).toContain('environment:prod'); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + expect(parsedMetrics[1].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[1].query).toBe(''); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,duration,distribution,none)' + ); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)' + ); + }); + }); + describe('datetime selection', () => { it('includes absolute datetime when start and end are provided', () => { const absoluteSelection: PageFilters = { diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx index d550e41473e453..cab94c1d388655 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx @@ -1,18 +1,23 @@ import type {PageFilters} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; -import {explodeFieldString} from 'sentry/utils/discover/fields'; +import {explodeFieldString, isEquation} from 'sentry/utils/discover/fields'; import {decodeSorts} from 'sentry/utils/queryString'; import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types'; import {DisplayType} from 'sentry/views/dashboards/types'; import {applyDashboardFilters} from 'sentry/views/dashboards/utils'; import {extractTraceMetricFromColumn} from 'sentry/views/dashboards/widgetBuilder/utils/buildTraceMetricAggregate'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import type {BaseMetricQuery} from 'sentry/views/explore/metrics/metricQuery'; +import {parseAggregateExpression} from 'sentry/views/explore/metrics/parseAggregateExpression'; import {getMetricsUrl, makeMetricsPathname} from 'sentry/views/explore/metrics/utils'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import type {GroupBy} from 'sentry/views/explore/queryParams/groupBy'; import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; -import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import { + VisualizeEquation, + VisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; import {ChartType} from 'sentry/views/insights/common/components/chart'; /** @@ -33,7 +38,37 @@ export function getWidgetMetricsUrl( const metricQueries = widget.queries[0].aggregates .flatMap(aggregate => { - // For each aggregate, create a metric query for each widget query + if (isEquation(aggregate)) { + // Use flatMap because of the queries type, but for an equation we will only have one + // true query. The other metric queries filters are parsed out from the equation string. + return widget.queries.flatMap(query => { + const groupByFields: GroupBy[] = query.columns.map( + (col): GroupBy => ({groupBy: col}) + ); + const queryString = + applyDashboardFilters( + query.conditions, + dashboardFilters, + widget.widgetType + ) ?? ''; + + const parsed = parseAggregateExpression(aggregate, queryString); + const results: BaseMetricQuery[] = [...parsed.metricQueries]; + if (parsed.equationRow) { + results.push({ + ...parsed.equationRow, + queryParams: parsed.equationRow.queryParams.replace({ + aggregateFields: [ + new VisualizeEquation(aggregate, {chartType}), + ...groupByFields, + ], + }), + }); + } + return results; + }); + } + return widget.queries.map(query => { const queryString = applyDashboardFilters(query.conditions, dashboardFilters, widget.widgetType) ?? diff --git a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx index b6ede32662b8c2..9e6b380922f6c9 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx @@ -6,7 +6,8 @@ export function trackEngagementAnalytics( widgets: Widget[], organization: Organization, dashboardTitle: string, - globalFilterCount: number + globalFilterCount: number, + isSentryBuilt: boolean ) { // Handle edge-case of dashboard with no widgets. if (!widgets.length) return; @@ -48,6 +49,7 @@ export function trackEngagementAnalytics( logRatio: logWidgetCount / widgets.length, metricsRatio: metricsWidgetCount / widgets.length, globalFilterCount, + isSentryBuilt, }; trackAnalytics('dashboards_views.engagement.load', analyticsPayload); } diff --git a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx index 2776f50b63fd30..d7c668c86ff6fe 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx @@ -7,9 +7,16 @@ import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {toArray} from 'sentry/utils/array/toArray'; import {getUtcDateString} from 'sentry/utils/dates'; import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; -import type {AggregationOutputType, DataUnit} from 'sentry/utils/discover/fields'; +import { + getEquationAliasIndex, + isEquation, + isEquationAlias, + type AggregationOutputType, + type DataUnit, +} from 'sentry/utils/discover/fields'; import type {DiscoverQueryRequestParams} from 'sentry/utils/discover/genericDiscoverQuery'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {decodeSorts} from 'sentry/utils/queryString'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {SERIES_QUERY_DELIMITER} from 'sentry/utils/timeSeries/transformLegacySeriesToTimeSeries'; import type {EventsTimeSeriesResponse} from 'sentry/utils/timeSeries/useFetchEventsTimeSeries'; @@ -280,7 +287,23 @@ export function useTraceMetricsTableQuery( }; if (query.orderby) { - requestParams.sort = toArray(query.orderby); + const baseSort = decodeSorts(query.orderby)[0]; + if (isEquationAlias(baseSort?.field ?? '')) { + const fields = query.fields ?? [...query.columns, ...query.aggregates]; + const equations = fields.filter(isEquation); + const equationIndex = getEquationAliasIndex(baseSort?.field ?? ''); + const equation = equations[equationIndex]; + if (equation) { + requestParams.sort = toArray( + baseSort?.kind === 'desc' ? `-${equation}` : equation + ); + } else { + // In case we failed to find an equation by its index, reset the sort + requestParams.sort = undefined; + } + } else { + requestParams.sort = toArray(query.orderby); + } } const queryParams = { diff --git a/static/app/views/dashboards/widgetCard/widgetFrame.tsx b/static/app/views/dashboards/widgetCard/widgetFrame.tsx index 6a0afac14447a8..11e571562f37d3 100644 --- a/static/app/views/dashboards/widgetCard/widgetFrame.tsx +++ b/static/app/views/dashboards/widgetCard/widgetFrame.tsx @@ -108,7 +108,10 @@ export function WidgetFrame(props: WidgetFrameProps) { { + e.stopPropagation(); + actions[0]!.onAction?.(); + }} to={actions[0]!.to} > {actions[0]!.label} @@ -117,7 +120,10 @@ export function WidgetFrame(props: WidgetFrameProps) { @@ -148,7 +154,8 @@ export function WidgetFrame(props: WidgetFrameProps) { aria-label={t('Copy Widget URL')} variant="transparent" icon={} - onClick={() => { + onClick={e => { + e.stopPropagation(); props.onCopyUrlClick?.(); }} /> @@ -161,7 +168,8 @@ export function WidgetFrame(props: WidgetFrameProps) { aria-label={t('Open Full-Screen View')} variant="transparent" icon={} - onClick={() => { + onClick={e => { + e.stopPropagation(); props.onFullScreenViewClick?.(); }} /> diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index e63bb9f822c887..37ddc7aa4b24ca 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -1,6 +1,6 @@ import 'echarts/lib/chart/heatmap'; -import {useRef} from 'react'; +import {Fragment, useRef, type ReactNode} from 'react'; import {useTheme} from '@emotion/react'; import type { TooltipFormatterCallback, @@ -8,18 +8,25 @@ import type { } from 'echarts/types/dist/shared'; import {Flex} from '@sentry/scraps/layout'; +import {useRenderToString} from '@sentry/scraps/renderToString'; import {BaseChart} from 'sentry/components/charts/baseChart'; -import {getFormatter} from 'sentry/components/charts/components/tooltip'; -import {isChartHovered, truncationFormatter} from 'sentry/components/charts/utils'; +import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip'; +import {isChartHovered} from 'sentry/components/charts/utils'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {t} from 'sentry/locale'; import type {ReactEchartsRef} from 'sentry/types/echarts'; +import {defined} from 'sentry/utils'; +import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; +import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint'; +import {useOrganization} from 'sentry/utils/useOrganization'; import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized'; import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue'; import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; import {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue'; import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings'; +import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; import {HeatMap} from './plottables/heatMap'; import type {HeatMapPlottable} from './plottables/heatMapPlottable'; @@ -34,11 +41,17 @@ interface HeatMapWidgetVisualizationProps { * Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range. */ scale?: 'linear' | 'log'; + /** + * getExploreUrl props that will be used to generate an explore link for the tooltip. Omitting this will not generate an explore link. + */ + tooltipExploreUrlArgs?: Omit; } export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) { const {plottables} = props; const theme = useTheme(); + const organization = useOrganization(); + const renderToString = useRenderToString(); const pageFilters = usePageFilters(); const {start, end, period, utc} = pageFilters.selection.datetime; @@ -68,43 +81,155 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp const Zmax = scale === 'log' ? Math.log1p(heatMapPlottable.Zend) : heatMapPlottable.Zend; + /** Extract the numeric value from ECharts tooltip param.value. */ + function extractValue(data: unknown): number | null { + // param.value can be either: + // 1. The numeric value directly (for heatmap charts with axis trigger) + // 2. An object {name, value} (depends on series config) + if (typeof data === 'number') { + return data; + } + + const value = (data as {value?: unknown} | null | undefined)?.value; + return typeof value === 'number' ? value : null; + } + // Create tooltip formatter - const formatTooltip: TooltipFormatterCallback = ( - params, - asyncTicket - ) => { + const formatTooltip: TooltipFormatterCallback = params => { // Only show the tooltip of the current chart. Otherwise, all tooltips // in the chart group appear. if (!isChartHovered(chartRef?.current)) { return ''; } - return getFormatter({ - isGroupedByDate: true, - showTimeInTooltip: true, - nameFormatter: function (seriesName, _nameFormatterParams) { - return truncationFormatter(seriesName, true, false); - }, - valueFormatter: function (value, _field, _valueFormatterParams) { - const bucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; - const fieldType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE; - - const yAxisMinValueFormatted = formatTooltipValue( - value, - fieldType, - heatMapPlottable.yAxisValueUnit ?? undefined - ); - const yAxisMaxValueFormatted = formatTooltipValue( - value + bucketSize, - fieldType, - heatMapPlottable.yAxisValueUnit ?? undefined - ); - - return `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`; - }, - truncate: false, - utc: utc ?? false, - })(params, asyncTicket); + const seriesParams = Array.isArray(params) ? params : [params]; + + // Filter null values from tooltip + const filteredParams = seriesParams.filter(param => { + // @ts-expect-error ECharts types param.value as unknown, but we know it's [xAxis, yAxis, zAxis] from our HeatMap plottable + const value = extractValue(param.value[2]); + return value !== null; + }); + + let formattedXValue = ECHARTS_MISSING_DATA_VALUE; + + const xAxisBucketSize = heatMapPlottable.heatMapSeries.meta.xAxis.bucketSize; + const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; + const yAxisUnit = heatMapPlottable?.yAxisValueUnit; + const yAxisValueType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE; + + return renderToString( + +
+ {filteredParams.map(param => { + let rawXValue: number | undefined; + let rawYValue: number | undefined; + + let formattedYValue = ECHARTS_MISSING_DATA_VALUE; + let formattedZValue = ECHARTS_MISSING_DATA_VALUE; + if (Array.isArray(param.value) && param.value.length === 3) { + const [xValue, yValue, zValue] = param.value; + + if (defined(xValue) && typeof xValue === 'number') { + rawXValue = xValue; + // bucket size seems to be in seconds but we need to convert to milliseconds + formattedXValue = defaultFormatAxisLabel( + xValue, + true, + utc ?? false, + true, + false, + xAxisBucketSize * 1000 + ).toString(); + } + + if (defined(yValue) && typeof yValue === 'number') { + rawYValue = yValue; + const yAxisMinValueFormatted = formatTooltipValue( + yValue, + yAxisValueType, + yAxisUnit ?? undefined + ); + + if (yAxisBucketSize === 0) { + formattedYValue = yAxisMinValueFormatted; + } else { + const yAxisMaxValueFormatted = formatTooltipValue( + yValue + yAxisBucketSize, + yAxisValueType, + yAxisUnit ?? undefined + ); + + formattedYValue = `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`; + } + } + + if (defined(zValue) && typeof zValue === 'number') { + formattedZValue = formatAbbreviatedNumber(zValue, 4, false); + } + } + + let exploreLink: ReactNode; + + if (defined(rawXValue) && defined(rawYValue) && props.tooltipExploreUrlArgs) { + const xAxisMaxValue = rawXValue + xAxisBucketSize * 1000; + const yAxisMaxValue = rawYValue + yAxisBucketSize; + + const exploreUrlProps: GetExploreUrlArgs = { + organization, + ...props.tooltipExploreUrlArgs, + selection: { + ...pageFilters.selection, + datetime: { + ...pageFilters.selection.datetime, + start: new Date(rawXValue), + end: new Date(xAxisMaxValue), + period: null, + }, + }, + // TODO(nikki): we're only handling metrics for now but if we're looking to support other explore + // surfaces then we'll need to add more logic here + crossEvents: props.tooltipExploreUrlArgs?.crossEvents?.map(crossEvent => { + if (crossEvent.type === 'metrics') { + return { + ...crossEvent, + query: + yAxisBucketSize === 0 + ? `value:<=${rawYValue}` + : `value:>=${rawYValue} value:<${yAxisMaxValue}`, + }; + } + return crossEvent; + }), + }; + + const tracesLink = getExploreUrl(exploreUrlProps); + exploreLink = {t('View related traces')}; + } + + return ( + +
+ + {formattedYValue} + {' '} + {formattedZValue} +
+ {exploreLink && ( +
+ + {exploreLink} + +
+ )} +
+ ); + })} +
+
{formattedXValue}
+
+ + ); }; return ( @@ -118,6 +243,8 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp ref={chartRef} tooltip={{ show: true, + enterable: true, + extraCssText: `box-shadow: 0 0 0 1px ${theme.tokens.border.transparent.neutral.muted}, ${theme.shadow.high}; z-index: ${theme.zIndex.tooltip} !important; pointer-events: auto !important;`, axisPointer: { show: false, }, diff --git a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx index 07c71d97c08025..bafdcee0e01f0e 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx @@ -49,7 +49,7 @@ export class HeatMap implements HeatMapPlottable { toSeries(plottingOptions: HeatMapPlottingOptions): SeriesOption[] { const {heatMapSeries} = this; - const {scale = 'linear'} = plottingOptions; + const {scale = 'linear', theme} = plottingOptions; return [ { @@ -64,7 +64,10 @@ export class HeatMap implements HeatMapPlottable { ]; }), emphasis: { - disabled: true, + itemStyle: { + borderColor: theme.tokens.border.onVibrant.dark, + borderWidth: parseInt(theme.border.xl.replace('px', ''), 10), + }, }, }, ]; diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index d337b70120ce11..ff08d9ce514ded 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -18,7 +18,10 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import type {DataUnit} from 'sentry/utils/discover/fields'; import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + useChartInterval, +} from 'sentry/utils/useChartInterval'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; @@ -120,11 +123,17 @@ export function MetricPanel({ const aggregateSortBys = useQueryParamsAggregateSortBys(); const groupBys = useQueryParamsGroupBys(); const setGroupBys = useSetQueryParamsGroupBys(); - const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const visualize = useMetricVisualize(); const visualizes = useMetricVisualizes(); const setVisualizes = useSetMetricVisualizes(); + // use the biggest interval for the heat map as this produces better patterns + const [interval, setInterval, intervalOptions] = useChartInterval({ + unspecifiedStrategy: + visualize.chartType === ChartType.HEATMAP + ? ChartIntervalUnspecifiedStrategy.USE_BIGGEST + : ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); const [title, setTitle] = useState(() => { if (isVisualizeEquation(visualize)) { diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx index 22403d0732c1e3..b973762558c6ee 100644 --- a/static/app/views/explore/metrics/metricsHeatMap.tsx +++ b/static/app/views/explore/metrics/metricsHeatMap.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import type {UseQueryResult} from '@tanstack/react-query'; import {t} from 'sentry/locale'; @@ -12,9 +13,10 @@ import { useMetricName, useMetricVisualize, useMetricVisualizes, + useTraceMetric, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; -import {prettifyAggregation} from 'sentry/views/explore/utils'; +import {prettifyAggregation, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; interface MetricsHeatMapProps { actions: React.ReactNode; @@ -27,6 +29,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr const visualizes = useMetricVisualizes(); const metricLabel = useMetricLabel(); const metricName = useMetricName(); + const metric = useTraceMetric(); const {data: heatMapSeries, isPending, error} = heatmapResult; @@ -36,6 +39,18 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr ? metricName : (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate); + const tooltipExploreUrlArgs: Omit = useMemo(() => { + return { + crossEvents: [ + { + type: 'metrics', + query: '', + metric, + }, + ], + }; + }, [metric]); + return ( ) } diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx index f937bcf4b98d29..abd86222fc9490 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx @@ -10,6 +10,7 @@ import * as modal from 'sentry/actionCreators/modal'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import * as discoverUtils from 'sentry/views/discover/utils'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; @@ -24,10 +25,17 @@ import {OrganizationContext} from 'sentry/views/organizationContext'; jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/useNavigate'); jest.mock('sentry/actionCreators/modal'); +jest.mock('sentry/views/discover/utils'); const mockedUseLocation = jest.mocked(useLocation); const mockUseNavigate = jest.mocked(useNavigate); const mockOpenSaveQueryModal = jest.mocked(modal.openSaveQueryModal); +const mockHandleAddQueryToDashboard = jest.mocked( + discoverUtils.handleAddQueryToDashboard +); +const mockHandleAddMultipleQueriesToDashboard = jest.mocked( + discoverUtils.handleAddMultipleQueriesToDashboard +); describe('useSaveAsMetricItems', () => { const organization = OrganizationFixture({ @@ -208,6 +216,170 @@ describe('useSaveAsMetricItems', () => { ); }); + it('disables equations in add-to-dashboard without the feature flag', () => { + const equation = + 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; + const encodedMetricQuery = encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [new VisualizeEquation(equation)], + aggregateSortBys: [{field: equation, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + label: 'ƒ1', + }); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: [encodedMetricQuery], + }, + }) + ); + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapper(), + initialProps: {interval: '5m'}, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | {children?: Array<{disabled: boolean; key: string; tooltip: string}>} + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-0' + ); + + expect(equationChild?.disabled).toBe(true); + expect(equationChild?.tooltip).toBe( + 'Equations cannot currently be added to a dashboard' + ); + }); + + it('enables equations in add-to-dashboard with the feature flag', () => { + const orgWithEquationsInDashboards = OrganizationFixture({ + features: [ + 'tracemetrics-enabled', + 'tracemetrics-equations-in-alerts', + 'tracemetrics-equations-in-dashboards', + ], + }); + + // Break the equation into its components to match how metric queries are encoded: + // sum, avg, and the final equation combining them. + const function1 = new VisualizeFunction('sum(value,metric.a,counter,none)'); + const function2 = new VisualizeFunction('avg(value,metric.a,counter,none)'); + const equation = `equation|${function1.yAxis} + ${function2.yAxis}`; + const equationObj = new VisualizeEquation(equation); + + const metricFunctions = [function1, function2, equationObj]; + const encodedMetricQueries = metricFunctions.map(fn => + encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [fn], + aggregateSortBys: [{field: fn.yAxis, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + }) + ); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: encodedMetricQueries, + }, + }) + ); + + function createWrapperWithEquationFlags() { + return function ({children}: {children?: React.ReactNode}) { + return ( + + + {children} + + + ); + }; + } + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapperWithEquationFlags(), + initialProps: { + interval: '5m', + }, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | { + children?: Array<{ + disabled: boolean; + key: string; + label: string; + onAction: () => void; + tooltip: string | undefined; + }>; + } + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-2' + ); + + expect(equationChild?.label).toBe('ƒ1'); + expect(equationChild?.disabled).toBe(false); + expect(equationChild?.tooltip).toBeUndefined(); + + equationChild?.onAction?.(); + + expect(mockHandleAddQueryToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventView: expect.objectContaining({ + yAxis: equation, + }), + yAxis: equation, + }) + ); + + mockHandleAddQueryToDashboard.mockClear(); + mockHandleAddMultipleQueriesToDashboard.mockClear(); + + const addAllToDashboard = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-all' + ); + + addAllToDashboard?.onAction?.(); + + expect(mockHandleAddMultipleQueriesToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventViews: expect.arrayContaining([ + expect.objectContaining({ + yAxis: equation, + }), + ]), + }) + ); + }); + it('formats alerts submenu labels for equations', () => { const equation = 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx index bfb9a9187b81ee..180f23aa38421a 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx @@ -35,6 +35,7 @@ import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; import { canUseMetricsAlertsUI, canUseMetricsEquationsInAlerts, + canUseMetricsEquationsInDashboards, canUseMetricsSavedQueriesUI, } from './metricsFlags'; @@ -54,6 +55,9 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { const metricQueries = useMultiMetricsQueryParams(); const {addToDashboard} = useAddMetricToDashboard(); + const metricsEquationsInDashboardsEnabled = + canUseMetricsEquationsInDashboards(organization); + const project = projects.length === 1 ? projects[0] @@ -193,9 +197,14 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { addToDashboard( metricQueries.filter( metricQuery => - !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) && - metricQuery.queryParams.visualizes[0]!.chartType !== - ChartType.HEATMAP + metricQuery.queryParams.visualizes[0]?.chartType !== + ChartType.HEATMAP && + // Allow all charts if you have the flag, otherwise only allow non-equation charts without the flag + (metricsEquationsInDashboardsEnabled || + (!metricsEquationsInDashboardsEnabled && + !isVisualizeEquation( + metricQuery.queryParams.visualizes[0]! + ))) ) ); }, @@ -205,7 +214,8 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { ...metricQueries.map((metricQuery, index) => { const visualize = metricQuery.queryParams.visualizes[0]!; const isUnsupported = - isVisualizeEquation(visualize) || visualize.chartType === ChartType.HEATMAP; + (!metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize)) || + visualize.chartType === ChartType.HEATMAP; const label = isVisualizeFunction(visualize) ? `${metricQuery.label ?? getVisualizeLabel(index, isVisualizeEquation(visualize))}: ${ formatTraceMetricsFunction( @@ -222,20 +232,22 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { if (isUnsupported) { return; } + // TODO: Handle sorting by equation better addToDashboard(metricQuery); }, disabled: isUnsupported, - tooltip: isVisualizeEquation(visualize) - ? t('Equations cannot currently be added to a dashboard') - : visualize.chartType === ChartType.HEATMAP - ? t('Heat maps cannot currently be added to a dashboard') - : undefined, + tooltip: + !metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize) + ? t('Equations cannot currently be added to a dashboard') + : visualize.chartType === ChartType.HEATMAP + ? t('Heat maps cannot currently be added to a dashboard') + : undefined, }; }), ], }, ]; - }, [addToDashboard, metricQueries]); + }, [addToDashboard, metricQueries, metricsEquationsInDashboardsEnabled]); return useMemo(() => { return [...saveAsItems, ...saveAsAlertItems, ...addToDashboardItems]; diff --git a/static/app/views/explore/spans/content.spec.tsx b/static/app/views/explore/spans/content.spec.tsx index 4c56614d70fccf..f294d998e14f6f 100644 --- a/static/app/views/explore/spans/content.spec.tsx +++ b/static/app/views/explore/spans/content.spec.tsx @@ -135,7 +135,7 @@ describe('ExploreContent', () => { ).toBeInTheDocument(); }); - it('resets period when max pickable days decreases', async () => { + it('resets period when maxDateRange is applied after cross events are added', async () => { PageFiltersStore.onInitializeUrlState({ projects: [project].map(p => parseInt(p.id, 10)), environments: [], diff --git a/static/app/views/explore/spans/content.tsx b/static/app/views/explore/spans/content.tsx index 5ecde70d0ac12f..721c173ca54df7 100644 --- a/static/app/views/explore/spans/content.tsx +++ b/static/app/views/explore/spans/content.tsx @@ -48,12 +48,6 @@ import {TraceItemDataset} from 'sentry/views/explore/types'; import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnboardingProject'; import {TopBar} from 'sentry/views/navigation/topBar'; -const CROSS_EVENTS_DATE_OVERRIDE: MaxPickableDaysOptions = { - defaultPeriod: MAX_PERIOD_FOR_CROSS_EVENTS, - maxPickableDays: MAX_DAYS_FOR_CROSS_EVENTS, - maxUpgradableDays: MAX_DAYS_FOR_CROSS_EVENTS, -}; - function useHasCrossEvents() { const crossEvents = useQueryParamsCrossEvents(); return defined(crossEvents) && crossEvents.length > 0; @@ -77,6 +71,13 @@ function ExploreContentInner() { dataCategories: [DataCategory.SPANS], }); + const CROSS_EVENTS_DATE_OVERRIDE: MaxPickableDaysOptions = { + defaultPeriod: MAX_PERIOD_FOR_CROSS_EVENTS, + maxPickableDays: dataCategoryMaxPickableDays.maxPickableDays, + maxUpgradableDays: MAX_DAYS_FOR_CROSS_EVENTS, + maxDateRange: MAX_DAYS_FOR_CROSS_EVENTS, + }; + const maxPickableDays = hasCrossEvents ? CROSS_EVENTS_DATE_OVERRIDE : dataCategoryMaxPickableDays; @@ -86,7 +87,10 @@ function ExploreContentInner() { return ( - + diff --git a/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx b/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx index 7f37496af3b8a7..e05dc73cac1c17 100644 --- a/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx +++ b/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx @@ -1,6 +1,6 @@ import {memo, useCallback, useMemo} from 'react'; -import {Grid} from '@sentry/scraps/layout'; +import {Container, Grid} from '@sentry/scraps/layout'; import {SearchQueryBuilderProvider} from 'sentry/components/searchQueryBuilder/context'; import { @@ -143,19 +143,23 @@ export const SpansTabCrossEventMetricsSearchBar = memo( }); return ( - - - - - + + + + + + + + + ); } diff --git a/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx b/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx index e852318d7caec2..e7194c61586c48 100644 --- a/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx +++ b/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx @@ -2,7 +2,7 @@ import {Fragment, useEffect, useEffectEvent} from 'react'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Container} from '@sentry/scraps/layout'; +import {Container, Grid} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; @@ -30,7 +30,13 @@ import {TraceItemDataset} from 'sentry/views/explore/types'; const EMPTY_CROSS_EVENTS: CrossEvent[] = []; -export function SpansTabCrossEventSearchBars() { +interface SpansTabCrossEventSearchBarsProps { + hasIndependentDateColumn?: boolean; +} + +export function SpansTabCrossEventSearchBars({ + hasIndependentDateColumn = false, +}: SpansTabCrossEventSearchBarsProps) { const organization = useOrganization(); const crossEvents = useQueryParamsCrossEvents() ?? EMPTY_CROSS_EVENTS; const setCrossEvents = useSetQueryParamsCrossEvents(); @@ -71,7 +77,7 @@ export function SpansTabCrossEventSearchBars() { return null; } - return visibleCrossEvents.map(({crossEvent, index}, visibleIndex) => { + const crossEventRows = visibleCrossEvents.map(({crossEvent, index}, visibleIndex) => { let traceItemType = TraceItemDataset.SPANS; if (crossEvent.type === 'logs') { traceItemType = TraceItemDataset.LOGS; @@ -176,4 +182,17 @@ export function SpansTabCrossEventSearchBars() { ); }); + + if (!hasIndependentDateColumn) { + return {crossEventRows}; + } + + return ( + + {crossEventRows} + + ); } diff --git a/static/app/views/explore/spans/spansTabSearchSection.tsx b/static/app/views/explore/spans/spansTabSearchSection.tsx index ac7e2979817e8b..480c47c2bc684f 100644 --- a/static/app/views/explore/spans/spansTabSearchSection.tsx +++ b/static/app/views/explore/spans/spansTabSearchSection.tsx @@ -9,6 +9,7 @@ import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter' import {EnvironmentPageFilter} from 'sentry/components/pageFilters/environment/environmentPageFilter'; import {PageFilterBar} from 'sentry/components/pageFilters/pageFilterBar'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {useSpanSearchQueryBuilderProps} from 'sentry/components/performance/spanSearchQueryBuilder'; import { SearchQueryBuilderProvider, @@ -73,6 +74,7 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection const crossEvents = useQueryParamsCrossEvents(); const setQueryParams = useSetQueryParams(); const [caseInsensitive, setCaseInsensitive] = useCaseInsensitivity(); + const {selection} = usePageFilters(); const organization = useOrganization(); const hasRawSearchReplacement = organization.features.includes( @@ -80,6 +82,9 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection ); const hasCrossEvents = defined(crossEvents) && crossEvents.length > 0; + const hasAbsoluteDateSelection = Boolean( + selection.datetime.start && selection.datetime.end && !selection.datetime.period + ); const {attributes: numberAttributes, isLoading: numberAttributesLoading} = useSpanItemAttributes({}, 'number'); @@ -167,20 +172,30 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection > {tourProps => (
- - - - - - - - - {hasCrossEvents ? : null} + + + + + + + + + + {hasCrossEvents && !hasAbsoluteDateSelection ? ( + + ) : null} + + {hasCrossEvents && hasAbsoluteDateSelection ? ( + + ) : null} {hasCrossEvents ? null : ( diff --git a/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx b/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx index 6f7e750086b0f2..042fc46e6973f0 100644 --- a/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx +++ b/static/app/views/issueDetails/actions/seerCommandPaletteActions.tsx @@ -29,12 +29,11 @@ import {useOpenSeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/se function useSeerState(group: Group, project: Project) { const organization = useOrganization(); const aiConfig = useAiConfig(group, project); - const isExplorer = organization.features.includes('autofix-on-explorer'); const issueTypeConfig = getConfigForIssueType(group, project); const issueTypeSupportsSeer = issueTypeConfig.autofix || issueTypeConfig.issueSummary; const autofix = useExplorerAutofix(group.id, { - enabled: aiConfig.areAiFeaturesAllowed && isExplorer, + enabled: aiConfig.areAiFeaturesAllowed, }); const sections = useMemo( @@ -56,7 +55,6 @@ function useSeerState(group: Group, project: Project) { return { organization, aiConfig, - isExplorer, issueTypeSupportsSeer, autofix, completedRootCause, @@ -80,7 +78,6 @@ export function SeerCommandPaletteActions({ const { organization, aiConfig, - isExplorer, issueTypeSupportsSeer, autofix, completedRootCause, @@ -96,7 +93,7 @@ export function SeerCommandPaletteActions({ ); const codingAgentIntegrations = codingAgentResponse?.integrations; - if (!aiConfig.areAiFeaturesAllowed || !isExplorer || !issueTypeSupportsSeer || !event) { + if (!aiConfig.areAiFeaturesAllowed || !issueTypeSupportsSeer || !event) { return null; } diff --git a/static/app/views/issueDetails/streamline/eventNavigation.tsx b/static/app/views/issueDetails/streamline/eventNavigation.tsx index cd49eb2447cdf1..601e6e159ff7b3 100644 --- a/static/app/views/issueDetails/streamline/eventNavigation.tsx +++ b/static/app/views/issueDetails/streamline/eventNavigation.tsx @@ -12,7 +12,7 @@ import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; import {Count} from 'sentry/components/count'; import {DropdownButton} from 'sentry/components/dropdownButton'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; +import {useExplorerAutofix} from 'sentry/components/events/autofix/useExplorerAutofix'; import {useGroupSummaryData} from 'sentry/components/group/groupSummary'; import {TourElement} from 'sentry/components/tours/components'; import {IconTelescope} from 'sentry/icons'; @@ -117,7 +117,7 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) // Get data for markdown copy functionality const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const handleCopyMarkdown = useCallback(() => { const markdownText = issueAndEventToMarkdown( diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 2bd3d13b7a0a16..dc7e69d152585a 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -5,12 +5,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {renderHook, userEvent} from 'sentry-test/reactTestingLibrary'; import * as indicators from 'sentry/actionCreators/indicator'; -import { - AutofixStatus, - AutofixStepType, - type AutofixData, -} from 'sentry/components/events/autofix/types'; -import * as autofixHooks from 'sentry/components/events/autofix/useAutofix'; +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import * as explorerAutofixHooks from 'sentry/components/events/autofix/useExplorerAutofix'; import type {GroupSummaryData} from 'sentry/components/group/groupSummary'; import * as groupSummaryHooks from 'sentry/components/group/groupSummary'; import {EntryType} from 'sentry/types/event'; @@ -39,46 +35,50 @@ describe('useCopyIssueDetails', () => { possibleCause: 'Missing parameter', }; - // Create a mock AutofixData with steps that includes root cause and solution steps - const mockAutofixData: AutofixData = { - last_triggered_at: '2023-01-01T00:00:00Z', - request: { - repos: [], - }, - codebases: {}, - run_id: '123', - status: AutofixStatus.COMPLETED, - steps: [ + const mockAutofixData: ExplorerAutofixState = { + run_id: 123, + status: 'completed', + updated_at: '2023-01-01T00:00:00Z', + blocks: [ { - id: 'root-cause-step', - index: 0, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Root Cause', - type: AutofixStepType.ROOT_CAUSE_ANALYSIS, - causes: [ + id: 'root-cause-block', + message: { + role: 'assistant' as const, + content: 'Found the root cause', + metadata: {step: 'root_cause'}, + }, + timestamp: '2023-01-01T00:00:00Z', + loading: false, + artifacts: [ { - id: 'cause-1', - description: 'Root cause text', + key: 'root_cause', + reason: 'Root cause analysis', + data: { + one_line_description: 'Root cause text', + five_whys: ['Why 1'], + }, }, ], - selection: null, }, { - id: 'solution-step', - index: 1, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Solution', - type: AutofixStepType.SOLUTION, - solution: [ + id: 'solution-block', + message: { + role: 'assistant' as const, + content: 'Here is the solution', + metadata: {step: 'solution'}, + }, + timestamp: '2023-01-01T00:00:01Z', + loading: false, + artifacts: [ { - timeline_item_type: 'internal_code', - title: 'Solution title', - code_snippet_and_analysis: 'Solution text', + key: 'solution', + reason: 'Solution plan', + data: { + one_line_summary: 'Solution title', + steps: [{title: 'Fix it', description: 'Solution text'}], + }, }, ], - solution_selected: true, }, ], }; @@ -124,7 +124,7 @@ describe('useCopyIssueDetails', () => { ); expect(result).toContain('## Root Cause'); - expect(result).toContain('## Solution'); + expect(result).toContain('## Plan'); }); it('includes tags when present in event', () => { @@ -379,10 +379,17 @@ describe('useCopyIssueDetails', () => { isPending: false, }); - jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({ - data: mockAutofixData, - isPending: false, - }); + jest.spyOn(explorerAutofixHooks, 'useExplorerAutofix').mockReturnValue({ + runState: mockAutofixData, + isLoading: false, + isPolling: false, + startStep: jest.fn(), + createPR: jest.fn(), + reset: jest.fn(), + triggerCodingAgentHandoff: jest.fn(), + codingAgentErrors: [], + dismissCodingAgentError: jest.fn(), + } as any); jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {}); jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); @@ -430,7 +437,7 @@ describe('useCopyIssueDetails', () => { expect(capturedText).toContain(`**Project:** ${group.project?.slug}`); expect(capturedText).toContain('## Issue Summary'); expect(capturedText).toContain('## Root Cause'); - expect(capturedText).toContain('## Solution'); + expect(capturedText).toContain('## Plan'); expect(capturedText).not.toContain('## Exception'); }); diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index b2f42716b88ab6..bb636df57906cc 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -2,12 +2,15 @@ import {useCallback, useMemo, useSyncExternalStore} from 'react'; import {useHotkeys} from '@sentry/scraps/hotkey'; -import type {AutofixData} from 'sentry/components/events/autofix/types'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; import { - getRootCauseCopyText, - getSolutionCopyText, -} from 'sentry/components/events/autofix/utils'; + type ExplorerAutofixState, + getAutofixArtifactFromSection, + getOrderedAutofixSections, + isRootCauseSection, + isSolutionSection, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {artifactToMarkdown} from 'sentry/components/events/autofix/v3/utils'; import { useGroupSummaryData, type GroupSummaryData, @@ -79,10 +82,7 @@ function formatStacktraceToMarkdown(stacktrace: StacktraceType): string { return markdownText; } -export function formatEventToMarkdown( - event: Event, - activeThreadId: number | undefined -): string { +function formatEventToMarkdown(event: Event, activeThreadId: number | undefined): string { let markdownText = ''; // Add tags @@ -145,7 +145,7 @@ export const issueAndEventToMarkdown = ( group: Group, event: Event | null | undefined, groupSummaryData: GroupSummaryData | null | undefined, - autofixData: AutofixData | null | undefined, + autofixData: ExplorerAutofixState | null | undefined, activeThreadId: number | undefined ): string => { // Format the basic issue information @@ -172,14 +172,29 @@ export const issueAndEventToMarkdown = ( } if (autofixData) { - const rootCauseCopyText = getRootCauseCopyText(autofixData); - const solutionCopyText = getSolutionCopyText(autofixData); + const sections = getOrderedAutofixSections(autofixData); + const rootCauseSection = sections.find(isRootCauseSection); + const solutionSection = sections.find(isSolutionSection); + + const rootCauseArtifact = rootCauseSection + ? getAutofixArtifactFromSection(rootCauseSection) + : null; + const solutionArtifact = solutionSection + ? getAutofixArtifactFromSection(solutionSection) + : null; + + const rootCauseCopyText = rootCauseArtifact + ? artifactToMarkdown(rootCauseArtifact, 2) + : null; + const solutionCopyText = solutionArtifact + ? artifactToMarkdown(solutionArtifact, 2) + : null; if (rootCauseCopyText) { - markdownText += `\n## Root Cause\n\`\`\`\n${rootCauseCopyText}\n\`\`\`\n`; + markdownText += `\n${rootCauseCopyText}\n`; } if (solutionCopyText) { - markdownText += `\n## Solution\n\`\`\`\n${solutionCopyText}\n\`\`\`\n`; + markdownText += `\n${solutionCopyText}\n`; } } @@ -193,9 +208,8 @@ export const issueAndEventToMarkdown = ( export const useCopyIssueDetails = (group: Group, event?: Event) => { const organization = useOrganization(); - // These aren't guarded by useAiConfig because they are both non fetching, and should only return data when it's fetched elsewhere. const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const activeThreadId = useActiveThreadId(); const text = useMemo(() => { diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx index f1fc39993bf4d6..ba934a29da9bbe 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx @@ -1,8 +1,4 @@ -import {AutofixDataFixture} from 'sentry-fixture/autofixData'; import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; -import {AutofixStepFixture} from 'sentry-fixture/autofixStep'; -import {EventFixture} from 'sentry-fixture/event'; -import {FrameFixture} from 'sentry-fixture/frame'; import {GroupFixture} from 'sentry-fixture/group'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {DetailedProjectFixture} from 'sentry-fixture/project'; @@ -15,92 +11,60 @@ import { waitForElementToBeRemoved, } from 'sentry-test/reactTestingLibrary'; -import {EntryType} from 'sentry/types/event'; import {SeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/seerDrawer'; +function makeExplorerBlock({ + id = 'block-1', + content = 'Analysis content', + step, + loading = false, + artifacts, +}: { + artifacts?: Array<{data: Record; key: string; reason: string}>; + content?: string; + id?: string; + loading?: boolean; + step?: string; +} = {}) { + return { + id, + message: { + role: 'assistant' as const, + content, + metadata: step ? {step} : null, + }, + timestamp: '2024-01-01T00:00:00Z', + loading, + artifacts: artifacts ?? [], + }; +} + +function makeExplorerAutofixData({ + blocks = [makeExplorerBlock()], + status = 'completed' as const, + run_id = 1, +}: { + blocks?: Array>; + run_id?: number; + status?: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; +} = {}) { + return { + run_id, + blocks, + status, + updated_at: '2024-01-01T00:00:00Z', + }; +} + describe('SeerDrawer', () => { const organization = OrganizationFixture({ hideAiFeatures: false, features: ['gen-ai-features'], }); - const mockEvent = EventFixture({ - entries: [ - { - type: EntryType.EXCEPTION, - data: {values: [{stacktrace: {frames: [FrameFixture()]}}]}, - }, - ], - }); const mockGroup = GroupFixture(); const mockProject = DetailedProjectFixture(); - const mockAutofixData = AutofixDataFixture({steps: [AutofixStepFixture()]}); - - // Create autofix data with various repository configurations for testing notices - const mockAutofixWithReadableRepos = AutofixDataFixture({ - steps: [AutofixStepFixture()], - request: { - repos: [ - { - name: 'org/repo', - provider: 'github', - owner: 'org', - external_id: 'repo-123', - }, - ], - }, - codebases: { - 'repo-123': { - repo_external_id: 'repo-123', - is_readable: true, - is_writeable: true, - }, - }, - }); - - const mockAutofixWithUnreadableGithubRepos = AutofixDataFixture({ - steps: [AutofixStepFixture()], - request: { - repos: [ - { - name: 'org/repo', - provider: 'github', - owner: 'org', - external_id: 'repo-123', - }, - ], - }, - codebases: { - 'repo-123': { - repo_external_id: 'repo-123', - is_readable: false, - is_writeable: false, - }, - }, - }); - - const mockAutofixWithUnreadableNonGithubRepos = AutofixDataFixture({ - steps: [AutofixStepFixture()], - request: { - repos: [ - { - name: 'org/gitlab-repo', - provider: 'gitlab', - owner: 'org', - external_id: 'repo-123', - }, - ], - }, - codebases: { - 'repo-123': { - repo_external_id: 'repo-123', - is_readable: false, - is_writeable: false, - }, - }, - }); - beforeEach(() => { MockApiClient.clearMockResponses(); localStorage.clear(); @@ -112,16 +76,6 @@ describe('SeerDrawer', () => { githubWriteIntegration: {ok: true, repos: []}, }), }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: { - whatsWrong: 'Test whats wrong', - trace: 'Test trace', - possibleCause: 'Test possible cause', - headline: 'Test headline', - }, - }); MockApiClient.addMockResponse({ url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`, body: { @@ -164,51 +118,13 @@ describe('SeerDrawer', () => { }); }); - it('renders issue summary if consent flow is removed and there is no autofix quota', async () => { - const orgWithConsentFlowRemoved = OrganizationFixture({ - hideAiFeatures: false, - features: ['seer-billing', 'gen-ai-features'], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: false, reason: null}, - githubWriteIntegration: {ok: false, repos: []}, - billing: {hasAutofixQuota: false}, - }), - }); + it('renders loading state while autofix setup is pending', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); - render(, { - organization: orgWithConsentFlowRemoved, - }); - - expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument(); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - // Issue summary fields are rendered - expect(screen.getByText('Test whats wrong')).toBeInTheDocument(); - expect(screen.getByText('Test trace')).toBeInTheDocument(); - expect(screen.getByText('Test possible cause')).toBeInTheDocument(); - expect(screen.getByText('Test headline')).toBeInTheDocument(); - - // Should display the seer purchase flow - expect(screen.getByTestId('ai-setup-data-consent')).toBeInTheDocument(); - }); - - it('renders initial state with Start Root Cause Analysis button', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - - render(, { + render(, { organization, }); @@ -217,28 +133,15 @@ describe('SeerDrawer', () => { await waitForElementToBeRemoved(() => screen.queryByTestId('ai-setup-loading-indicator') ); - - expect(screen.getByRole('heading', {name: 'Seer Autofix'})).toBeInTheDocument(); - - // Verify the Start Root Cause Analysis button is available - const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'}); - expect(startButton).toBeInTheDocument(); }); - it('renders GitHub integration setup notice when missing GitHub integration', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: false, reason: null}, - githubWriteIntegration: {ok: false, repos: []}, - }), - }); + it('renders Seer Autofix header text after loading', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); - render(, { + render(, { organization, }); @@ -246,113 +149,37 @@ describe('SeerDrawer', () => { screen.queryByTestId('ai-setup-loading-indicator') ); - expect(screen.getByText('Set Up the GitHub Integration')).toBeInTheDocument(); - expect(screen.getByText('Set Up Integration')).toBeInTheDocument(); - - const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'}); - expect(startButton).toBeInTheDocument(); + expect(screen.getByText('Seer Autofix')).toBeInTheDocument(); }); - it('triggers Seer on clicking the Start button', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - method: 'POST', - body: {autofix: null}, - }); + it('shows reset button that is always enabled', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - method: 'GET', body: {autofix: null}, }); - render(, { + render(, { organization, }); - expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument(); - await waitForElementToBeRemoved(() => screen.queryByTestId('ai-setup-loading-indicator') ); - let resetButton = await screen.findByRole('button', { - name: 'Start a new analysis from scratch', - }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeDisabled(); - - const startButton = screen.getByRole('button', {name: 'Start Root Cause Analysis'}); - await userEvent.click(startButton); - - resetButton = await screen.findByRole('button', { + const resetButton = screen.getByRole('button', { name: 'Start a new analysis from scratch', }); expect(resetButton).toBeInTheDocument(); expect(resetButton).toBeEnabled(); }); - it('shows disabled reset button when hasAutofix is false', async () => { - // Mock AI consent as okay but no autofix capability - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - - // Use jest.spyOn instead of jest.mock inside the test - const issueTypeConfigModule = require('sentry/utils/issueTypeConfig'); - const spy = jest - .spyOn(issueTypeConfigModule, 'getConfigForIssueType') - .mockImplementation(() => ({ - autofix: false, - issueSummary: {enabled: true}, - resources: null, - })); - - render(, { - organization, - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - const resetButton = await screen.findByRole('button', { - name: 'Start a new analysis from scratch', - }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeDisabled(); - - // But the Start Root Cause Analysis button should not be visible - expect( - screen.queryByRole('button', {name: 'Start Root Cause Analysis'}) - ).not.toBeInTheDocument(); - - // Restore the original implementation - spy.mockRestore(); - }); - - it('shows disabled reset button when hasAutofix is true but no autofixData', async () => { - // Mock everything as ready for autofix but no data - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); + it('shows copy button disabled when no autofix run exists', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); - render(, { + render(, { organization, }); @@ -360,29 +187,22 @@ describe('SeerDrawer', () => { screen.queryByTestId('ai-setup-loading-indicator') ); - // Reset button should be visible and enabled (onReset is always provided) - const resetButton = screen.getByRole('button', { - name: 'Start a new analysis from scratch', + const copyButton = screen.getByRole('button', { + name: 'Copy analysis as Markdown', }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeDisabled(); + expect(copyButton).toBeInTheDocument(); + expect(copyButton).toBeDisabled(); }); - it('shows enabled reset button when hasAutofix and autofixData are both true', async () => { - // Mock everything as ready with existing autofix data - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); + it('shows copy button enabled when autofix run exists', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixData}, + body: { + autofix: makeExplorerAutofixData(), + }, }); - render(, { + render(, { organization, }); @@ -390,21 +210,22 @@ describe('SeerDrawer', () => { screen.queryByTestId('ai-setup-loading-indicator') ); - // Reset button should be visible and enabled - const resetButton = screen.getByRole('button', { - name: 'Start a new analysis from scratch', + const copyButton = screen.getByRole('button', { + name: 'Copy analysis as Markdown', }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeEnabled(); + expect(copyButton).toBeInTheDocument(); + expect(copyButton).toBeEnabled(); }); - it('displays reset button with autofix data', async () => { + it('renders reset button enabled with autofix data', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixData}, + body: { + autofix: makeExplorerAutofixData(), + }, }); - render(, { + render(, { organization, }); @@ -419,37 +240,21 @@ describe('SeerDrawer', () => { expect(resetButton).toBeEnabled(); }); - it('displays reset button even without autofix data', async () => { + it('clicking reset triggers a new root cause analysis', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - - render(, { - organization, - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - const resetButton = await screen.findByRole('button', { - name: 'Start a new analysis from scratch', + body: { + autofix: makeExplorerAutofixData(), + }, }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeDisabled(); - expect( - await screen.findByRole('button', {name: 'Start Root Cause Analysis'}) - ).toBeInTheDocument(); - }); - it('resets autofix on clicking the reset button', async () => { - MockApiClient.addMockResponse({ + const postMock = MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixData}, + method: 'POST', + body: {run_id: 2}, }); - render(, { + render(, { organization, }); @@ -457,198 +262,51 @@ describe('SeerDrawer', () => { screen.queryByTestId('ai-setup-loading-indicator') ); - const resetButton = await screen.findByRole('button', { + const resetButton = screen.getByRole('button', { name: 'Start a new analysis from scratch', }); - expect(resetButton).toBeInTheDocument(); - expect(resetButton).toBeEnabled(); await userEvent.click(resetButton); await waitFor(() => { - expect( - screen.getByRole('button', {name: 'Start Root Cause Analysis'}) - ).toBeInTheDocument(); - }); - }); - - it('shows setup instructions when GitHub integration setup is needed', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: false, reason: null}, - githubWriteIntegration: {ok: false, repos: []}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/?provider_key=github&includeConfig=0', - body: [], + expect(postMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({step: 'root_cause'}), + }) + ); }); - - render(, { - organization, - }); - - expect(screen.getByTestId('ai-setup-loading-indicator')).toBeInTheDocument(); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - expect(screen.getByRole('heading', {name: 'Seer Autofix'})).toBeInTheDocument(); - - // Since "Install the GitHub Integration" text isn't found, let's check for - // the "Set Up the GitHub Integration" text which is what the component is actually showing - expect(screen.getByText('Set Up the GitHub Integration')).toBeInTheDocument(); - expect(screen.getByText('Set Up Integration')).toBeInTheDocument(); }); - it('does not render SeerNotices when all repositories are readable', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); + it('renders root cause section when blocks contain root cause step', async () => { MockApiClient.addMockResponse({ url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixWithReadableRepos}, - }); - - render(, { - organization, - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - // We don't expect to see any notice about repositories since all are readable - expect(screen.queryByText(/Seer can't access/)).not.toBeInTheDocument(); - }); - - it('renders warning for unreadable GitHub repository', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixWithUnreadableGithubRepos}, - }); - - render(, { - organization, - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - expect(screen.getByText(/Seer can't access the/)).toBeInTheDocument(); - expect(screen.getByText('org/repo')).toBeInTheDocument(); - expect(screen.getByText(/GitHub integration/)).toBeInTheDocument(); - }); - - it('renders warning for unreadable non-GitHub repository', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: mockAutofixWithUnreadableNonGithubRepos}, - }); - - render(, { - organization, - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - expect(screen.getByText(/Seer can't access the/)).toBeInTheDocument(); - expect(screen.getByText('org/gitlab-repo')).toBeInTheDocument(); - expect( - screen.getByText(/It currently only supports GitHub repositories/) - ).toBeInTheDocument(); - }); - - it('shows cursor integration onboarding step if integration is installed but handoff not configured', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`, - body: { - code_mapping_repos: [], - preference: { - repositories: [{external_id: 'repo-123', name: 'org/repo', provider: 'github'}], - automated_run_stopping_point: 'root_cause', - // No automation_handoff - }, - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/`, body: { - autofixAutomationTuning: 'medium', - seerScannerAutomation: true, + autofix: makeExplorerAutofixData({ + blocks: [ + makeExplorerBlock({ + id: 'rc-1', + step: 'root_cause', + content: 'Root cause analysis result', + artifacts: [ + { + key: 'root_cause', + reason: 'Analysis complete', + data: { + one_line_description: 'A null pointer dereference in the auth module', + five_whys: ['First why', 'Second why'], + }, + }, + ], + }), + ], + status: 'completed', + }), }, }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/autofix-repos/`, - body: [ - { - name: 'org/repo', - provider: 'github', - owner: 'org', - external_id: 'repo-123', - is_readable: true, - is_writeable: true, - }, - ], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/group-search-views/starred/`, - body: [ - { - id: '1', - name: 'Fixability View', - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }, - ], - }); - render(, { - organization: OrganizationFixture({ - features: ['gen-ai-features', 'issue-views'], - }), + render(, { + organization, }); await waitForElementToBeRemoved(() => @@ -656,87 +314,7 @@ describe('SeerDrawer', () => { ); expect( - await screen.findByText('Hand Off to Cursor Cloud Agents') - ).toBeInTheDocument(); - expect( - screen.getByRole('button', {name: 'Set Seer to hand off to Cursor'}) + await screen.findByText('A null pointer dereference in the auth module') ).toBeInTheDocument(); }); - - it('does not show cursor integration step if localStorage skip key is set', async () => { - // Set skip key BEFORE rendering - localStorage.setItem(`seer-onboarding-cursor-skipped:${mockProject.id}`, 'true'); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/seer/preferences/`, - body: { - code_mapping_repos: [], - preference: { - repositories: [{external_id: 'repo-123', name: 'org/repo', provider: 'github'}], - automated_run_stopping_point: 'root_cause', - // No automation_handoff - }, - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/`, - body: { - autofixAutomationTuning: 'medium', - seerScannerAutomation: true, - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {autofix: null}, - }); - MockApiClient.addMockResponse({ - url: `/projects/${mockProject.organization.slug}/${mockProject.slug}/autofix-repos/`, - body: [ - { - name: 'org/repo', - provider: 'github', - owner: 'org', - external_id: 'repo-123', - is_readable: true, - is_writeable: true, - }, - ], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/group-search-views/starred/`, - body: [ - { - id: '1', - name: 'Fixability View', - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }, - ], - }); - - render(, { - organization: OrganizationFixture({ - features: ['gen-ai-features', 'issue-views'], - }), - }); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('ai-setup-loading-indicator') - ); - - // Should not show the step since it was skipped - expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument(); - }); }); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx index f5d0186a7d244d..2cc6a8a20792fb 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx @@ -2,8 +2,7 @@ import {useCallback, useRef} from 'react'; import {useDrawer} from '@sentry/scraps/drawer'; -import {SeerDrawer as LegacySeerDrawer} from 'sentry/components/events/autofix/v1/drawer'; -import {SeerDrawer as ExplorerSeerDrawer} from 'sentry/components/events/autofix/v3/drawer'; +import {SeerDrawer} from 'sentry/components/events/autofix/v3/drawer'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; @@ -12,22 +11,7 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; - -interface SeerDrawerProps { - event: Event; - group: Group; - project: Project; -} - -export function SeerDrawer({group, project, event}: SeerDrawerProps) { - const organization = useOrganization(); - - if (organization.features.includes('autofix-on-explorer')) { - return ; - } - - return ; -} +export {SeerDrawer} from 'sentry/components/events/autofix/v3/drawer'; export const useOpenSeerDrawer = ({ group, @@ -59,7 +43,7 @@ export const useOpenSeerDrawer = ({ `/organizations/${organization.slug}/issues/${group.id}/` ); - openDrawer(() => , { + openDrawer(() => , { ariaLabel: t('Seer drawer'), drawerKey: 'seer-autofix-drawer', resizable: true, diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx deleted file mode 100644 index 34917dfda1be52..00000000000000 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.spec.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import {GroupSearchViewFixture} from 'sentry-fixture/groupSearchView'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {DetailedProjectFixture} from 'sentry-fixture/project'; - -import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {CodingAgentProvider} from 'sentry/components/events/autofix/types'; -import {SeerNotices} from 'sentry/views/issueDetails/streamline/sidebar/seerNotices'; - -describe('SeerNotices', () => { - const createRepository = (overrides = {}) => ({ - external_id: 'repo-123', - name: 'org/repo', - owner: 'org', - provider: 'github', - provider_raw: 'github', - is_readable: true, - is_writeable: true, - ...overrides, - }); - - function getProjectWithAutomation( - automationTuning = 'off' as 'off' | 'low' | 'medium' | 'high' | 'always' - ) { - return { - ...DetailedProjectFixture(), - autofixAutomationTuning: automationTuning, - organization: { - ...DetailedProjectFixture().organization, - }, - }; - } - - const organization = OrganizationFixture(); - - beforeEach(() => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/seer/preferences/`, - body: { - code_mapping_repos: [], - preference: null, - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/starred/`, - body: [], - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/autofix-repos/`, - body: [createRepository()], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - body: { - integrations: [], - }, - }); - }); - - it('shows automation step if automation is allowed and tuning is off', async () => { - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'off', - }, - }); - const project = { - ...DetailedProjectFixture(), - organization: { - ...DetailedProjectFixture().organization, - features: [], - }, - }; - render(, { - organization, - }); - await waitFor(() => { - expect(screen.getByText('Unleash Automation')).toBeInTheDocument(); - }); - }); - - it('shows fixability view step if automation is allowed and view not starred', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/`, - body: [], - }); - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'medium', - }, - }); - const project = getProjectWithAutomation('high'); - render(, { - organization: { - ...organization, - features: ['issue-views'], - }, - }); - await waitFor(() => { - expect(screen.getByText('Get Some Quick Wins')).toBeInTheDocument(); - }); - }); - - it('shows cursor integration step if integration is installed but handoff not configured', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/seer/preferences/`, - body: { - code_mapping_repos: [], - preference: { - repositories: [], - automated_run_stopping_point: 'root_cause', - // No automation_handoff - handoff is not configured - }, - }, - }); - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'medium', - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/starred/`, - body: [ - GroupSearchViewFixture({ - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }), - ], - }); - const project = getProjectWithAutomation('medium'); - render(, { - organization: { - ...organization, - features: [], - }, - }); - await waitFor(() => { - expect(screen.getByText('Hand Off to Cursor Cloud Agents')).toBeInTheDocument(); - }); - }); - - it('does not show cursor integration step if localStorage skip key is set', () => { - // Set localStorage skip key - localStorage.setItem( - `seer-onboarding-cursor-skipped:${DetailedProjectFixture().id}`, - 'true' - ); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'medium', - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/starred/`, - body: [ - GroupSearchViewFixture({ - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }), - ], - }); - const project = getProjectWithAutomation('medium'); - render(, { - organization: { - ...organization, - features: [], - }, - }); - - // Should not show the cursor step since it was skipped - expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument(); - - // Clean up localStorage - localStorage.removeItem( - `seer-onboarding-cursor-skipped:${DetailedProjectFixture().id}` - ); - }); - - it('does not show cursor integration step if handoff is already configured', () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/coding-agents/`, - body: { - integrations: [ - { - id: '123', - provider: 'cursor', - name: 'Cursor', - }, - ], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/seer/preferences/`, - body: { - code_mapping_repos: [], - preference: { - repositories: [], - automated_run_stopping_point: 'root_cause', - automation_handoff: { - handoff_point: 'root_cause', - target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, - integration_id: 123, - }, - }, - }, - }); - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'medium', - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/starred/`, - body: [ - GroupSearchViewFixture({ - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }), - ], - }); - const project = getProjectWithAutomation('medium'); - render(, { - organization: { - ...organization, - features: [], - }, - }); - - // Should not show the cursor step since handoff is already configured - expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument(); - }); - - it('does not render guided steps if all onboarding steps are complete', () => { - MockApiClient.addMockResponse({ - method: 'GET', - url: `/projects/${organization.slug}/${DetailedProjectFixture().slug}/`, - body: { - autofixAutomationTuning: 'medium', - }, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/group-search-views/starred/`, - body: [ - GroupSearchViewFixture({ - query: 'is:unresolved issue.seer_actionability:high', - starred: true, - }), - ], - }); - const project = getProjectWithAutomation('medium'); - render(, { - ...{ - organization, - }, - }); - // Should not find any step titles - expect(screen.queryByText('Set Up the GitHub Integration')).not.toBeInTheDocument(); - expect(screen.queryByText('Pick Repositories to Work In')).not.toBeInTheDocument(); - expect(screen.queryByText('Unleash Automation')).not.toBeInTheDocument(); - expect(screen.queryByText('Get Some Quick Wins')).not.toBeInTheDocument(); - expect(screen.queryByText('Hand Off to Cursor Cloud Agents')).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx deleted file mode 100644 index 12bd76ff0f93ca..00000000000000 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ /dev/null @@ -1,666 +0,0 @@ -import {Fragment, useCallback} from 'react'; -import styled from '@emotion/styled'; -import {useQuery} from '@tanstack/react-query'; -import {AnimatePresence, motion} from 'framer-motion'; - -import addIntegrationProvider from 'sentry-images/spot/add-integration-provider.svg'; -import alertsEmptyStateImg from 'sentry-images/spot/alerts-empty-state.svg'; -import feedbackOnboardingImg from 'sentry-images/spot/feedback-onboarding.svg'; -import onboardingCompass from 'sentry-images/spot/onboarding-compass.svg'; -import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg'; - -import {Alert} from '@sentry/scraps/alert'; -import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Stack, type FlexProps, type StackProps} from '@sentry/scraps/layout'; -import {ExternalLink, Link} from '@sentry/scraps/link'; - -import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; -import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences'; -import {StarFixabilityViewButton} from 'sentry/components/events/autofix/seerCreateViewButton'; -import {CodingAgentProvider} from 'sentry/components/events/autofix/types'; -import { - organizationIntegrationsCodingAgents, - useAutofixRepos, -} from 'sentry/components/events/autofix/useAutofix'; -import { - GuidedSteps, - useGuidedStepsContext, -} from 'sentry/components/guidedSteps/guidedSteps'; -import {IconChevron, IconSeer} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; -import type {Project} from 'sentry/types/project'; -import {FieldKey} from 'sentry/utils/fields'; -import {useDetailedProject} from 'sentry/utils/project/useDetailedProject'; -import {useUpdateProject} from 'sentry/utils/project/useUpdateProject'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useHasIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useHasIssueViews'; -import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; - -interface SeerNoticesProps { - groupId: string; - project: Project; - hasGithubIntegration?: boolean; -} - -function CustomSkipButton({...props}: Partial>) { - const {currentStep, setCurrentStep, totalSteps} = useGuidedStepsContext(); - - if (currentStep >= totalSteps) { - return null; - } - - const handleSkip = () => { - setCurrentStep(currentStep + 1); - }; - - return ( - - ); -} - -function CustomStepButtons({ - showBack, - showNext, - showSkip, - onSkip, - children, -}: { - showBack: boolean; - showNext: boolean; - showSkip: boolean; - children?: React.ReactNode; - onSkip?: () => void; -}) { - return ( - - {showBack && } - {showNext && } - {showSkip && ( - - )} - {children} - - ); -} - -export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNoticesProps) { - const organization = useOrganization(); - const {repos} = useAutofixRepos(groupId); - const {data, isLoading: isLoadingPreferences} = useProjectSeerPreferences(project); - const {preference, code_mapping_repos: codeMappingRepos} = data ?? {}; - const {mutate: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); - const {mutateAsync: updateProjectAutomation} = useUpdateProject(project); - const {data: codingAgentIntegrations} = useQuery( - organizationIntegrationsCodingAgents(organization) - ); - const {starredViews: views} = useStarredIssueViews(); - - const {data: projectDetails, isPending: isLoadingProject} = useDetailedProject({ - orgSlug: organization.slug, - projectSlug: project.slug, - }); - - const hasIssueViews = useHasIssueViews(); - const isStarredViewAllowed = hasIssueViews; - - const cursorIntegration = codingAgentIntegrations?.integrations.find( - integration => integration.provider === 'cursor' - ); - const isCursorHandoffConfigured = Boolean(preference?.automation_handoff); - - const unreadableRepos = repos.filter(repo => repo.is_readable === false); - const githubRepos = unreadableRepos.filter(repo => repo.provider.includes('github')); - const nonGithubRepos = unreadableRepos.filter( - repo => !repo.provider.includes('github') - ); - - // Onboarding conditions - const needsGithubIntegration = !hasGithubIntegration; - const needsRepoSelection = - repos.length === 0 && !preference?.repositories?.length && !codeMappingRepos?.length; - const needsAutomation = - projectDetails !== undefined && - (projectDetails.autofixAutomationTuning === 'off' || - projectDetails.autofixAutomationTuning === undefined || - projectDetails.seerScannerAutomation === false); - const needsFixabilityView = - !views.some(view => view.query.includes(FieldKey.ISSUE_SEER_ACTIONABILITY)) && - isStarredViewAllowed; - - // Warning conditions - const hasMultipleUnreadableRepos = unreadableRepos.length > 1; - const hasSingleUnreadableRepo = unreadableRepos.length === 1; - - // Use localStorage for collapsed state and cursor step skip - const [stepsCollapsed, setStepsCollapsed] = useLocalStorageState( - `seer-onboarding-collapsed:${project.id}`, - false - ); - const [cursorStepSkipped, setCursorStepSkipped] = useLocalStorageState( - `seer-onboarding-cursor-skipped:${project.id}`, - false - ); - - const needsCursorIntegration = - (!isCursorHandoffConfigured || !cursorIntegration) && !cursorStepSkipped; - - // Calculate incomplete steps - const stepConditions = [ - needsGithubIntegration, - needsRepoSelection, - needsAutomation, - needsFixabilityView, - needsCursorIntegration, - ]; - - const handleSetupCursorHandoff = async () => { - if (!cursorIntegration?.id || !projectDetails) { - return; - } - - const isAutomationDisabled = - projectDetails.seerScannerAutomation === false || - projectDetails.autofixAutomationTuning === 'off'; - - if (isAutomationDisabled) { - await updateProjectAutomation({ - autofixAutomationTuning: 'low', - seerScannerAutomation: true, - }); - } - - updateProjectSeerPreferences({ - repositories: preference?.repositories || [], - automated_run_stopping_point: 'root_cause', - automation_handoff: { - handoff_point: 'root_cause', - target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, - integration_id: parseInt(cursorIntegration.id, 10), - }, - }); - }; - - const handleSkipCursorStep = useCallback(() => { - setCursorStepSkipped(true); - setStepsCollapsed(true); - }, [setCursorStepSkipped, setStepsCollapsed]); - const incompleteStepIndices = stepConditions - .map((needed, idx) => (needed ? idx : null)) - .filter(idx => idx !== null); - const firstIncompleteIdx = incompleteStepIndices[0]; - const lastIncompleteIdx = incompleteStepIndices[incompleteStepIndices.length - 1]; - const anyStepIncomplete = incompleteStepIndices.length > 0; - const showOnboardingSteps = - !isLoadingPreferences && !isLoadingProject && anyStepIncomplete; - const showCollapsedSummary = showOnboardingSteps && stepsCollapsed; - const showFullGuidedSteps = showOnboardingSteps && !stepsCollapsed; - - return ( - - {/* Collapsed summary */} - {showCollapsedSummary && ( - setStepsCollapsed(false)}> - - - {t( - 'Only %s step%s left to get the most out of Seer.', - incompleteStepIndices.length, - incompleteStepIndices.length === 1 ? '' : 's' - )} - - - - )} - {/* Full guided steps */} - {showFullGuidedSteps && ( - - - - - Debug Faster with Seer - - - {/* Step 1: GitHub Integration */} - - - - - - {tct( - 'Seer is [bold:a lot better] when it has your codebase as context.', - { - bold: , - } - )} - - - {tct( - 'Set up the [integrationLink:GitHub Integration] to allow Seer to find the most accurate root causes, solutions, and code changes for your issues.', - { - integrationLink: ( - - ), - } - )} - - - {tct( - 'Support for other source code providers are coming soon. You can keep up with progress on these GitHub issues: [bitbucketLink:BitBucket], [gitlabLink:GitLab], and [azureDevopsLink:Azure DevOps].', - { - bitbucketLink: ( - - ), - gitlabLink: ( - - ), - azureDevopsLink: ( - - ), - } - )} - - - - - - - - - - {t('Set Up Integration')} - - - - - {/* Step 2: Repo Selection */} - - - - - - {t('Select the repos Seer can explore in this project.')} - - - {t( - 'You can also configure working branches and custom instructions so Seer fits your unique workflow.' - )} - - - - - - - - setStepsCollapsed(true)} - > - - {t('Configure Repos')} - - - - - {/* Step 3: Unleash Automation */} - - - - - - {t( - 'Let Seer automatically deep dive into incoming issues, so you wake up to solutions, not headaches.' - )} - - - - - - - - setStepsCollapsed(true)} - > - - {t('Enable Automation')} - - - - - {/* Step 4: Fixability View */} - {isStarredViewAllowed && ( - - - - - - {t( - 'Seer scans all your issues and highlights the ones that are likely quick to fix.' - )} - - - {t( - 'Star the recommended issue view to keep an eye on quick debugging opportunities. You can customize the view later.' - )} - - - - - - - - setStepsCollapsed(true)} - > - - - - )} - - {/* Step 5: Cursor Integration */} - - - - - {t('Hand Off to Cursor Cloud Agents')} - - } - isCompleted={!needsCursorIntegration} - > - - - - {cursorIntegration ? ( - - - {t( - 'Enable Seer automation and set up handoff to Cursor Cloud Agents when Seer identifies a root cause.' - )} - - - {tct( - 'During automation, Seer will trigger Cursor Cloud Agents to generate and submit pull requests directly to your repos. Configure in [seerProjectSettings:Seer project settings] or [docsLink:read the docs] to learn more.', - { - seerProjectSettings: ( - - ), - docsLink: ( - - ), - } - )} - - - ) : ( - - - {t( - 'Connect Cursor to automatically hand off Seer root cause analysis to Cursor Cloud Agents for seamless code fixes.' - )} - - - {tct( - 'Set up the [integrationLink:Cursor Integration] to enable automatic handoff. [docsLink:Read the docs] to learn more.', - { - integrationLink: ( - - ), - docsLink: ( - - ), - } - )} - - - )} - - - - - - - - {cursorIntegration ? ( - - ) : ( - - {t('Install Cursor Integration')} - - )} - - - - - - - )} - {/* Banners for unreadable repos */} - {hasMultipleUnreadableRepos && ( - - {tct("Seer can't access these repositories: [repoList].", { - repoList: {unreadableRepos.map(repo => repo.name).join(', ')}, - })} - {githubRepos.length > 0 && ( - - {' '} - {tct( - 'For best performance, enable the [integrationLink:GitHub integration].', - { - integrationLink: ( - - ), - } - )} - - )} - {nonGithubRepos.length > 0 && ( - {t('Seer currently only supports GitHub repositories.')} - )} - - )} - {hasSingleUnreadableRepo && ( - - {unreadableRepos[0]?.provider.includes('github') - ? tct( - "Seer can't access the [repo] repository, make sure the [integrationLink:GitHub integration] is correctly set up.", - { - repo: {unreadableRepos[0]?.name}, - integrationLink: ( - - ), - } - ) - : tct( - "Seer can't access the [repo] repository. It currently only supports GitHub repositories.", - {repo: {unreadableRepos[0]?.name}} - )} - - )} - - ); -} - -const StyledGuidedSteps = styled(GuidedSteps)` - background: transparent; -`; - -const StyledAlert = styled(Alert)` - margin-bottom: ${p => p.theme.space.xl}; -`; - -function CardDescription(props: StackProps) { - return ( - - {props.children} - - ); -} - -const CardIllustration = styled('img')` - width: 100%; - max-width: 200px; - min-width: 100px; - height: auto; - object-fit: contain; - margin-bottom: -6px; - margin-right: 10px; -`; - -const CursorCardIllustration = styled(CardIllustration)` - max-width: 160px; -`; - -const CursorPluginIcon = styled('div')` - transform: translateY(3px); -`; - -function StepContentRow(props: FlexProps) { - return ( - - {props.children} - - ); -} - -function StepTextCol(props: StackProps) { - return ( - - {props.children} - - ); -} - -function StepImageCol(props: FlexProps) { - return ( - - {props.children} - - ); -} - -const StepsHeader = styled('h3')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - font-size: ${p => p.theme.font.size.xl}; - margin-bottom: ${p => p.theme.space.xs}; - margin-left: 1px; -`; - -const StepsDivider = styled('hr')` - border: none; - border-top: 1px solid ${p => p.theme.tokens.border.primary}; - margin: ${p => p.theme.space['2xl']} 0; -`; - -const CollapsedSummaryCard = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.md}; - background: ${p => p.theme.colors.pink500}10; - border: 1px solid ${p => p.theme.tokens.border.primary}; - border-radius: 6px; - padding: ${p => p.theme.space.md}; - margin-bottom: ${p => p.theme.space.xl}; - cursor: pointer; - font-size: ${p => p.theme.font.size.md}; - font-weight: 500; - color: ${p => p.theme.tokens.content.primary}; - transition: box-shadow 0.2s; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); - &:hover { - background: ${p => p.theme.colors.pink500}20; - } -`; diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx deleted file mode 100644 index a6478e0163e1e2..00000000000000 --- a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; -import {EventFixture} from 'sentry-fixture/event'; -import {FrameFixture} from 'sentry-fixture/frame'; -import {GroupFixture} from 'sentry-fixture/group'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {DetailedProjectFixture} from 'sentry-fixture/project'; - -import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {EntryType} from 'sentry/types/event'; -import {IssueCategory, type Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {SeerSection} from 'sentry/views/issueDetails/streamline/sidebar/seerSection'; - -jest.mock('sentry/utils/regions'); - -describe('SeerSection', () => { - const mockEvent = EventFixture({ - entries: [ - { - type: EntryType.EXCEPTION, - data: {values: [{stacktrace: {frames: [FrameFixture()]}}]}, - }, - ], - }); - let mockGroup!: ReturnType; - const mockProject = DetailedProjectFixture(); - const organization = OrganizationFixture({ - hideAiFeatures: false, - features: ['gen-ai-features'], - }); - - beforeEach(() => { - mockGroup = GroupFixture(); - MockApiClient.clearMockResponses(); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, - body: {steps: []}, - }); - }); - - it('renders summary when AI features are enabled and data is available', async () => { - const mockWhatHappened = 'This is a test what happened'; - const mockTrace = 'This is a test trace'; - const mockCause = 'This is a test cause'; - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {possibleCause: mockCause, whatsWrong: mockWhatHappened, trace: mockTrace}, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByText(mockCause)).toBeInTheDocument(); - }); - expect(screen.queryByText(mockWhatHappened)).not.toBeInTheDocument(); - expect(screen.queryByText(mockTrace)).not.toBeInTheDocument(); - }); - - it('renders resources section when AI features are disabled', () => { - const customOrganization = OrganizationFixture({ - hideAiFeatures: true, - features: ['gen-ai-features'], - }); - - const disabledIssueSummaryGroup: Group = { - ...mockGroup, - issueCategory: IssueCategory.PERFORMANCE, - title: 'ChunkLoadError', - platform: 'javascript', - }; - - const javascriptProject: Project = {...mockProject, platform: 'javascript'}; - - render( - , - {organization: customOrganization} - ); - - expect(screen.getByText('Resources')).toBeInTheDocument(); - - expect( - screen.getByRole('button', {name: 'How to fix ChunkLoadErrors'}) - ).toBeInTheDocument(); - }); - - describe('Seer button text', () => { - it('shows issue summary and "Fix with Seer" when consent flow is removed and there is no autofix quota', async () => { - const orgWithConsentFlowRemoved = OrganizationFixture({ - hideAiFeatures: false, - features: ['gen-ai-features'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - billing: {hasAutofixQuota: false}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {whatsWrong: 'Test summary', possibleCause: 'You did it wrong'}, - }); - - render(, { - organization: orgWithConsentFlowRemoved, - }); - - await waitFor(() => { - expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); - }); - - expect(screen.getByText(/initial guess/i)).toBeInTheDocument(); - // Should show issue summary - expect(await screen.findByText('You did it wrong')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Fix with Seer'})).toBeInTheDocument(); - }); - - it('shows "Find Root Cause" even when autofix needs setup', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: false, reason: null}, - githubWriteIntegration: {ok: false, repos: []}, - }), - }); - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {whatsWrong: 'Test summary'}, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); - }); - - expect(screen.getByRole('button', {name: 'Find Root Cause'})).toBeInTheDocument(); - }); - - it('shows "Find Root Cause" when autofix is available', async () => { - // Mock successful autofix setup but disable resources - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, - method: 'POST', - body: {whatsWrong: 'Test summary'}, - }); - - render(, { - organization, - }); - - await waitFor(() => { - expect(screen.getByRole('button', {name: 'Find Root Cause'})).toBeInTheDocument(); - }); - }); - - it('shows resource link when available', () => { - const disabledIssueSummaryGroup: Group = { - ...mockGroup, - issueCategory: IssueCategory.PERFORMANCE, - title: 'ChunkLoadError', - platform: 'javascript', - }; - - const javascriptProject: Project = {...mockProject, platform: 'javascript'}; - - // Mock config with autofix disabled - MockApiClient.addMockResponse({ - url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, - body: AutofixSetupFixture({ - integration: {ok: true, reason: null}, - githubWriteIntegration: {ok: true, repos: []}, - }), - }); - - render( - , - {organization} - ); - - expect( - screen.getByRole('button', {name: 'How to fix ChunkLoadErrors'}) - ).toBeInTheDocument(); - }); - }); -}); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx deleted file mode 100644 index f405b1d327cd35..00000000000000 --- a/static/app/views/issueDetails/streamline/sidebar/seerSection.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import styled from '@emotion/styled'; - -import autofixSetupImg from 'sentry-images/features/autofix-setup.svg'; - -import {Stack} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; - -import {GroupSummary} from 'sentry/components/group/groupSummary'; -import {GroupSummaryWithAutofix} from 'sentry/components/group/groupSummaryWithAutofix'; -import {Placeholder} from 'sentry/components/placeholder'; -import {IconSeer} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; -import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; -import {SidebarFoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; -import {Resources} from 'sentry/views/issueDetails/streamline/sidebar/resources'; - -import {SeerSectionCtaButton} from './seerSectionCtaButton'; - -function SeerWelcomeEntrypoint() { - return ( - - - {t('Meet Seer, the AI debugging agent.')} - - - Seer AI debugging agent - - - - {t( - 'Find the root cause of the issue, and even open a PR to fix it, in minutes.' - )} - - - - ); -} - -function SeerSectionContent({ - group, - project, - event, -}: { - event: Event | undefined; - group: Group; - project: Project; -}) { - const aiConfig = useAiConfig(group, project); - - if (!event && !aiConfig.isAutofixSetupLoading) { - return {t('No event to analyze.')}; - } - if (!event || aiConfig.isAutofixSetupLoading) { - return ; - } - - if (aiConfig.hasSummary) { - if (aiConfig.hasAutofix) { - return ( - - - - ); - } - - return ( - - - - ); - } - - return null; -} - -export function SeerSection({ - group, - project, - event, -}: { - event: Event | undefined; - group: Group; - project: Project; -}) { - const aiConfig = useAiConfig(group, project); - const issueTypeConfig = getConfigForIssueType(group, project); - - const issueTypeDoesntHaveSeer = - !issueTypeConfig.autofix && !issueTypeConfig.issueSummary; - - if ( - (!aiConfig.areAiFeaturesAllowed || issueTypeDoesntHaveSeer) && - !aiConfig.hasResources - ) { - return null; - } - - const showCtaButton = - aiConfig.orgNeedsGenAiAcknowledgement || - aiConfig.hasAutofix || - (aiConfig.hasSummary && aiConfig.hasResources); - - const onlyHasResources = - issueTypeDoesntHaveSeer || - (!aiConfig.orgNeedsGenAiAcknowledgement && - !aiConfig.hasSummary && - !aiConfig.hasAutofix && - aiConfig.hasResources); - - const titleComponent = onlyHasResources ? ( - {t('Resources')} - ) : ( - - {t('Seer Autofix')} - - - ); - - // Determine what content to show in the section body - const renderSectionContent = () => { - // Welcome entrypoint for orgs that need consent - if (aiConfig.orgNeedsGenAiAcknowledgement && !aiConfig.isAutofixSetupLoading) { - return ; - } - - // Default: show group summary - if (aiConfig.hasAutofix || aiConfig.hasSummary) { - return ; - } - - // Resources only - if (issueTypeConfig.resources) { - return ( - - - - - - ); - } - - return null; - }; - - return ( - - - {renderSectionContent()} - {event && showCtaButton && ( - - )} - - - ); -} - -const Summary = styled('div')` - margin-bottom: ${p => p.theme.space.xs}; - position: relative; -`; - -const ResourcesWrapper = styled('div')` - position: relative; - margin-bottom: ${p => p.theme.space.md}; -`; - -const ResourcesContent = styled('div')` - position: relative; - padding-bottom: ${p => p.theme.space.xl}; -`; - -const HeaderContainer = styled('div')` - font-size: ${p => p.theme.font.size.md}; - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; -`; - -const StyledP = styled('p')` - margin-bottom: ${p => p.theme.space.md}; -`; - -const WelcomeContainer = styled('div')` - margin-bottom: ${p => p.theme.space.lg}; -`; - -const WelcomeImageContainer = styled('div')` - margin-bottom: ${p => p.theme.space.lg}; - margin-top: ${p => p.theme.space.lg}; - - img { - max-width: 100%; - height: auto; - } -`; diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx deleted file mode 100644 index 1b46d40de942a1..00000000000000 --- a/static/app/views/issueDetails/streamline/sidebar/seerSectionCtaButton.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import {useEffect, useRef} from 'react'; -import styled from '@emotion/styled'; -// eslint-disable-next-line no-restricted-imports -import color from 'color'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; - -import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import { - AutofixStatus, - AutofixStepType, - type AutofixStep, -} from 'sentry/components/events/autofix/types'; -import {useAiAutofix, useAutofixData} from 'sentry/components/events/autofix/useAutofix'; -import { - getAutofixRunExists, - getCodeChangesDescription, - getRootCauseDescription, - getSolutionDescription, - hasPullRequest, -} from 'sentry/components/events/autofix/utils'; -import {useGroupSummaryData} from 'sentry/components/group/groupSummary'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Placeholder} from 'sentry/components/placeholder'; -import {IconChevron} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {Project} from 'sentry/types/project'; -import {useLocation} from 'sentry/utils/useLocation'; -import type {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; -import {useOpenSeerDrawer} from 'sentry/views/issueDetails/streamline/sidebar/seerDrawer'; - -interface Props { - aiConfig: ReturnType; - event: Event; - group: Group; - hasStreamlinedUI: boolean; - project: Project; -} - -export function SeerSectionCtaButton({ - aiConfig, - event, - group, - project, - hasStreamlinedUI, -}: Props) { - const location = useLocation(); - const seerLink = { - pathname: location.pathname, - query: { - ...location.query, - seerDrawer: true, - }, - }; - - const openButtonRef = useRef(null); - const isDrawerOpenRef = useRef(false); - - const {isPending: isAutofixPending} = useAutofixData({groupId: group.id}); - const {autofixData} = useAiAutofix(group, event, { - isSidebar: !isDrawerOpenRef.current, - pollInterval: 1500, - }); - - const {data: summaryData, isPending: isSummaryPending} = useGroupSummaryData(group); - - const {openSeerDrawer} = useOpenSeerDrawer({ - group, - project, - event, - buttonRef: openButtonRef, - }); - - // Keep isDrawerOpenRef in sync with the Seer drawer state (based on URL query) - useEffect(() => { - isDrawerOpenRef.current = !!location.query.seerDrawer; - }, [location.query.seerDrawer]); - - // Keep track of previous steps to detect state transitions and notify the user - const prevStepsRef = useRef(null); - const prevRunIdRef = useRef(null); - useEffect(() => { - if (isDrawerOpenRef.current) { - return; - } - - if (!autofixData?.steps || !prevStepsRef.current) { - prevStepsRef.current = autofixData?.steps ?? null; - prevRunIdRef.current = autofixData?.run_id ?? null; - return; - } - - const prevSteps = prevStepsRef.current; - const currentSteps = autofixData.steps; - - // Don't show notifications if the run_id has changed - if ( - prevStepsRef.current !== currentSteps && - autofixData?.run_id !== prevRunIdRef.current - ) { - prevStepsRef.current = currentSteps; - prevRunIdRef.current = autofixData?.run_id; - return; - } - - // Find the most recent step - const processingStep = currentSteps.findLast( - step => step.type === AutofixStepType.DEFAULT - ); - - if (processingStep?.status === AutofixStatus.COMPLETED) { - // Check if this is a new completion (wasn't completed in previous state) - const prevProcessingStep = prevSteps.findLast( - step => step.type === AutofixStepType.DEFAULT - ); - if (prevProcessingStep && prevProcessingStep.status !== AutofixStatus.COMPLETED) { - if (currentSteps.some(step => step.type === AutofixStepType.CHANGES)) { - addSuccessMessage(t('Seer has finished coding.')); - } else if (currentSteps.some(step => step.type === AutofixStepType.SOLUTION)) { - addSuccessMessage(t('Seer has found a solution.')); - } else if ( - currentSteps.some(step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) - ) { - addSuccessMessage(t('Seer has found the root cause.')); - } - } - } - - prevStepsRef.current = autofixData?.steps ?? null; - prevRunIdRef.current = autofixData?.run_id ?? null; - }, [autofixData?.steps, autofixData?.run_id]); - - // Update drawer state when opening - const handleOpenDrawer = () => { - openSeerDrawer(); - }; - - const showCtaButton = - aiConfig.orgNeedsGenAiAcknowledgement || - aiConfig.hasAutofix || - (aiConfig.hasSummary && aiConfig.hasResources); - const isButtonLoading = - aiConfig.isAutofixSetupLoading || (isAutofixPending && getAutofixRunExists(group)); - - const lastStep = autofixData?.steps?.[autofixData.steps.length - 1]; - const isAutofixInProgress = lastStep?.status === AutofixStatus.PROCESSING; - const isAutofixCompleted = lastStep?.status === AutofixStatus.COMPLETED; - const isAutofixWaitingForUser = - autofixData?.status === AutofixStatus.WAITING_FOR_USER_RESPONSE; - - const hasStepType = (type: AutofixStepType) => - autofixData?.steps?.some(step => step.type === type); - - const rootCauseDescription = autofixData ? getRootCauseDescription(autofixData) : null; - const solutionDescription = autofixData ? getSolutionDescription(autofixData) : null; - const codeChangesDescription = autofixData - ? getCodeChangesDescription(autofixData) - : null; - const hasPr = hasPullRequest(autofixData); - - const getButtonText = () => { - if (!aiConfig.hasAutofix) { - return t('Open Resources'); - } - - if ( - (aiConfig.orgNeedsGenAiAcknowledgement || !aiConfig.hasAutofixQuota) && - !aiConfig.isAutofixSetupLoading - ) { - return t('Fix with Seer'); - } - - if (!lastStep) { - return t('Find Root Cause'); - } - - if (isAutofixWaitingForUser) { - return t('Waiting for Your Input'); - } - - if (isAutofixInProgress) { - if (!hasStepType(AutofixStepType.ROOT_CAUSE_ANALYSIS)) { - return t('Finding Root Cause'); - } - if (!hasStepType(AutofixStepType.SOLUTION)) { - return t('Finding Solution'); - } - if (!hasStepType(AutofixStepType.CHANGES)) { - return t('Writing Code'); - } - } - - if (isAutofixCompleted) { - if (lastStep.type === AutofixStepType.SOLUTION) { - return t('Fix with Seer'); - } - return t('Open Autofix'); - } - - return t('Fix with Seer'); - }; - - if (isButtonLoading) { - return ; - } - - if (!showCtaButton) { - return null; - } - - return ( - - {getButtonText()} - - {isAutofixInProgress ? ( - - ) : ( - - )} - - - ); -} - -const StyledButton = styled(LinkButton)` - margin-top: ${p => p.theme.space.md}; - width: 100%; -`; - -const StyledLoadingIndicator = styled(LoadingIndicator)` - position: relative; - margin-left: ${p => p.theme.space.md}; - - .loading-indicator { - border-color: ${p => color(p.theme.colors.white).alpha(0.35).string()}; - border-left-color: ${p => p.theme.colors.white}; - } -`; - -const ButtonPlaceholder = styled(Placeholder)` - width: 100%; - height: 38px; - border-radius: ${p => p.theme.radius.md}; - margin-top: ${p => p.theme.space.md}; -`; diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx index 98754fa1faef92..5c150d059959fe 100644 --- a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx @@ -67,6 +67,16 @@ describe('StreamlinedSidebar', () => { body: {steps: []}, }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/onboarding-check/`, + body: { + hasSupportedScmIntegration: true, + isAutofixEnabled: true, + isCodeReviewEnabled: true, + isSeerConfigured: true, + }, + }); + mockFirstLastRelease = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/${group.id}/first-last-release/`, method: 'GET', diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx index 75b4a7aef1e3b6..63e08407f08588 100644 --- a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx @@ -28,7 +28,6 @@ import {ExternalIssueSidebarList} from 'sentry/views/issueDetails/streamline/sid import {FirstLastSeenSection} from 'sentry/views/issueDetails/streamline/sidebar/firstLastSeenSection'; import {MergedIssuesSidebarSection} from 'sentry/views/issueDetails/streamline/sidebar/mergedSidebarSection'; import {PeopleSection} from 'sentry/views/issueDetails/streamline/sidebar/peopleSection'; -import {SeerSection} from 'sentry/views/issueDetails/streamline/sidebar/seerSection'; import {SimilarIssuesSidebarSection} from 'sentry/views/issueDetails/streamline/sidebar/similarIssuesSidebarSection'; import {SupergroupSection} from 'sentry/views/issueDetails/streamline/sidebar/supergroupSection'; @@ -96,11 +95,7 @@ export function StreamlinedSidebar({group, event, project}: Props) { {showSeerSection && ( - {organization.features.includes('autofix-on-explorer') ? ( - - ) : ( - - )} + )} {event && ( diff --git a/static/app/views/issueList/pages/autofix/recentlyRun.tsx b/static/app/views/issueList/pages/autofix/recentlyRun.tsx index 1f538bacf8f303..e6f9e31d9db119 100644 --- a/static/app/views/issueList/pages/autofix/recentlyRun.tsx +++ b/static/app/views/issueList/pages/autofix/recentlyRun.tsx @@ -1,4 +1,3 @@ -import Feature from 'sentry/components/acl/feature'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import {t} from 'sentry/locale'; @@ -13,18 +12,16 @@ export default function AutofixRecentlyRunPage() { const organization = useOrganization(); return ( - - - - - - - - - + + + + + + + ); } diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx index b232114def5e9a..881c86cb3fef15 100644 --- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx +++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx @@ -65,24 +65,20 @@ export function IssuesSecondaryNavigation() { - {organization.features.includes('autofix-on-explorer') && ( - - - - - - - {t('Recently Run')} - - - - - - )} + + + + + + {t('Recently Run')} + + + + diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx index cb570b706bbfff..023e14a6aba1f2 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx @@ -43,6 +43,7 @@ import { IconJson, IconPanel, IconProfiling, + IconTerminal, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Event, EventTransaction} from 'sentry/types/event'; @@ -51,6 +52,7 @@ import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getDuration} from 'sentry/utils/duration/getDuration'; +import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -64,6 +66,7 @@ import { makeTraceContinuousProfilingLink, makeTransactionProfilingLink, } from 'sentry/views/performance/newTraceDetails/traceDrawer/traceProfilingLink'; +import {isEAPSpanNode} from 'sentry/views/performance/newTraceDetails/traceGuards'; import type {BaseNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/baseNode'; import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import { @@ -1013,6 +1016,16 @@ function NodeActions(props: { /> ) : null} + {isActiveSuperuser() && isEAPSpanNode(props.node) && params.traceSlug ? ( + + } + /> + + ) : null} {continuousProfileTarget ? ( ; - } - return ; -} - -function AutofixConfigureQuota() { - const organization = useOrganization(); - const subscription = useSubscription(); - return ( - - - - - - - {t('Meet Seer')} - - - {t( - 'Debug faster with Seer. It will connect to your repositories, scan all of your issues, highlight the ones that are quick to fix, and propose solutions. You can even integrate with your favorite coding agent to implement changes in code. ' - )} - - - {hasAccessToSubscriptionOverview(subscription, organization) ? ( - } - > - {t('Try Out Seer Now')} - - ) : ( - - {t( - 'You need to be a billing member to try out Seer. Please contact your organization owner to upgrade your plan.' - )} - - )} - - - - - - - - - - - - - - {t('Root Cause Analysis & Code Fixes')} - - {t( - 'Seer analyzes the root cause of an issue and propose fixes ready to merge as draft PRs.' - )} - - - - - - - - - - - {t('AI Code Review')} - {t('Seer catches bugs in your PRs before you ship them.')} - - - - - - - ); -} - -const HeroImage = styled(ImageBase)` - position: absolute; - z-index: ${p => p.theme.zIndex.initial}; - min-width: 150%; - left: 50%; - transform: translateX(-47%) translateY(-35%); -`; - -const Image = styled(ImageBase)<{alignSelf?: CSSProperties['alignSelf']}>` - align-self: ${p => p.alignSelf ?? 'center'}; -`; - -const MeetSeerPanel = styled(Panel)` - margin-top: 32%; -`; diff --git a/static/gsApp/registerOverrides.tsx b/static/gsApp/registerOverrides.tsx index 431cc1cd48818a..e746420eac73c5 100644 --- a/static/gsApp/registerOverrides.tsx +++ b/static/gsApp/registerOverrides.tsx @@ -7,7 +7,6 @@ import type {Overrides} from 'sentry/types/overrides'; import type {OrganizationStatsProps} from 'sentry/views/organizationStats'; import {AiConfigureSeerQuotaSidebar} from 'getsentry/components/ai/aiConfigureSeerQuotaSidebar'; -import {AiSetupConfiguration} from 'getsentry/components/ai/aiSetupConfiguration'; import {AiSetupDataConsent} from 'getsentry/components/ai/AiSetupDataConsent'; import CronsBillingBanner from 'getsentry/components/crons/cronsBillingBanner'; import {DashboardBanner} from 'getsentry/components/dashboardBanner'; @@ -236,7 +235,6 @@ const GETSENTRY_OVERRIDES: Partial = { 'component:insights-date-range-query-limit-footer': () => InsightsDateRangeQueryLimitFooter, 'component:ai-configure-seer-quota-sidebar': () => AiConfigureSeerQuotaSidebar, - 'component:ai-setup-configuration': () => AiSetupConfiguration, 'component:ai-setup-data-consent': () => AiSetupDataConsent, 'component:codecov-integration-settings-link': () => CodecovSettingsLink, 'component:continuous-profiling-billing-requirement-banner': () => diff --git a/tests/js/fixtures/autofixCodebaseChangeData.ts b/tests/js/fixtures/autofixCodebaseChangeData.ts deleted file mode 100644 index c37cce7dd29c13..00000000000000 --- a/tests/js/fixtures/autofixCodebaseChangeData.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {AutofixDiffFilePatch} from 'sentry-fixture/autofixDiffFilePatch'; - -import type {AutofixCodebaseChange} from 'sentry/components/events/autofix/types'; - -export function AutofixCodebaseChangeData( - params: Partial = {} -): AutofixCodebaseChange { - return { - description: '', - diff: [AutofixDiffFilePatch()], - repo_external_id: '100', - repo_name: 'owner/hello-world', - title: 'Add error handling', - pull_request: { - pr_number: 200, - pr_url: 'https://github.com/owner/hello-world/pull/200', - }, - ...params, - }; -} diff --git a/tests/js/fixtures/autofixData.ts b/tests/js/fixtures/autofixData.ts deleted file mode 100644 index aab7f33610ceb4..00000000000000 --- a/tests/js/fixtures/autofixData.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type {AutofixData} from 'sentry/components/events/autofix/types'; -import {AutofixStatus} from 'sentry/components/events/autofix/types'; - -export function AutofixDataFixture(params: Partial): AutofixData { - return { - run_id: '1', - status: AutofixStatus.PROCESSING, - completed_at: '', - last_triggered_at: '', - steps: [], - request: { - repos: [], - }, - codebases: {}, - ...params, - }; -} diff --git a/tests/js/fixtures/autofixDiffFilePatch.ts b/tests/js/fixtures/autofixDiffFilePatch.ts deleted file mode 100644 index 71b92ed516c9e7..00000000000000 --- a/tests/js/fixtures/autofixDiffFilePatch.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type {FilePatch} from 'sentry/components/events/autofix/types'; -import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types'; - -export function AutofixDiffFilePatch(params: Partial = {}): FilePatch { - return { - added: 1, - path: 'src/sentry/processing/backpressure/memory.py', - removed: 1, - source_file: 'src/sentry/processing/backpressure/memory.py', - target_file: 'src/sentry/processing/backpressure/memory.py', - type: DiffFileType.MODIFIED, - hunks: [ - { - lines: [ - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 6, - source_line_no: 47, - target_line_no: 47, - value: ' # or alternatively: `used_memory_rss`?\n', - }, - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 7, - source_line_no: 48, - target_line_no: 48, - value: ' memory_used = info.get("used_memory", 0)\n', - }, - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 8, - source_line_no: 49, - target_line_no: 49, - value: ' # `maxmemory` might be 0 in development\n', - }, - { - line_type: DiffLineType.REMOVED, - diff_line_no: 9, - source_line_no: 50, - target_line_no: null, - value: - ' memory_available = info.get("maxmemory", 0) or info["total_system_memory"]\n', - }, - { - line_type: DiffLineType.ADDED, - diff_line_no: 10, - source_line_no: null, - target_line_no: 50, - value: - ' memory_available = info.get("maxmemory", 0) or info.get("total_system_memory", 0)\n', - }, - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 11, - source_line_no: 51, - target_line_no: 51, - value: '\n', - }, - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 12, - source_line_no: 52, - target_line_no: 52, - value: ' return ServiceMemory(node_id, memory_used, memory_available)\n', - }, - { - line_type: DiffLineType.CONTEXT, - diff_line_no: 13, - source_line_no: 53, - target_line_no: 53, - value: '\n', - }, - ], - section_header: - 'def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory:', - source_length: 7, - source_start: 47, - target_length: 7, - target_start: 47, - }, - ], - ...params, - }; -} diff --git a/tests/js/fixtures/autofixProgressItem.ts b/tests/js/fixtures/autofixProgressItem.ts deleted file mode 100644 index 8544ae9c04ed85..00000000000000 --- a/tests/js/fixtures/autofixProgressItem.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type {AutofixProgressItem} from 'sentry/components/events/autofix/types'; - -export function AutofixProgressItemFixture( - params: Partial -): AutofixProgressItem { - return { - message: 'Example log message', - timestamp: '2024-01-01T00:00:00', - type: 'INFO', - data: null, - ...params, - }; -} diff --git a/tests/js/fixtures/autofixRootCauseData.ts b/tests/js/fixtures/autofixRootCauseData.ts deleted file mode 100644 index bacaec4a95176e..00000000000000 --- a/tests/js/fixtures/autofixRootCauseData.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {AutofixRootCauseData} from 'sentry/components/events/autofix/types'; - -export function AutofixRootCauseData( - params: Partial = {} -): AutofixRootCauseData { - return { - id: '100', - root_cause_reproduction: [ - { - code_snippet_and_analysis: - 'This is the code snippet and analysis of a root cause.', - relevant_code_file: { - file_path: 'src/file.py', - repo_name: 'owner/repo', - }, - timeline_item_type: 'internal_code', - title: 'This is the title of a root cause.', - is_most_important_event: true, - }, - ], - ...params, - }; -} diff --git a/tests/js/fixtures/autofixStep.ts b/tests/js/fixtures/autofixStep.ts deleted file mode 100644 index 5b0b4545dcc1f3..00000000000000 --- a/tests/js/fixtures/autofixStep.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - AutofixDefaultStep, - AutofixStep, -} from 'sentry/components/events/autofix/types'; -import {AutofixStepType} from 'sentry/components/events/autofix/types'; - -export function AutofixStepFixture(params: Partial = {}): AutofixStep { - return { - type: AutofixStepType.DEFAULT, - id: '1', - index: 1, - title: 'I am processing', - status: 'PROCESSING', - progress: [], - insights: [], - ...params, - } as AutofixDefaultStep; -} diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py index 75ec4434eacb62..3a11f45f97f09a 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py @@ -176,6 +176,30 @@ def test_invalid_display_type(self) -> None: assert response.status_code == 400, response.data assert "displayType" in response.data, response.data + def test_unsupported_display_type_for_widget_type(self) -> None: + data = { + "title": "Table on preprod-app-size", + "displayType": "table", + "widgetType": "preprod-app-size", + "queries": [ + { + "name": "", + "conditions": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + } + ], + } + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + assert "preprod-app-size" in str(response.data["displayType"]) + def test_invalid_equation(self) -> None: data = { "title": "Invalid query", @@ -1442,7 +1466,7 @@ def test_widget_type_tracemetrics(self) -> None: data = { "title": "Test Metrics Query", "widgetType": "tracemetrics", - "displayType": "table", + "displayType": "line", "queries": [ { "name": "", @@ -1461,6 +1485,30 @@ def test_widget_type_tracemetrics(self) -> None: ) assert response.status_code == 200, response.data + def test_widget_type_tracemetrics_rejects_table(self) -> None: + data = { + "title": "Test Metrics Query", + "widgetType": "tracemetrics", + "displayType": "table", + "queries": [ + { + "name": "", + "conditions": "metric.name:foo", + "fields": ["sum(value)"], + "columns": [], + "aggregates": ["sum(value)"], + }, + ], + } + + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + def test_text_widget_without_feature_flag(self) -> None: data = { "title": "Text Widget Title", diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index d5c51ef1449870..459d9096cdd162 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -2169,6 +2169,92 @@ def test_post_with_text_widget(self) -> None: assert DashboardWidgetQuery.objects.filter(widget=text_widget).count() == 0 + def test_agents_traces_table_dashboard_save_and_update(self) -> None: + # Regression: the AI Agents Overview prebuilt config has an + # agents_traces_table widget without a widget_type. The backend defaults + # it to error-events on create. On the next PUT the frontend round-trips + # widget_type=error-events, which would otherwise fail validation. + data = { + "title": "AI Agents Overview", + "widgets": [ + { + "title": "Traces", + "displayType": "agents_traces_table", + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + create = self.do_request("post", self.url, data=data) + assert create.status_code == 201, create.data + dashboard_id = create.data["id"] + widget_id = create.data["widgets"][0]["id"] + widget_type = create.data["widgets"][0].get("widgetType") + + put_url = f"/api/0/organizations/{self.organization.slug}/dashboards/{dashboard_id}/" + put_data = { + "title": "AI Agents Overview", + "widgets": [ + { + "id": widget_id, + "title": "Traces", + "displayType": "agents_traces_table", + "widgetType": widget_type, + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + update = self.do_request("put", put_url, data=put_data) + assert update.status_code == 200, update.data + + def test_post_text_widget_after_restrictive_dataset_widget(self) -> None: + # Regression: DRF reuses a single child serializer for ``many=True``, + # so a previous widget's widget_type can leak via serializer context + # and incorrectly fail validation for a later TEXT widget. + with self.feature("organizations:dashboards-text-widgets"): + data = { + "title": "Dashboard from Post", + "widgets": [ + { + "title": "Mobile Size", + "displayType": "line", + "widgetType": "preprod-app-size", + "interval": "5m", + "queries": [ + { + "name": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + "conditions": "", + } + ], + }, + { + "title": "Text Widget", + "displayType": "text", + "description": "Notes", + }, + ], + } + response = self.do_request("post", self.url, data=data) + assert response.status_code == 201, response.data + def test_post_with_text_widget_without_feature_flag(self) -> None: data = { "title": "Dashboard from Post", diff --git a/tests/sentry/integrations/slack/test_unfurl.py b/tests/sentry/integrations/slack/test_unfurl.py index 04e85bac98f674..5ce347cd4cc9b7 100644 --- a/tests/sentry/integrations/slack/test_unfurl.py +++ b/tests/sentry/integrations/slack/test_unfurl.py @@ -23,6 +23,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.helpers import install_slack from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba from sentry.types.group import PriorityLevel from sentry.workflow_engine.migration_helpers.alert_rule import migrate_alert_rule @@ -196,6 +197,7 @@ def test_match_link(url, expected) -> None: assert match_link(url) == expected +@with_feature("organizations:visibility-explore-view") class UnfurlTest(TestCase): def setUp(self) -> None: super().setUp() @@ -1135,8 +1137,7 @@ def test_unfurl_explore( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -1149,28 +1150,6 @@ def test_unfurl_explore( chart_data = mock_generate_chart.call_args[0][1] assert "timeSeries" in chart_data - @patch( - "sentry.integrations.slack.unfurl.explore.client.get", - ) - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_explore_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - url = f"https://sentry.io/organizations/{self.organization.slug}/explore/traces/?aggregateField=%7B%22yAxes%22%3A%5B%22avg(span.duration)%22%5D%7D&project={self.project.id}&statsPeriod=24h" - link_type, args = match_link(url) - - if not args or not link_type: - raise AssertionError("Missing link_type/args") - - links = [ - UnfurlableUrl(url=url, args=args), - ] - - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - assert len(unfurls) == 0 - assert len(mock_generate_chart.mock_calls) == 0 - @patch( "sentry.integrations.slack.unfurl.explore.client.get", ) @@ -1189,8 +1168,7 @@ def test_unfurl_explore_with_groupby( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1221,8 +1199,7 @@ def test_unfurl_explore_forwards_multiple_groupbys_to_api( raise AssertionError("Missing link_type/args") links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) mock_view.assert_called_once() request = mock_view.call_args[0][0] @@ -1248,8 +1225,7 @@ def test_unfurl_explore_with_groupby_explicit_sort( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 @@ -1277,8 +1253,7 @@ def test_unfurl_explore_default_yaxis( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1305,8 +1280,7 @@ def test_unfurl_explore_malformed_aggregate_field( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Should still unfurl with default yAxis assert len(unfurls) == 1 @@ -1341,8 +1315,7 @@ def test_unfurl_explore_end_to_end( # Step 2: Run handler links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Step 3: Verify events-timeseries was called with correct args assert mock_client_get.call_count == 1 @@ -1402,8 +1375,7 @@ def test_unfurl_explore_with_visualize_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1429,8 +1401,7 @@ def test_unfurl_explore_without_chart_type_defaults_to_line( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1456,8 +1427,7 @@ def test_unfurl_explore_skips_unsupported_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} # Skip happens before the events-timeseries call, so neither the API @@ -1486,8 +1456,7 @@ def test_unfurl_explore_without_chart_type_count_defaults_to_bar( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1729,8 +1698,7 @@ def test_unfurl_explore_with_interval( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -1880,8 +1848,7 @@ def test_unfurl_explore_logs( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2002,8 +1969,7 @@ def test_unfurl_explore_logs_customer_domain( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -2030,8 +1996,7 @@ def test_unfurl_explore_metrics( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2075,8 +2040,7 @@ def test_unfurl_explore_metrics_skips_hidden_charts( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2112,8 +2076,7 @@ def test_unfurl_explore_metrics_all_hidden_returns_no_unfurl( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} assert mock_generate_chart.call_count == 0 @@ -2171,8 +2134,7 @@ def test_unfurl_dashboards_spans_widget( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2213,36 +2175,11 @@ def test_unfurl_dashboards_customer_domain( assert args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_generate_chart.call_count == 1 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_dashboards_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - dashboard, _ = self._create_spans_widget() - - url = ( - f"https://sentry.io/organizations/{self.organization.slug}" - f"/dashboard/{dashboard.id}/widget/0/?statsPeriod=7d" - ) - link_type, args = match_link(url) - - assert link_type == LinkType.DASHBOARDS - assert args is not None - - links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - - assert len(unfurls) == 0 - assert mock_generate_chart.call_count == 0 - assert mock_client_get.call_count == 0 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") @patch("sentry.charts.backend.generate_chart", return_value="chart-url") def test_unfurl_dashboards_unsupported_widget_type_is_skipped( @@ -2273,8 +2210,7 @@ def test_unfurl_dashboards_unsupported_widget_type_is_skipped( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2298,8 +2234,7 @@ def test_unfurl_dashboards_unsupported_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2320,8 +2255,7 @@ def test_unfurl_dashboards_widget_not_found( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2372,8 +2306,7 @@ def test_unfurl_dashboards_multiple_queries_are_joined( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_client_get.call_count == 2 @@ -2430,8 +2363,7 @@ def test_unfurl_dashboards_multi_query_same_aggregate( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2489,8 +2421,7 @@ def grouped_response(group_value: str): assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2518,8 +2449,7 @@ def test_unfurl_dashboards_bar_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index fc3bb427542b35..d7b6946d46e967 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -93,7 +93,7 @@ def share_explore_links_ephemeral_sdk(self, mock_match_link, mock_): return self.mock_post.call_args[1] def test_share_explore_links_unlinked_user(self) -> None: - with self.feature("organizations:data-browsing-widget-unfurl"): + with self.feature("organizations:visibility-explore-view"): data = self.share_explore_links_ephemeral_sdk() blocks = orjson.loads(data["blocks"]) diff --git a/tests/sentry/seer/agent/test_agent_client.py b/tests/sentry/seer/agent/test_agent_client.py index 7319535b815498..1c479457fe59de 100644 --- a/tests/sentry/seer/agent/test_agent_client.py +++ b/tests/sentry/seer/agent/test_agent_client.py @@ -75,6 +75,8 @@ def test_start_run_basic(self, mock_collect_context, mock_post, mock_access): assert run_id == 123 mock_collect_context.assert_called_once_with(self.user, self.organization, request=None) assert mock_post.called + body = mock_post.call_args[0][0] + assert "enable_frontend_code_search" not in body @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @@ -139,12 +141,14 @@ def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_a client = SeerAgentClient( self.organization, self.user, category_key="bug-fixer", category_value="issue-123" ) - run_id = client.start_run("Fix bug") + with self.feature("organizations:seer-agent-source-code-search"): + run_id = client.start_run("Fix bug") assert run_id == 999 body = mock_post.call_args[0][0] assert body["category_key"] == "bug-fixer" assert body["category_value"] == "issue-123" + assert body["enable_frontend_code_search"] is True @patch("sentry.seer.agent.client.has_seer_access_with_detail") def test_init_category_key_only_raises_error(self, mock_access): @@ -274,6 +278,8 @@ def test_continue_run_basic(self, mock_post, mock_access): assert run_id == 456 assert mock_post.called + body = mock_post.call_args[0][0] + assert "enable_frontend_code_search" not in body @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @@ -286,11 +292,14 @@ def test_continue_run_with_all_params(self, mock_post, mock_access): mock_post.return_value = mock_response client = SeerAgentClient(self.organization, self.user) - run_id = client.continue_run(789, "Follow up", insert_index=2, on_page_context="context") + with self.feature("organizations:seer-agent-source-code-search"): + run_id = client.continue_run( + 789, "Follow up", insert_index=2, on_page_context="context" + ) assert run_id == 789 - call_args = mock_post.call_args - assert call_args is not None + body = mock_post.call_args[0][0] + assert body["enable_frontend_code_search"] is True @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") diff --git a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py index 6a02f7039dc9ca..b9f6434c62fd60 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py @@ -63,6 +63,16 @@ def test_org_level_method_get_organization_project_ids(self) -> None: project_ids = [p["id"] for p in response.data["projects"]] assert self.project.id in project_ids + @with_feature("organizations:seer-public-rpc") + def test_org_level_method_get_organization_features(self) -> None: + """Test that get_organization_features returns the features key""" + path = self._get_path("get_organization_features") + response = self.client.post(path, data={"args": {}}, format="json") + + assert response.status_code == 200 + assert "features" in response.data + assert isinstance(response.data["features"], list) + @with_feature("organizations:seer-public-rpc") def test_org_level_method_get_dsn(self) -> None: project = self.create_project(organization=self.organization, slug="wordcraft") diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index 5ee70fbad72fad..f56be2ee5ceb51 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -25,6 +25,7 @@ generate_request_signature, get_attributes_for_span, get_github_enterprise_integration_config, + get_organization_features, get_project_preferences, get_repo_installation_id, has_repo_code_mappings, @@ -69,6 +70,17 @@ def test_404(self) -> None: ) assert response.status_code == 404 + def test_get_organization_features_registered_on_internal_rpc(self) -> None: + org = self.create_organization() + path = self._get_path("get_organization_features") + data: dict[str, Any] = {"args": {"org_id": org.id}, "meta": {}} + response = self.client.post( + path, data=data, HTTP_AUTHORIZATION=self.auth_header(path, data) + ) + assert response.status_code == 200 + assert "features" in response.data + assert isinstance(response.data["features"], list) + def test_snuba_rate_limit_returns_429(self) -> None: """Test that SnubaRPCRateLimitExceeded returns 429 to Seer for retry.""" path = self._get_path("get_trace_waterfall") @@ -1616,6 +1628,62 @@ def test_bulk_get_project_preferences_returns_empty_for_no_projects(self) -> Non assert result == {} +# Two real api_expose=True flags used as a controlled feature set for +# get_organization_features tests. Mocking features.all to this subset keeps +# each test deterministic instead of iterating all 100+ registered flags. +_ORG_FEATURES_TEST_SET = { + "organizations:seer-agent-source-code-search": object(), + "organizations:seer-explorer-chat-coding": object(), +} + + +class TestGetOrganizationFeatures(APITestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(owner=self.user) + + @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET) + def test_returns_active_flags_without_prefix(self, _mock_all: object) -> None: + with self.feature("organizations:seer-agent-source-code-search"): + result = get_organization_features(org_id=self.organization.id) + assert result == {"features": ["seer-agent-source-code-search"]} + + @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET) + def test_excludes_inactive_flags(self, _mock_all: object) -> None: + result = get_organization_features(org_id=self.organization.id) + assert result == {"features": []} + + @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET) + def test_returns_sorted_list(self, _mock_all: object) -> None: + with self.feature( + { + "organizations:seer-agent-source-code-search": True, + "organizations:seer-explorer-chat-coding": True, + } + ): + result = get_organization_features(org_id=self.organization.id) + # "seer-agent-..." < "seer-explorer-..." alphabetically + assert result == { + "features": ["seer-agent-source-code-search", "seer-explorer-chat-coding"] + } + + def test_org_not_found_returns_empty(self) -> None: + result = get_organization_features(org_id=0) + assert result == {"features": []} + + @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET) + def test_uses_user_as_actor_when_provided(self, _mock_all: object) -> None: + with self.feature("organizations:seer-agent-source-code-search"): + result = get_organization_features(org_id=self.organization.id, user_id=self.user.id) + assert result == {"features": ["seer-agent-source-code-search"]} + + @patch("sentry.seer.endpoints.seer_rpc.features.all", return_value=_ORG_FEATURES_TEST_SET) + def test_unknown_user_id_falls_back_to_no_actor(self, _mock_all: object) -> None: + with self.feature("organizations:seer-agent-source-code-search"): + result = get_organization_features(org_id=self.organization.id, user_id=0) + assert result == {"features": ["seer-agent-source-code-search"]} + + class TestTriggerCodingAgentLaunch: @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") def test_not_found_returns_integration_not_found_error_code(self, mock_launch):